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/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/overview_form.rb b/app/models/account/overview_form.rb new file mode 100644 index 00000000..089711c8 --- /dev/null +++ b/app/models/account/overview_form.rb @@ -0,0 +1,87 @@ +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) + 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 index 6b6f1adc..dc6df605 100644 --- a/app/models/account/reconcileable.rb +++ b/app/models/account/reconcileable.rb @@ -17,39 +17,63 @@ module Account::Reconcileable 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:, date:) raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance raise InvalidBalanceError, "Linked accounts cannot be reconciled" if linked? - existing_valuation = valuations.joins(:entry).where(kind: "recon", entry: { date: Date.current }).first + existing_valuation = valuations.joins(:entry).where(kind: "recon", entry: { date: date }).first - if existing_valuation.present? - existing_valuation.update!( - balance: balance, - cash_balance: cash_balance - ) - else - entries.create!( - date: date, - name: Valuation::Name.new("recon", self.accountable_type), - amount: balance, - currency: self.currency, - entryable: Valuation.new( - kind: "recon", + transaction do + if existing_valuation.present? + existing_valuation.update!( balance: balance, cash_balance: cash_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: cash_balance + ) + ) + end + + # Update cached balance fields on account when reconciling for current date + if date == Date.current + update!(balance: balance, cash_balance: cash_balance) + end end end - def update_current_balance(balance:, cash_balance:) + def update_current_balance!(balance:, cash_balance:) raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance - if opening_anchor_valuation.present? && valuations.where(kind: "recon").empty? - adjust_opening_balance_with_delta(balance:, cash_balance:) - else - reconcile_balance!(balance:, cash_balance:, date: Date.current) + transaction do + if opening_anchor_valuation.present? && valuations.where(kind: "recon").empty? + adjust_opening_balance_with_delta(balance:, cash_balance:) + else + reconcile_balance!(balance:, cash_balance:, date: Date.current) + end + + # Always update cached balance fields when updating current balance + update!(balance: balance, cash_balance: cash_balance) end end @@ -100,7 +124,7 @@ module Account::Reconcileable private def opening_anchor_valuation - valuations.opening_anchor.first + @opening_anchor_valuation ||= valuations.opening_anchor.includes(:entry).first end def current_anchor_valuation diff --git a/app/models/family/account_creatable.rb b/app/models/family/account_creatable.rb index 8ab6985c..9aa575d9 100644 --- a/app/models/family/account_creatable.rb +++ b/app/models/family/account_creatable.rb @@ -1,7 +1,7 @@ module Family::AccountCreatable extend ActiveSupport::Concern - def create_property_account!(name:, current_value:, purchase_price: nil, purchase_date: nil, currency: nil) + 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, @@ -9,11 +9,12 @@ module Family::AccountCreatable accountable_type: Property, opening_balance: purchase_price, opening_date: purchase_date, - currency: currency + currency: currency, + draft: draft ) end - def create_vehicle_account!(name:, current_value:, purchase_price: nil, purchase_date: nil, currency: nil) + 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, @@ -21,23 +22,25 @@ module Family::AccountCreatable accountable_type: Vehicle, opening_balance: purchase_price, opening_date: purchase_date, - currency: currency + currency: currency, + draft: draft ) end - def create_depository_account!(name:, current_balance:, opening_date: nil, currency: nil) + 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 + 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) + def create_investment_account!(name:, currency: nil, draft: false) create_manual_account!( name: name, balance: 0, @@ -45,11 +48,12 @@ module Family::AccountCreatable accountable_type: Investment, opening_balance: 0, # Investment accounts start empty opening_cash_balance: 0, - currency: currency + currency: currency, + draft: draft ) end - def create_other_asset_account!(name:, current_value:, purchase_price: nil, purchase_date: nil, currency: nil) + 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, @@ -57,11 +61,12 @@ module Family::AccountCreatable accountable_type: OtherAsset, opening_balance: purchase_price, opening_date: purchase_date, - currency: currency + currency: currency, + draft: draft ) end - def create_other_liability_account!(name:, current_debt:, original_debt: nil, origination_date: nil, currency: nil) + 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, @@ -69,12 +74,13 @@ module Family::AccountCreatable accountable_type: OtherLiability, opening_balance: original_debt, opening_date: origination_date, - currency: currency + 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) + def create_crypto_account!(name:, current_value:, currency: nil, draft: false) create_manual_account!( name: name, balance: current_value, @@ -82,11 +88,12 @@ module Family::AccountCreatable accountable_type: Crypto, opening_balance: current_value, opening_cash_balance: current_value, - currency: currency + currency: currency, + draft: draft ) end - def create_credit_card_account!(name:, current_debt:, opening_date: nil, currency: nil) + def create_credit_card_account!(name:, current_debt:, opening_date: nil, currency: nil, draft: false) create_manual_account!( name: name, balance: current_debt, @@ -94,11 +101,12 @@ module Family::AccountCreatable accountable_type: CreditCard, opening_balance: 0, # Credit cards typically start with no debt opening_date: opening_date, - currency: currency + currency: currency, + draft: draft ) end - def create_loan_account!(name:, current_principal:, original_principal: nil, origination_date: nil, currency: nil) + 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, @@ -106,7 +114,8 @@ module Family::AccountCreatable accountable_type: Loan, opening_balance: original_principal, opening_date: origination_date, - currency: currency + currency: currency, + draft: draft ) end @@ -128,14 +137,15 @@ module Family::AccountCreatable private - def create_manual_account!(name:, balance:, cash_balance:, accountable_type:, opening_balance: nil, opening_cash_balance: nil, opening_date: nil, currency: nil) + 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 + accountable: accountable_type.new, + status: draft ? "draft" : "active" ) account.set_or_update_opening_balance!( 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/views/properties/_form_tabs.html.erb b/app/views/properties/_form_tabs.html.erb index 0693c7c9..9e8b2e6b 100644 --- a/app/views/properties/_form_tabs.html.erb +++ b/app/views/properties/_form_tabs.html.erb @@ -2,6 +2,6 @@