1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-23 07:09:39 +02:00

Update properties controller to use new creational and update balance methods

This commit is contained in:
Zach Gollwitzer 2025-07-09 22:28:07 -04:00
parent d459ebdad8
commit 25f0c78c47
17 changed files with 500 additions and 144 deletions

View file

@ -1,31 +1,49 @@
class PropertiesController < ApplicationController class PropertiesController < ApplicationController
include AccountableResource, StreamExtensions 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 def new
@account = Current.family.accounts.build(accountable: Property.new) @account = Current.family.accounts.build(accountable: Property.new)
end end
def create def create
@account = Current.family.accounts.create!( @account = Current.family.create_property_account!(
property_params.merge(currency: Current.family.currency, balance: 0, status: "draft") 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 end
def update 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." @success_message = "Property details updated successfully."
if @account.active? if @account.active?
render :edit render :edit
else else
redirect_to balances_property_path(@account) redirect_to details_property_path(@account)
end end
else else
@error_message = "Unable to update property details." @error_message = result.error || "Unable to update property details."
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@ -33,26 +51,25 @@ class PropertiesController < ApplicationController
def edit def edit
end end
def balances def details
end end
def update_balances def update_details
result = @account.update_balance(balance: balance_params[:balance], currency: balance_params[:currency]) if @account.update(details_params)
@success_message = "Property details updated successfully."
if result.success?
@success_message = result.updated? ? "Balance updated successfully." : "No changes made. Account is already up to date."
if @account.active? if @account.active?
render :balances render :details
else else
redirect_to address_property_path(@account) redirect_to address_property_path(@account)
end end
else else
@error_message = result.error_message @error_message = "Unable to update property details."
render :balances, status: :unprocessable_entity render :details, status: :unprocessable_entity
end end
end end
def address def address
@property = @account.property @property = @account.property
@property.address ||= Address.new @property.address ||= Address.new
@ -78,8 +95,9 @@ class PropertiesController < ApplicationController
end end
private private
def balance_params def details_params
params.require(:account).permit(:balance, :currency) params.require(:account)
.permit(:subtype, accountable_attributes: [ :id, :year_built, :area_unit, :area_value ])
end end
def address_params def address_params
@ -89,7 +107,9 @@ class PropertiesController < ApplicationController
def property_params def property_params
params.require(:account) 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 end
def set_property def set_property

View file

@ -5,10 +5,15 @@ import { CurrenciesService } from "services/currencies_service";
// when currency select change, update the input value with the correct placeholder and step // when currency select change, update the input value with the correct placeholder and step
export default class extends Controller { export default class extends Controller {
static targets = ["amount", "currency", "symbol"]; static targets = ["amount", "currency", "symbol"];
static values = { syncCurrency: Boolean };
handleCurrencyChange(e) { handleCurrencyChange(e) {
const selectedCurrency = e.target.value; const selectedCurrency = e.target.value;
this.updateAmount(selectedCurrency); this.updateAmount(selectedCurrency);
if (this.syncCurrencyValue) {
this.syncOtherMoneyFields(selectedCurrency);
}
} }
updateAmount(currency) { updateAmount(currency) {
@ -24,4 +29,28 @@ export default class extends Controller {
this.symbolTarget.innerText = currency.symbol; 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);
}
});
}
} }

View file

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

View file

@ -17,12 +17,25 @@ module Account::Reconcileable
balance - cash_balance balance - cash_balance
end 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:) def reconcile_balance!(balance:, cash_balance:, date:)
raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance
raise InvalidBalanceError, "Linked accounts cannot be reconciled" if linked? 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
transaction do
if existing_valuation.present? if existing_valuation.present?
existing_valuation.update!( existing_valuation.update!(
balance: balance, balance: balance,
@ -41,16 +54,27 @@ module Account::Reconcileable
) )
) )
end 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 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 raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance
transaction do
if opening_anchor_valuation.present? && valuations.where(kind: "recon").empty? if opening_anchor_valuation.present? && valuations.where(kind: "recon").empty?
adjust_opening_balance_with_delta(balance:, cash_balance:) adjust_opening_balance_with_delta(balance:, cash_balance:)
else else
reconcile_balance!(balance:, cash_balance:, date: Date.current) reconcile_balance!(balance:, cash_balance:, date: Date.current)
end end
# Always update cached balance fields when updating current balance
update!(balance: balance, cash_balance: cash_balance)
end
end end
def adjust_opening_balance_with_delta(balance:, cash_balance:) def adjust_opening_balance_with_delta(balance:, cash_balance:)
@ -100,7 +124,7 @@ module Account::Reconcileable
private private
def opening_anchor_valuation def opening_anchor_valuation
valuations.opening_anchor.first @opening_anchor_valuation ||= valuations.opening_anchor.includes(:entry).first
end end
def current_anchor_valuation def current_anchor_valuation

View file

@ -1,7 +1,7 @@
module Family::AccountCreatable module Family::AccountCreatable
extend ActiveSupport::Concern 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!( create_manual_account!(
name: name, name: name,
balance: current_value, balance: current_value,
@ -9,11 +9,12 @@ module Family::AccountCreatable
accountable_type: Property, accountable_type: Property,
opening_balance: purchase_price, opening_balance: purchase_price,
opening_date: purchase_date, opening_date: purchase_date,
currency: currency currency: currency,
draft: draft
) )
end 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!( create_manual_account!(
name: name, name: name,
balance: current_value, balance: current_value,
@ -21,23 +22,25 @@ module Family::AccountCreatable
accountable_type: Vehicle, accountable_type: Vehicle,
opening_balance: purchase_price, opening_balance: purchase_price,
opening_date: purchase_date, opening_date: purchase_date,
currency: currency currency: currency,
draft: draft
) )
end 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!( create_manual_account!(
name: name, name: name,
balance: current_balance, balance: current_balance,
cash_balance: current_balance, cash_balance: current_balance,
accountable_type: Depository, accountable_type: Depository,
opening_date: opening_date, opening_date: opening_date,
currency: currency currency: currency,
draft: draft
) )
end end
# Investment account values are built up by adding holdings / trades, not by initializing a "balance" # 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!( create_manual_account!(
name: name, name: name,
balance: 0, balance: 0,
@ -45,11 +48,12 @@ module Family::AccountCreatable
accountable_type: Investment, accountable_type: Investment,
opening_balance: 0, # Investment accounts start empty opening_balance: 0, # Investment accounts start empty
opening_cash_balance: 0, opening_cash_balance: 0,
currency: currency currency: currency,
draft: draft
) )
end 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!( create_manual_account!(
name: name, name: name,
balance: current_value, balance: current_value,
@ -57,11 +61,12 @@ module Family::AccountCreatable
accountable_type: OtherAsset, accountable_type: OtherAsset,
opening_balance: purchase_price, opening_balance: purchase_price,
opening_date: purchase_date, opening_date: purchase_date,
currency: currency currency: currency,
draft: draft
) )
end 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!( create_manual_account!(
name: name, name: name,
balance: current_debt, balance: current_debt,
@ -69,12 +74,13 @@ module Family::AccountCreatable
accountable_type: OtherLiability, accountable_type: OtherLiability,
opening_balance: original_debt, opening_balance: original_debt,
opening_date: origination_date, opening_date: origination_date,
currency: currency currency: currency,
draft: draft
) )
end end
# For now, crypto accounts are very simple; we just track overall value # 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!( create_manual_account!(
name: name, name: name,
balance: current_value, balance: current_value,
@ -82,11 +88,12 @@ module Family::AccountCreatable
accountable_type: Crypto, accountable_type: Crypto,
opening_balance: current_value, opening_balance: current_value,
opening_cash_balance: current_value, opening_cash_balance: current_value,
currency: currency currency: currency,
draft: draft
) )
end 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!( create_manual_account!(
name: name, name: name,
balance: current_debt, balance: current_debt,
@ -94,11 +101,12 @@ module Family::AccountCreatable
accountable_type: CreditCard, accountable_type: CreditCard,
opening_balance: 0, # Credit cards typically start with no debt opening_balance: 0, # Credit cards typically start with no debt
opening_date: opening_date, opening_date: opening_date,
currency: currency currency: currency,
draft: draft
) )
end 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!( create_manual_account!(
name: name, name: name,
balance: current_principal, balance: current_principal,
@ -106,7 +114,8 @@ module Family::AccountCreatable
accountable_type: Loan, accountable_type: Loan,
opening_balance: original_principal, opening_balance: original_principal,
opening_date: origination_date, opening_date: origination_date,
currency: currency currency: currency,
draft: draft
) )
end end
@ -128,14 +137,15 @@ module Family::AccountCreatable
private 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 Family.transaction do
account = accounts.create!( account = accounts.create!(
name: name, name: name,
balance: balance, balance: balance,
cash_balance: cash_balance, cash_balance: cash_balance,
currency: currency.presence || self.currency, currency: currency.presence || self.currency,
accountable: accountable_type.new accountable: accountable_type.new,
status: draft ? "draft" : "active"
) )
account.set_or_update_opening_balance!( account.set_or_update_opening_balance!(

View file

@ -52,6 +52,7 @@ class Property < ApplicationRecord
private private
def first_valuation_amount def first_valuation_amount
return nil unless account
account.entries.valuations.order(:date).first&.amount_money || account.balance_money account.entries.valuations.order(:date).first&.amount_money || account.balance_money
end end
end end

View file

@ -2,6 +2,6 @@
<div class="flex flex-col gap-0.5 w-[156px] shrink-0"> <div class="flex flex-col gap-0.5 w-[156px] shrink-0">
<%= render "properties/form_tab", label: "Overview", href: account.new_record? ? nil : edit_property_path(@account), active: active_tab == "overview" %> <%= render "properties/form_tab", label: "Overview", href: account.new_record? ? nil : edit_property_path(@account), active: active_tab == "overview" %>
<%= render "properties/form_tab", label: "Value", href: account.new_record? ? nil : balances_property_path(@account), active: active_tab == "value" %> <%= render "properties/form_tab", label: "Details", href: account.new_record? ? nil : details_property_path(@account), active: active_tab == "details" %>
<%= render "properties/form_tab", label: "Address", href: account.new_record? ? nil : address_property_path(@account), active: active_tab == "address" %> <%= render "properties/form_tab", label: "Address", href: account.new_record? ? nil : address_property_path(@account), active: active_tab == "address" %>
</div> </div>

View file

@ -0,0 +1,30 @@
<%# locals: (form:, account:) %>
<div class="flex flex-col gap-2">
<%= form.text_field :name,
label: "Name",
placeholder: "Vacation home",
required: true %>
<%= form.money_field :current_estimated_value,
label: "Current estimated value",
label_tooltip: "The estimated market value of your property. This number can often be found on sites like Zillow or Redfin, and is never an exact number.",
placeholder: Money.new(0, form.object.currency || Current.family.currency),
value: account.balance,
required: true,
sync_currency: true %>
<%= form.money_field :purchase_price,
label: "Purchase price",
label_tooltip: "The amount you paid when you purchased the property. Leave blank if unknown.",
placeholder: Money.new(0, form.object.currency || Current.family.currency),
value: account.opening_balance,
sync_currency: true %>
<%= form.date_field :purchase_date,
label: "Purchase date",
label_tooltip: "The date you purchased the property. This helps track your property's value over time.",
value: account.opening_date %>
<%= form.hidden_field :current_cash_balance, value: 0 %>
</div>

View file

@ -1,30 +0,0 @@
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: "Enter property manually") %>
<% dialog.with_body do %>
<div class="flex gap-4">
<%= render "properties/form_tabs", account: @account, active_tab: "value" %>
<!-- Right content area with form -->
<div class="flex-1">
<%= styled_form_with model: @account, url: update_balances_property_path(@account), method: :patch do |form| %>
<div class="flex flex-col gap-4 min-h-[320px]">
<%= render "properties/form_alert", notice: @success_message, error: @error_message %>
<%= form.money_field :balance,
label: "Estimated market value",
label_tooltip: "The estimated market value of your property. This number can often be found on sites like Zillow or Redfin, and is never an exact number.",
placeholder: "0" %>
</div>
<!-- Next button -->
<div class="flex justify-end mt-4">
<%= render ButtonComponent.new(
text: @account.active? ? "Save" : "Next",
variant: "primary",
) %>
</div>
<% end %>
</div>
</div>
<% end %>
<% end %>

View file

@ -0,0 +1,50 @@
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: "Enter property manually") %>
<% dialog.with_body do %>
<div class="flex gap-4">
<!-- Left sidebar with tabs -->
<%= render "properties/form_tabs", account: @account, active_tab: "details" %>
<!-- Right content area with form -->
<div class="flex-1">
<%= styled_form_with model: @account, url: update_details_property_path(@account), method: :patch do |form| %>
<div class="flex flex-col gap-4 min-h-[320px]">
<%= render "properties/form_alert", notice: @success_message, error: @error_message %>
<%= form.select :subtype,
options_for_select(Property::SUBTYPES.map { |k, v| [v[:long], k] }, @account.subtype),
{ label: "Property type" },
{ class: "form-field__input" } %>
<%= form.fields_for :accountable do |property_form| %>
<div class="flex items-center gap-2">
<%= property_form.number_field :area_value,
label: "Area",
placeholder: "1200",
min: 0 %>
<%= property_form.select :area_unit,
[["Square Feet", "sqft"], ["Square Meters", "sqm"]],
{ label: "Area unit", selected: @account.accountable.area_unit } %>
</div>
<%= property_form.number_field :year_built,
label: "Year built",
placeholder: "2000",
step: 1,
min: 1800,
max: Date.current.year %>
<% end %>
</div>
<!-- Next/Save button -->
<div class="flex justify-end mt-4">
<%= render ButtonComponent.new(
text: @account.active? ? "Save" : "Next",
variant: "primary",
) %>
</div>
<% end %>
</div>
</div>
<% end %>
<% end %>

View file

@ -10,7 +10,7 @@
<%= styled_form_with model: @account, url: property_path(@account), method: :patch do |form| %> <%= styled_form_with model: @account, url: property_path(@account), method: :patch do |form| %>
<div class="flex flex-col gap-2 min-h-[320px]"> <div class="flex flex-col gap-2 min-h-[320px]">
<%= render "properties/form_alert", notice: @success_message, error: @error_message %> <%= render "properties/form_alert", notice: @success_message, error: @error_message %>
<%= render "properties/overview_fields", form: form %> <%= render "properties/property_overview_fields", form: form, account: @account %>
</div> </div>
<!-- Save button --> <!-- Save button -->

View file

@ -10,7 +10,7 @@
<%= styled_form_with model: @account, url: properties_path do |form| %> <%= styled_form_with model: @account, url: properties_path do |form| %>
<div class="flex flex-col gap-2 min-h-[320px]"> <div class="flex flex-col gap-2 min-h-[320px]">
<%= render "properties/form_alert", notice: @success_message, error: @error_message %> <%= render "properties/form_alert", notice: @success_message, error: @error_message %>
<%= render "properties/overview_fields", form: form %> <%= render "properties/property_overview_fields", form: form, account: @account %>
</div> </div>
<!-- Create button --> <!-- Create button -->

View file

@ -7,7 +7,7 @@
end end
currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %> currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %>
<div class="form-field <%= options[:container_class] %>" data-controller="money-field"> <div class="form-field <%= options[:container_class] %>" data-controller="money-field" <%= "data-money-field-sync-currency-value=\"true\"" if options[:sync_currency] %>>
<% if options[:label_tooltip] %> <% if options[:label_tooltip] %>
<div class="form-field__header"> <div class="form-field__header">
<%= form.label options[:label] || t(".label"), class: "form-field__label" do %> <%= form.label options[:label] || t(".label"), class: "form-field__label" do %>

View file

@ -170,8 +170,8 @@ Rails.application.routes.draw do
resources :investments, except: :index resources :investments, except: :index
resources :properties, except: :index do resources :properties, except: :index do
member do member do
get :balances get :details
patch :update_balances patch :update_details
get :address get :address
patch :update_address patch :update_address

View file

@ -8,18 +8,15 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
@account = accounts(:property) @account = accounts(:property)
end end
test "creates property in draft status and redirects to balances step" do test "creates property in draft status with initial balance information and redirects to details step" do
assert_difference -> { Account.count } => 1 do assert_difference -> { Account.count } => 1 do
post properties_path, params: { post properties_path, params: {
account: { account: {
name: "New Property", name: "New Property",
subtype: "house", purchase_price: "250000",
accountable_type: "Property", purchase_date: "2023-01-01",
accountable_attributes: { current_estimated_value: "300000",
year_built: 1990, currency: "USD"
area_value: 1200,
area_unit: "sqft"
}
} }
} }
end end
@ -27,38 +24,47 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
created_account = Account.order(:created_at).last created_account = Account.order(:created_at).last
assert created_account.accountable.is_a?(Property) assert created_account.accountable.is_a?(Property)
assert_equal "draft", created_account.status assert_equal "draft", created_account.status
assert_equal 0, created_account.balance assert_equal "New Property", created_account.name
assert_equal 1990, created_account.accountable.year_built assert_equal 300_000, created_account.balance
assert_equal 1200, created_account.accountable.area_value assert_equal 0, created_account.cash_balance
assert_equal "sqft", created_account.accountable.area_unit assert_equal "USD", created_account.currency
assert_redirected_to balances_property_path(created_account)
# Check opening balance was set
opening_valuation = created_account.valuations.opening_anchor.first
assert_not_nil opening_valuation
assert_equal 250_000, opening_valuation.balance
assert_equal Date.parse("2023-01-01"), opening_valuation.entry.date
assert_redirected_to details_property_path(created_account)
end end
test "updates property overview" do test "updates property overview with balance information" do
assert_no_difference [ "Account.count", "Property.count" ] do assert_no_difference [ "Account.count", "Property.count" ] do
patch property_path(@account), params: { patch property_path(@account), params: {
account: { account: {
name: "Updated Property", name: "Updated Property",
subtype: "condo" current_estimated_value: "350000",
currency: "USD"
} }
} }
end end
@account.reload @account.reload
assert_equal "Updated Property", @account.name assert_equal "Updated Property", @account.name
assert_equal "condo", @account.subtype assert_equal 350_000, @account.balance
assert_equal 0, @account.cash_balance
# If account is active, it renders edit view; otherwise redirects to balances # If account is active, it renders edit view; otherwise redirects to details
if @account.active? if @account.active?
assert_response :success assert_response :success
else else
assert_redirected_to balances_property_path(@account) assert_redirected_to details_property_path(@account)
end end
end end
# Tab view tests # Tab view tests
test "shows balances tab" do test "shows details tab" do
get balances_property_path(@account) get details_property_path(@account)
assert_response :success assert_response :success
end end
@ -68,22 +74,26 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
end end
# Tab update tests # Tab update tests
test "updates balances tab" do test "updates property details" do
original_balance = @account.balance patch update_details_property_path(@account), params: {
# Mock the update_balance method to return a successful result
Account::BalanceUpdater::Result.any_instance.stubs(:success?).returns(true)
Account::BalanceUpdater::Result.any_instance.stubs(:updated?).returns(true)
patch update_balances_property_path(@account), params: {
account: { account: {
balance: 600000, subtype: "condo",
currency: "EUR" accountable_attributes: {
year_built: 2005,
area_value: 1500,
area_unit: "sqft"
}
} }
} }
# If account is active, it renders balances view; otherwise redirects to address @account.reload
if @account.reload.active? assert_equal "condo", @account.subtype
assert_equal 2005, @account.accountable.year_built
assert_equal 1500, @account.accountable.area_value
assert_equal "sqft", @account.accountable.area_unit
# If account is active, it renders details view; otherwise redirects to address
if @account.active?
assert_response :success assert_response :success
else else
assert_redirected_to address_property_path(@account) assert_redirected_to address_property_path(@account)
@ -115,20 +125,6 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
end end
end end
test "balances update handles validation errors" do
# Mock update_balance to return a failure result
Account::BalanceUpdater::Result.any_instance.stubs(:success?).returns(false)
Account::BalanceUpdater::Result.any_instance.stubs(:error_message).returns("Invalid balance")
patch update_balances_property_path(@account), params: {
account: {
balance: 600000,
currency: "EUR"
}
}
assert_response :unprocessable_entity
end
test "address update handles validation errors" do test "address update handles validation errors" do
Property.any_instance.stubs(:update).returns(false) Property.any_instance.stubs(:update).returns(false)

View file

@ -0,0 +1,139 @@
require "test_helper"
class Account::OverviewFormTest < ActiveSupport::TestCase
setup do
@account = accounts(:property)
end
test "initializes with account and attributes" do
form = Account::OverviewForm.new(
account: @account,
name: "Updated Property"
)
assert_equal @account, form.account
assert_equal "Updated Property", form.name
end
test "save returns result with success and updated status" do
form = Account::OverviewForm.new(account: @account)
result = form.save
assert result.success?
assert_not result.updated?
end
test "updates account name when provided" do
form = Account::OverviewForm.new(
account: @account,
name: "New Property Name"
)
@account.expects(:update!).with(name: "New Property Name").once
@account.expects(:sync_later).never # Name change should not trigger sync
result = form.save
assert result.success?
assert result.updated?
end
test "updates currency and triggers sync" do
form = Account::OverviewForm.new(
account: @account,
currency: "EUR"
)
@account.expects(:update_currency!).with("EUR").once
@account.expects(:sync_later).once # Currency change should trigger sync
result = form.save
assert result.success?
assert result.updated?
end
test "calls sync_later only once for multiple balance-related changes" do
form = Account::OverviewForm.new(
account: @account,
currency: "EUR",
opening_balance: 100_000,
opening_cash_balance: 0,
current_balance: 150_000,
current_cash_balance: 0
)
@account.expects(:update_currency!).with("EUR").once
@account.expects(:set_or_update_opening_balance!).once
@account.expects(:update_current_balance!).once
@account.expects(:sync_later).once # Should only be called once despite multiple changes
result = form.save
assert result.success?
assert result.updated?
end
test "does not call sync_later when transaction fails" do
form = Account::OverviewForm.new(
account: @account,
name: "New Name",
opening_balance: 100_000,
opening_cash_balance: 0
)
# Simulate a validation error on opening balance update
@account.expects(:update!).with(name: "New Name").once
@account.expects(:set_or_update_opening_balance!).raises(Account::Reconcileable::InvalidBalanceError.new("Cash balance cannot exceed balance"))
@account.expects(:sync_later).never # Should NOT sync if any update fails
result = form.save
assert_not result.success?
assert_not result.updated?
assert_equal "Cash balance cannot exceed balance", result.error
end
test "raises ArgumentError when balance fields are not properly paired" do
# Opening balance without cash balance
form = Account::OverviewForm.new(
account: @account,
opening_balance: 100_000
)
# Debug what values we have
assert_equal 100_000.to_d, form.opening_balance
assert_nil form.opening_cash_balance
error = assert_raises(ArgumentError) do
form.save
end
assert_equal "Both opening_balance and opening_cash_balance must be provided together", error.message
# Current cash balance without balance
form = Account::OverviewForm.new(
account: @account,
current_cash_balance: 0
)
error = assert_raises(ArgumentError) do
form.save
end
assert_equal "Both current_balance and current_cash_balance must be provided together", error.message
end
test "converts string balance values to decimals" do
form = Account::OverviewForm.new(
account: @account,
opening_balance: "100000.50",
opening_cash_balance: "0",
current_balance: "150000.75",
current_cash_balance: "5000.25"
)
assert_equal 100000.50.to_d, form.opening_balance
assert_equal 0.to_d, form.opening_cash_balance
assert_equal 150000.75.to_d, form.current_balance
assert_equal 5000.25.to_d, form.current_cash_balance
end
end

View file

@ -64,7 +64,7 @@ class Account::ReconcileableTest < ActiveSupport::TestCase
# No new valuation is appended; we're just adjusting the opening valuation anchor # No new valuation is appended; we're just adjusting the opening valuation anchor
assert_no_difference "account.entries.count" do assert_no_difference "account.entries.count" do
account.update_current_balance(balance: 1000, cash_balance: 1000) account.update_current_balance!(balance: 1000, cash_balance: 1000)
end end
opening_valuation = account.valuations.first opening_valuation = account.valuations.first
@ -113,7 +113,7 @@ class Account::ReconcileableTest < ActiveSupport::TestCase
assert_equal 2, account.valuations.count assert_equal 2, account.valuations.count
# Here, we assume user is once again "overriding" the balance to 1400 # Here, we assume user is once again "overriding" the balance to 1400
account.update_current_balance(balance: 1400, cash_balance: 1400) account.update_current_balance!(balance: 1400, cash_balance: 1400)
most_recent_valuation = account.valuations.joins(:entry).order("entries.date DESC").first most_recent_valuation = account.valuations.joins(:entry).order("entries.date DESC").first