Compare commits
13 Commits
main
...
zachgoll/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13372dea54 | ||
|
|
33a32ed994 | ||
|
|
d80cb9f812 | ||
|
|
25f0c78c47 | ||
|
|
d459ebdad8 | ||
|
|
18271ce005 | ||
|
|
3b6a5a573f | ||
|
|
a7cd046563 | ||
|
|
2e09d1a8c0 | ||
|
|
018310d4d1 | ||
|
|
6322c48848 | ||
|
|
b7acef1e7a | ||
|
|
15f8d827b5 |
@@ -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? %>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
88
app/models/account/overview_form.rb
Normal file
88
app/models/account/overview_form.rb
Normal file
@@ -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
|
||||
154
app/models/account/reconcileable.rb
Normal file
154
app/models/account/reconcileable.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ],
|
||||
|
||||
160
app/models/family/account_creatable.rb
Normal file
160
app/models/family/account_creatable.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
63
app/models/valuation/name.rb
Normal file
63
app/models/valuation/name.rb
Normal file
@@ -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
|
||||
@@ -1,10 +1,12 @@
|
||||
<%# locals: (account:, url:) %>
|
||||
|
||||
<% if @error_message.present? %>
|
||||
<%= render AlertComponent.new(message: @error_message, variant: :error) %>
|
||||
<div class="mb-4">
|
||||
<%= render AlertComponent.new(message: @error_message, variant: :error) %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= styled_form_with model: account, url: url, scope: :account, data: { turbo: false }, class: "flex flex-col gap-4 justify-between grow text-primary" do |form| %>
|
||||
<%= styled_form_with model: account, url: url, scope: :account, class: "flex flex-col gap-4 justify-between grow text-primary" do |form| %>
|
||||
<div class="grow space-y-2">
|
||||
<%= form.hidden_field :accountable_type %>
|
||||
<%= form.hidden_field :return_to, value: params[:return_to] %>
|
||||
@@ -12,7 +14,19 @@
|
||||
<%= form.text_field :name, placeholder: t(".name_placeholder"), required: "required", label: t(".name_label") %>
|
||||
|
||||
<% unless account.linked? %>
|
||||
<%= form.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %>
|
||||
<%= form.money_field :balance,
|
||||
label: t(".balance"),
|
||||
required: true,
|
||||
default_currency: Current.family.currency,
|
||||
label_tooltip: "The current balance or value of the account, which is typically the balance reported by your financial institution." %>
|
||||
|
||||
<% unless account.persisted? %>
|
||||
<%= form.date_field :tracking_start_date,
|
||||
label: "Tracking start date",
|
||||
required: true,
|
||||
value: 2.years.ago.to_date,
|
||||
label_tooltip: "The date we will start tracking the balance for this account. If you're not sure, we recommend using the default of 2 years ago so net worth graphs have adequate historical data." %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= yield form %>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: "New balance",
|
||||
text: "Account #{account.asset? ? "value" : "balance"} update",
|
||||
icon: "circle-dollar-sign",
|
||||
href: new_valuation_path(account_id: @account.id),
|
||||
data: { turbo_frame: :modal }) %>
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
<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: "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" %>
|
||||
</div>
|
||||
|
||||
30
app/views/properties/_property_overview_fields.html.erb
Normal file
30
app/views/properties/_property_overview_fields.html.erb
Normal 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>
|
||||
@@ -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 %>
|
||||
50
app/views/properties/details.html.erb
Normal file
50
app/views/properties/details.html.erb
Normal 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 %>
|
||||
@@ -10,7 +10,7 @@
|
||||
<%= styled_form_with model: @account, url: property_path(@account), method: :patch do |form| %>
|
||||
<div class="flex flex-col gap-2 min-h-[320px]">
|
||||
<%= 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>
|
||||
|
||||
<!-- Save button -->
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<%= styled_form_with model: @account, url: properties_path do |form| %>
|
||||
<div class="flex flex-col gap-2 min-h-[320px]">
|
||||
<%= 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>
|
||||
|
||||
<!-- Create button -->
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
end
|
||||
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] %>
|
||||
<div class="form-field__header">
|
||||
<%= form.label options[:label] || t(".label"), class: "form-field__label" do %>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
<div class="space-y-3">
|
||||
<%= form.hidden_field :name, value: "Balance update" %>
|
||||
<%= form.date_field :date, label: true, required: true, value: Date.current, min: Entry.min_supported_date, max: Date.current %>
|
||||
<%= form.money_field :amount, label: t(".amount"), required: true %>
|
||||
<%= form.money_field :amount, label: t(".amount"), required: true, disable_currency: true %>
|
||||
</div>
|
||||
|
||||
<%= form.submit t(".submit") %>
|
||||
<%= form.submit "Update account #{entry.account.asset? ? "value" : "balance"}" %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<% term = @entry.account.asset? ? "value" : "balance" %>
|
||||
|
||||
<%= render DialogComponent.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title")) %>
|
||||
<% dialog.with_header(title: "Account #{term} update") %>
|
||||
<% dialog.with_body do %>
|
||||
<%= render "form", entry: @entry, error_message: @error_message %>
|
||||
|
||||
<p class="text-sm text-secondary mt-4">
|
||||
This action "resets" the account's <%= term %> to the new value, on the date. Subsequent entries after this date will reference the new value.
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -170,8 +170,8 @@ Rails.application.routes.draw do
|
||||
resources :investments, except: :index
|
||||
resources :properties, except: :index do
|
||||
member do
|
||||
get :balances
|
||||
patch :update_balances
|
||||
get :details
|
||||
patch :update_details
|
||||
|
||||
get :address
|
||||
patch :update_address
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
class AddValuationKindFieldForAnchors < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
add_column :valuations, :kind, :string, default: "recon"
|
||||
add_column :valuations, :balance, :decimal, precision: 19, scale: 4
|
||||
add_column :valuations, :cash_balance, :decimal, precision: 19, scale: 4
|
||||
|
||||
# Copy `amount` from Entry, set both `balance` and `cash_balance` to the same value on all Valuation records, and `currency` from Entry to Valuation
|
||||
execute <<-SQL
|
||||
UPDATE valuations
|
||||
SET
|
||||
balance = entries.amount,
|
||||
cash_balance = entries.amount
|
||||
FROM entries
|
||||
WHERE entries.entryable_type = 'Valuation' AND entries.entryable_id = valuations.id
|
||||
SQL
|
||||
|
||||
change_column_null :valuations, :kind, false
|
||||
change_column_null :valuations, :balance, false
|
||||
change_column_null :valuations, :cash_balance, false
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :valuations, :kind
|
||||
remove_column :valuations, :balance
|
||||
remove_column :valuations, :cash_balance
|
||||
end
|
||||
end
|
||||
14
db/schema.rb
generated
14
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_07_02_173231) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_07_07_130134) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -29,7 +29,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_02_173231) do
|
||||
t.uuid "accountable_id"
|
||||
t.decimal "balance", precision: 19, scale: 4
|
||||
t.string "currency"
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.uuid "import_id"
|
||||
t.uuid "plaid_account_id"
|
||||
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
|
||||
@@ -215,12 +215,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_02_173231) do
|
||||
t.boolean "excluded", default: false
|
||||
t.string "plaid_id"
|
||||
t.jsonb "locked_attributes", default: {}
|
||||
t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date"
|
||||
t.index ["account_id"], name: "index_entries_on_account_id"
|
||||
t.index ["amount"], name: "index_entries_on_amount"
|
||||
t.index ["date"], name: "index_entries_on_date"
|
||||
t.index ["entryable_id", "entryable_type"], name: "index_entries_on_entryable"
|
||||
t.index ["excluded"], name: "index_entries_on_excluded"
|
||||
t.index ["import_id"], name: "index_entries_on_import_id"
|
||||
end
|
||||
|
||||
@@ -231,7 +226,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_02_173231) do
|
||||
t.date "date", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["date", "from_currency", "to_currency"], name: "index_exchange_rates_on_date_and_currencies"
|
||||
t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true
|
||||
t.index ["from_currency"], name: "index_exchange_rates_on_from_currency"
|
||||
t.index ["to_currency"], name: "index_exchange_rates_on_to_currency"
|
||||
@@ -689,7 +683,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_02_173231) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["tag_id"], name: "index_taggings_on_tag_id"
|
||||
t.index ["taggable_id", "taggable_type"], name: "index_taggings_on_taggable_id_and_type"
|
||||
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable"
|
||||
end
|
||||
|
||||
@@ -786,6 +779,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_02_173231) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.jsonb "locked_attributes", default: {}
|
||||
t.string "kind", default: "recon", null: false
|
||||
t.decimal "balance", precision: 19, scale: 4, null: false
|
||||
t.decimal "cash_balance", precision: 19, scale: 4, null: false
|
||||
end
|
||||
|
||||
create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
||||
@@ -111,4 +111,47 @@ namespace :data_migration do
|
||||
|
||||
puts "✅ Duplicate security migration complete."
|
||||
end
|
||||
|
||||
desc "Migrate account valuation anchors"
|
||||
# 2025-01-07: Set opening_anchor kinds for valuations to support event-sourced ledger model.
|
||||
# Manual accounts get their oldest valuation marked as opening_anchor, which acts as the
|
||||
# starting balance for the account. Current anchors are only used for Plaid accounts.
|
||||
task migrate_account_valuation_anchors: :environment do
|
||||
puts "==> Migrating account valuation anchors..."
|
||||
|
||||
manual_accounts = Account.manual.includes(valuations: :entry)
|
||||
total_accounts = manual_accounts.count
|
||||
accounts_processed = 0
|
||||
opening_anchors_set = 0
|
||||
|
||||
manual_accounts.find_each do |account|
|
||||
accounts_processed += 1
|
||||
|
||||
# Find oldest valuation for opening anchor
|
||||
oldest_valuation = account.valuations
|
||||
.joins(:entry)
|
||||
.order("entries.date ASC, entries.created_at ASC")
|
||||
.first
|
||||
|
||||
if oldest_valuation && !oldest_valuation.opening_anchor?
|
||||
derived_valuation_name = "#{account.name} Opening Balance"
|
||||
|
||||
Account.transaction do
|
||||
oldest_valuation.update!(kind: "opening_anchor")
|
||||
oldest_valuation.entry.update!(name: derived_valuation_name)
|
||||
end
|
||||
opening_anchors_set += 1
|
||||
end
|
||||
|
||||
if accounts_processed % 100 == 0
|
||||
puts "[#{accounts_processed}/#{total_accounts}] Processed #{accounts_processed} accounts..."
|
||||
end
|
||||
rescue => e
|
||||
puts "ERROR processing account #{account.id}: #{e.message}"
|
||||
end
|
||||
|
||||
puts "✅ Account valuation anchor migration complete."
|
||||
puts " Processed: #{accounts_processed} accounts"
|
||||
puts " Opening anchors set: #{opening_anchors_set}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace :demo_data do
|
||||
generator = Demo::Generator.new(seed: seed)
|
||||
generator.generate_default_data!
|
||||
|
||||
validate_demo_data!
|
||||
validate_demo_data
|
||||
|
||||
elapsed = Time.now - start
|
||||
puts "🎉 Demo data ready in #{elapsed.round(2)}s"
|
||||
@@ -37,7 +37,7 @@ namespace :demo_data do
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def validate_demo_data!
|
||||
def validate_demo_data
|
||||
total_entries = Entry.count
|
||||
trade_entries = Entry.where(entryable_type: "Trade").count
|
||||
categorized_txn = Transaction.joins(:category).count
|
||||
@@ -51,13 +51,15 @@ namespace :demo_data do
|
||||
puts "Txn categorization: #{coverage}% (>=75% ✅)"
|
||||
|
||||
unless total_entries.between?(8_000, 12_000)
|
||||
raise "Total entries #{total_entries} outside 8k–12k range"
|
||||
puts "Total entries #{total_entries} outside 8k–12k range"
|
||||
end
|
||||
|
||||
unless trade_entries.between?(500, 1000)
|
||||
raise "Trade entries #{trade_entries} outside 500–1 000 range"
|
||||
puts "Trade entries #{trade_entries} outside 500–1000 range"
|
||||
end
|
||||
|
||||
unless coverage >= 75
|
||||
raise "Categorization coverage below 75%"
|
||||
puts "Categorization coverage below 75%"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,8 +8,29 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
@family = @user.family
|
||||
@account = @family.accounts.first
|
||||
@transaction = @family.transactions.first
|
||||
@api_key = api_keys(:active_key) # Has read_write scope
|
||||
@read_only_api_key = api_keys(:one) # Has read scope
|
||||
|
||||
# Destroy existing active API keys to avoid validation errors
|
||||
@user.api_keys.active.destroy_all
|
||||
|
||||
# Create fresh API keys instead of using fixtures to avoid parallel test conflicts
|
||||
@api_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test Read-Write Key",
|
||||
scopes: [ "read_write" ],
|
||||
display_key: "test_rw_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
|
||||
@read_only_api_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test Read-Only Key",
|
||||
scopes: [ "read" ],
|
||||
display_key: "test_ro_#{SecureRandom.hex(8)}",
|
||||
source: "mobile" # Use different source to allow multiple keys
|
||||
)
|
||||
|
||||
# Clear any existing rate limit data
|
||||
Redis.new.del("api_rate_limit:#{@api_key.id}")
|
||||
Redis.new.del("api_rate_limit:#{@read_only_api_key.id}")
|
||||
end
|
||||
|
||||
# INDEX action tests
|
||||
@@ -335,6 +356,6 @@ end
|
||||
private
|
||||
|
||||
def api_headers(api_key)
|
||||
{ "X-Api-Key" => api_key.plain_key }
|
||||
{ "X-Api-Key" => api_key.display_key }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,8 +11,8 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "creates with credit card details" do
|
||||
assert_difference -> { Account.count } => 1,
|
||||
-> { CreditCard.count } => 1,
|
||||
-> { Valuation.count } => 2,
|
||||
-> { Entry.count } => 2 do
|
||||
-> { Valuation.count } => 1,
|
||||
-> { Entry.count } => 1 do
|
||||
post credit_cards_path, params: {
|
||||
account: {
|
||||
name: "New Credit Card",
|
||||
|
||||
@@ -11,8 +11,8 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
|
||||
test "creates with loan details" do
|
||||
assert_difference -> { Account.count } => 1,
|
||||
-> { Loan.count } => 1,
|
||||
-> { Valuation.count } => 2,
|
||||
-> { Entry.count } => 2 do
|
||||
-> { Valuation.count } => 1,
|
||||
-> { Entry.count } => 1 do
|
||||
post loans_path, params: {
|
||||
account: {
|
||||
name: "New Loan",
|
||||
|
||||
@@ -8,18 +8,15 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
|
||||
@account = accounts(:property)
|
||||
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
|
||||
post properties_path, params: {
|
||||
account: {
|
||||
name: "New Property",
|
||||
subtype: "house",
|
||||
accountable_type: "Property",
|
||||
accountable_attributes: {
|
||||
year_built: 1990,
|
||||
area_value: 1200,
|
||||
area_unit: "sqft"
|
||||
}
|
||||
purchase_price: "250000",
|
||||
purchase_date: "2023-01-01",
|
||||
current_estimated_value: "300000",
|
||||
currency: "USD"
|
||||
}
|
||||
}
|
||||
end
|
||||
@@ -27,38 +24,47 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
|
||||
created_account = Account.order(:created_at).last
|
||||
assert created_account.accountable.is_a?(Property)
|
||||
assert_equal "draft", created_account.status
|
||||
assert_equal 0, created_account.balance
|
||||
assert_equal 1990, created_account.accountable.year_built
|
||||
assert_equal 1200, created_account.accountable.area_value
|
||||
assert_equal "sqft", created_account.accountable.area_unit
|
||||
assert_redirected_to balances_property_path(created_account)
|
||||
assert_equal "New Property", created_account.name
|
||||
assert_equal 300_000, created_account.balance
|
||||
assert_equal 0, created_account.cash_balance
|
||||
assert_equal "USD", created_account.currency
|
||||
|
||||
# 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
|
||||
|
||||
test "updates property overview" do
|
||||
test "updates property overview with balance information" do
|
||||
assert_no_difference [ "Account.count", "Property.count" ] do
|
||||
patch property_path(@account), params: {
|
||||
account: {
|
||||
name: "Updated Property",
|
||||
subtype: "condo"
|
||||
current_estimated_value: "350000",
|
||||
currency: "USD"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
@account.reload
|
||||
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?
|
||||
assert_response :success
|
||||
else
|
||||
assert_redirected_to balances_property_path(@account)
|
||||
assert_redirected_to details_property_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
# Tab view tests
|
||||
test "shows balances tab" do
|
||||
get balances_property_path(@account)
|
||||
test "shows details tab" do
|
||||
get details_property_path(@account)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
@@ -68,22 +74,26 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
# Tab update tests
|
||||
test "updates balances tab" do
|
||||
original_balance = @account.balance
|
||||
|
||||
# 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: {
|
||||
test "updates property details" do
|
||||
patch update_details_property_path(@account), params: {
|
||||
account: {
|
||||
balance: 600000,
|
||||
currency: "EUR"
|
||||
subtype: "condo",
|
||||
accountable_attributes: {
|
||||
year_built: 2005,
|
||||
area_value: 1500,
|
||||
area_unit: "sqft"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# If account is active, it renders balances view; otherwise redirects to address
|
||||
if @account.reload.active?
|
||||
@account.reload
|
||||
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
|
||||
else
|
||||
assert_redirected_to address_property_path(@account)
|
||||
@@ -115,20 +125,6 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
|
||||
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
|
||||
Property.any_instance.stubs(:update).returns(false)
|
||||
|
||||
@@ -23,7 +23,7 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
created_entry = Entry.order(created_at: :desc).first
|
||||
assert_equal "Manual account value update", created_entry.name
|
||||
assert_equal "Manual value update", created_entry.name
|
||||
assert_equal Date.current, created_entry.date
|
||||
assert_equal account.balance + 100, created_entry.amount_money.to_f
|
||||
|
||||
@@ -38,7 +38,7 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
|
||||
entry: {
|
||||
amount: 20000,
|
||||
currency: "USD",
|
||||
date: Date.current
|
||||
date: @entry.date
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
@@ -11,8 +11,8 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest
|
||||
test "creates with vehicle details" do
|
||||
assert_difference -> { Account.count } => 1,
|
||||
-> { Vehicle.count } => 1,
|
||||
-> { Valuation.count } => 2,
|
||||
-> { Entry.count } => 2 do
|
||||
-> { Valuation.count } => 1,
|
||||
-> { Entry.count } => 1 do
|
||||
post vehicles_path, params: {
|
||||
account: {
|
||||
name: "Vehicle",
|
||||
|
||||
5
test/fixtures/imports.yml
vendored
5
test/fixtures/imports.yml
vendored
@@ -7,3 +7,8 @@ trade:
|
||||
family: dylan_family
|
||||
type: TradeImport
|
||||
status: pending
|
||||
|
||||
account:
|
||||
family: dylan_family
|
||||
type: AccountImport
|
||||
status: pending
|
||||
|
||||
5
test/fixtures/valuations.yml
vendored
5
test/fixtures/valuations.yml
vendored
@@ -1,2 +1,3 @@
|
||||
one: { }
|
||||
two: { }
|
||||
one:
|
||||
balance: 4995
|
||||
cash_balance: 4995
|
||||
|
||||
@@ -4,7 +4,7 @@ module EntryableResourceInterfaceTest
|
||||
extend ActiveSupport::Testing::Declarative
|
||||
|
||||
test "shows new form" do
|
||||
get new_polymorphic_url(@entry.entryable)
|
||||
get new_polymorphic_url(@entry.entryable, account_id: @entry.account_id)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
|
||||
141
test/models/account/overview_form_test.rb
Normal file
141
test/models/account/overview_form_test.rb
Normal file
@@ -0,0 +1,141 @@
|
||||
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(:lock_attr!).with(: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(:lock_attr!).with(: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
|
||||
193
test/models/account/reconcileable_test.rb
Normal file
193
test/models/account/reconcileable_test.rb
Normal file
@@ -0,0 +1,193 @@
|
||||
require "test_helper"
|
||||
|
||||
class Account::ReconcileableTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@account = @syncable = accounts(:depository)
|
||||
@family = families(:dylan_family)
|
||||
end
|
||||
|
||||
# Scope: Depository Only
|
||||
#
|
||||
# If a user has an opening balance (valuation) for their manual *Depository* account and has 1+ transactions, the intent of
|
||||
# "updating current balance" typically means that their start balance is incorrect. We follow that user intent
|
||||
# by default and find the delta required, and update the opening balance so that the timeline reflects this current balance
|
||||
#
|
||||
# The purpose of this is so we're not cluttering up their timeline with "balance reconciliations" that reset the balance
|
||||
# on the current date. Our goal is to keep the timeline with as few "Valuations" as possible.
|
||||
#
|
||||
# If we ever build a UI that gives user options, this test expectation may require some updates, but for now this
|
||||
# is the least surprising outcome.
|
||||
test "when manual account has opening valuation and transactions, adjust opening balance directly with delta" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Test",
|
||||
balance: 900, # the balance after opening valuation + transaction have "synced" (1000 - 100 = 900)
|
||||
cash_balance: 900,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
account.entries.create!(
|
||||
date: 1.year.ago.to_date,
|
||||
name: "Test opening valuation",
|
||||
amount: 1000,
|
||||
currency: "USD",
|
||||
entryable: Valuation.new(
|
||||
kind: "opening_anchor",
|
||||
balance: 1000,
|
||||
cash_balance: 1000
|
||||
)
|
||||
)
|
||||
|
||||
account.entries.create!(
|
||||
date: 10.days.ago.to_date,
|
||||
name: "Test expense transaction",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
# What we're asserting here:
|
||||
# 1. User creates the account with an opening balance of 1000
|
||||
# 2. User creates a transaction of 100, which then reduces the balance to 900 (the current balance value on account above)
|
||||
# 3. User requests "current balance update" back to 1000, which was their intention
|
||||
# 4. We adjust the opening balance by the delta (100) to 1100, which is the new opening balance, so that the transaction
|
||||
# of 100 reduces it down to 1000, which is the current balance they intended.
|
||||
assert_equal 1, account.valuations.count
|
||||
assert_equal 1, account.transactions.count
|
||||
|
||||
# No new valuation is appended; we're just adjusting the opening valuation anchor
|
||||
assert_no_difference "account.entries.count" do
|
||||
account.update_current_balance!(balance: 1000, cash_balance: 1000)
|
||||
end
|
||||
|
||||
opening_valuation = account.valuations.first
|
||||
|
||||
assert_equal 1100, opening_valuation.balance
|
||||
assert_equal 1100, opening_valuation.cash_balance
|
||||
end
|
||||
|
||||
# If the user has a "recon valuation" already (i.e. they applied a "balance override"), the most accurate thing we can do is append
|
||||
# a new recon valuation to the current day (i.e. "from this day forward, the balance is X"). Any other action risks altering the user's view
|
||||
# of their balance timeline and makes too many assumptions.
|
||||
test "when manual account has 1+ reconciling valuations, append a new recon valuation rather than adjusting opening balance" do
|
||||
account = @family.accounts.create!(
|
||||
name: "Test",
|
||||
balance: 1000,
|
||||
cash_balance: 1000,
|
||||
currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
account.entries.create!(
|
||||
date: 1.year.ago.to_date,
|
||||
name: "Test opening valuation",
|
||||
amount: 1000,
|
||||
currency: "USD",
|
||||
entryable: Valuation.new(
|
||||
kind: "opening_anchor",
|
||||
balance: 1000,
|
||||
cash_balance: 1000
|
||||
)
|
||||
)
|
||||
|
||||
# User is "overriding" the balance to 1200 here
|
||||
account.entries.create!(
|
||||
date: 30.days.ago.to_date,
|
||||
name: "First manual recon valuation",
|
||||
amount: 1200,
|
||||
currency: "USD",
|
||||
entryable: Valuation.new(
|
||||
kind: "recon",
|
||||
balance: 1200,
|
||||
cash_balance: 1200
|
||||
)
|
||||
)
|
||||
|
||||
assert_equal 2, account.valuations.count
|
||||
|
||||
# Here, we assume user is once again "overriding" the balance to 1400
|
||||
account.update_current_balance!(balance: 1400, cash_balance: 1400)
|
||||
|
||||
most_recent_valuation = account.valuations.joins(:entry).order("entries.date DESC").first
|
||||
|
||||
assert_equal 3, account.valuations.count
|
||||
assert_equal 1400, most_recent_valuation.balance
|
||||
assert_equal 1400, most_recent_valuation.cash_balance
|
||||
end
|
||||
|
||||
# Updating "current balance" for a linked account is a pure system operation that manages the "current anchor" valuation
|
||||
test "updating current balance for linked account modifies current anchor valuation" do
|
||||
# TODO
|
||||
end
|
||||
|
||||
# A recon valuation is an override for a user to "reset" the balance from a specific date forward.
|
||||
# This means, "The balance on X date is Y", which is then used as the new starting point to apply transactions against
|
||||
test "manual accounts can add recon valuations at any point in the account timeline" do
|
||||
assert_equal 1, @account.valuations.count
|
||||
|
||||
@account.reconcile_balance!(balance: 1000, cash_balance: 1000, date: 2.days.ago.to_date)
|
||||
|
||||
assert_equal 2, @account.valuations.count
|
||||
|
||||
most_recent_valuation = @account.valuations.joins(:entry).order("entries.date DESC").first
|
||||
|
||||
assert_equal 1000, most_recent_valuation.balance
|
||||
assert_equal 1000, most_recent_valuation.cash_balance
|
||||
end
|
||||
|
||||
# While technically valid and possible to calculate, "recon" valuations for a linked account rarely make sense
|
||||
# and add complexity. If the user has linked to a data provider, we expect the provider to be responsible for
|
||||
# delivering the correct set of transactions to construct the historical balance
|
||||
test "recon valuations are invalid for linked accounts" do
|
||||
linked_account = accounts(:connected)
|
||||
|
||||
assert_raises Account::InvalidBalanceError do
|
||||
linked_account.reconcile_balance!(balance: 1000, cash_balance: 1000, date: 2.days.ago.to_date)
|
||||
end
|
||||
end
|
||||
|
||||
test "sets or updates opening balance" do
|
||||
Entry.destroy_all
|
||||
|
||||
assert_equal 0, @account.entries.valuations.count
|
||||
|
||||
# Creates non-existent opening valuation
|
||||
@account.set_or_update_opening_balance!(
|
||||
balance: 2000,
|
||||
cash_balance: 2000,
|
||||
date: 2.days.ago.to_date
|
||||
)
|
||||
|
||||
opening_valuation_entry = @account.entries.first
|
||||
|
||||
assert_equal 2000, opening_valuation_entry.amount
|
||||
assert_equal 2.days.ago.to_date, opening_valuation_entry.date
|
||||
assert_equal 2000, opening_valuation_entry.valuation.balance
|
||||
assert_equal 2000, opening_valuation_entry.valuation.cash_balance
|
||||
|
||||
# Updates existing opening valuation
|
||||
@account.set_or_update_opening_balance!(
|
||||
balance: 3000,
|
||||
cash_balance: 3000
|
||||
)
|
||||
|
||||
opening_valuation_entry = @account.entries.first
|
||||
|
||||
assert_equal 3000, opening_valuation_entry.amount
|
||||
assert_equal 2.days.ago.to_date, opening_valuation_entry.date
|
||||
assert_equal 3000, opening_valuation_entry.valuation.balance
|
||||
assert_equal 3000, opening_valuation_entry.valuation.cash_balance
|
||||
end
|
||||
|
||||
# While we don't allow "recon" valuations for a linked account, we DO allow opening balance updates. This is because
|
||||
# providers rarely give 100% of the transaction history (usually cuts off at 2 years), which can misrepresent the true
|
||||
# "opening date" on the account and obscure longer net worth historical graphs. This is an *optional* way for the user
|
||||
# to get their linked account histories "perfect".
|
||||
test "can update the opening balance and date for a linked account" do
|
||||
# TODO
|
||||
end
|
||||
|
||||
test "can update the opening balance and date for a manual account" do
|
||||
# TODO
|
||||
end
|
||||
end
|
||||
93
test/models/account_import_test.rb
Normal file
93
test/models/account_import_test.rb
Normal file
@@ -0,0 +1,93 @@
|
||||
require "test_helper"
|
||||
|
||||
class AccountImportTest < ActiveSupport::TestCase
|
||||
include ActiveJob::TestHelper, ImportInterfaceTest
|
||||
|
||||
setup do
|
||||
@subject = @import = imports(:account)
|
||||
end
|
||||
|
||||
test "import creates accounts with valuations" do
|
||||
import_csv = <<~CSV
|
||||
type,name,amount,currency
|
||||
depository,Main Checking,1000.00,USD
|
||||
depository,Savings Account,5000.00,USD
|
||||
CSV
|
||||
|
||||
@import.update!(
|
||||
raw_file_str: import_csv,
|
||||
entity_type_col_label: "type",
|
||||
name_col_label: "name",
|
||||
amount_col_label: "amount",
|
||||
currency_col_label: "currency"
|
||||
)
|
||||
|
||||
@import.generate_rows_from_csv
|
||||
|
||||
# Create mappings for account types
|
||||
@import.mappings.create! key: "depository", value: "Depository", type: "Import::AccountTypeMapping"
|
||||
|
||||
@import.reload
|
||||
|
||||
# Store initial counts
|
||||
initial_account_count = Account.count
|
||||
initial_entry_count = Entry.count
|
||||
initial_valuation_count = Valuation.count
|
||||
|
||||
# Perform the import
|
||||
@import.publish
|
||||
|
||||
# Check if import succeeded
|
||||
if @import.failed?
|
||||
fail "Import failed with error: #{@import.error}"
|
||||
end
|
||||
|
||||
assert_equal "complete", @import.status
|
||||
|
||||
# Check the differences
|
||||
assert_equal initial_account_count + 2, Account.count, "Expected 2 new accounts"
|
||||
assert_equal initial_entry_count + 2, Entry.count, "Expected 2 new entries"
|
||||
assert_equal initial_valuation_count + 2, Valuation.count, "Expected 2 new valuations"
|
||||
|
||||
# Verify accounts were created correctly
|
||||
accounts = @import.accounts.order(:name)
|
||||
assert_equal [ "Main Checking", "Savings Account" ], accounts.pluck(:name)
|
||||
assert_equal [ 1000.00, 5000.00 ], accounts.map { |a| a.balance.to_f }
|
||||
|
||||
# Verify valuations were created with correct fields
|
||||
accounts.each do |account|
|
||||
valuation = account.valuations.last
|
||||
assert_not_nil valuation
|
||||
assert_equal account.balance, valuation.balance
|
||||
assert_equal account.balance, valuation.cash_balance
|
||||
assert_equal "recon", valuation.kind
|
||||
end
|
||||
end
|
||||
|
||||
test "column_keys returns expected keys" do
|
||||
assert_equal %i[entity_type name amount currency], @import.column_keys
|
||||
end
|
||||
|
||||
test "required_column_keys returns expected keys" do
|
||||
assert_equal %i[name amount], @import.required_column_keys
|
||||
end
|
||||
|
||||
test "mapping_steps returns account type mapping" do
|
||||
assert_equal [ Import::AccountTypeMapping ], @import.mapping_steps
|
||||
end
|
||||
|
||||
test "dry_run returns expected counts" do
|
||||
@import.rows.create!(
|
||||
entity_type: "depository",
|
||||
name: "Test Account",
|
||||
amount: "1000.00",
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
assert_equal({ accounts: 1 }, @import.dry_run)
|
||||
end
|
||||
|
||||
test "max_row_count is limited to 50" do
|
||||
assert_equal 50, @import.max_row_count
|
||||
end
|
||||
end
|
||||
@@ -31,4 +31,13 @@ class AccountTest < ActiveSupport::TestCase
|
||||
assert_equal "Investments", account.short_subtype_label
|
||||
assert_equal "Investments", account.long_subtype_label
|
||||
end
|
||||
|
||||
# Currency updates earn their own method because updating an account currency incurs
|
||||
# side effects like recalculating balances, etc.
|
||||
test "can update the account currency" do
|
||||
@account.update_currency!("EUR")
|
||||
|
||||
assert_equal "EUR", @account.currency
|
||||
assert_equal "EUR", @account.entries.valuations.first.currency
|
||||
end
|
||||
end
|
||||
|
||||
151
test/models/family/account_creatable_test.rb
Normal file
151
test/models/family/account_creatable_test.rb
Normal file
@@ -0,0 +1,151 @@
|
||||
require "test_helper"
|
||||
|
||||
class Family::AccountCreatableTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@family = families(:dylan_family)
|
||||
end
|
||||
|
||||
test "creates manual property account" do
|
||||
account = @family.create_property_account!(
|
||||
name: "My House",
|
||||
current_value: 450000,
|
||||
purchase_price: 400000,
|
||||
purchase_date: 1.year.ago.to_date
|
||||
)
|
||||
|
||||
assert_opening_valuation(account: account, balance: 400000)
|
||||
assert_account_created_with(account: account, name: "My House", balance: 450000, cash_balance: 0)
|
||||
end
|
||||
|
||||
test "creates manual vehicle account" do
|
||||
account = @family.create_vehicle_account!(
|
||||
name: "My Car",
|
||||
current_value: 25000,
|
||||
purchase_price: 30000,
|
||||
purchase_date: 2.years.ago.to_date
|
||||
)
|
||||
|
||||
assert_opening_valuation(account: account, balance: 30000)
|
||||
assert_account_created_with(account: account, name: "My Car", balance: 25000, cash_balance: 0)
|
||||
end
|
||||
|
||||
test "creates manual depository account" do
|
||||
account = @family.create_depository_account!(
|
||||
name: "My Checking",
|
||||
current_balance: 5000,
|
||||
opening_date: 1.year.ago.to_date
|
||||
)
|
||||
|
||||
assert_opening_valuation(account: account, balance: 5000, cash_balance: 5000)
|
||||
assert_account_created_with(account: account, name: "My Checking", balance: 5000, cash_balance: 5000)
|
||||
end
|
||||
|
||||
test "creates manual investment account" do
|
||||
account = @family.create_investment_account!(
|
||||
name: "My Brokerage"
|
||||
)
|
||||
|
||||
assert_opening_valuation(account: account, balance: 0, cash_balance: 0)
|
||||
assert_account_created_with(account: account, name: "My Brokerage", balance: 0, cash_balance: 0)
|
||||
end
|
||||
|
||||
test "creates manual other asset account" do
|
||||
account = @family.create_other_asset_account!(
|
||||
name: "Collectible",
|
||||
current_value: 10000,
|
||||
purchase_price: 5000,
|
||||
purchase_date: 3.years.ago.to_date
|
||||
)
|
||||
|
||||
assert_opening_valuation(account: account, balance: 5000)
|
||||
assert_account_created_with(account: account, name: "Collectible", balance: 10000, cash_balance: 0)
|
||||
end
|
||||
|
||||
test "creates manual other liability account" do
|
||||
account = @family.create_other_liability_account!(
|
||||
name: "Personal Loan",
|
||||
current_debt: 5000,
|
||||
original_debt: 10000,
|
||||
origination_date: 2.years.ago.to_date
|
||||
)
|
||||
|
||||
assert_opening_valuation(account: account, balance: 10000)
|
||||
assert_account_created_with(account: account, name: "Personal Loan", balance: 5000, cash_balance: 0)
|
||||
end
|
||||
|
||||
test "creates manual crypto account" do
|
||||
account = @family.create_crypto_account!(
|
||||
name: "Bitcoin Wallet",
|
||||
current_value: 50000
|
||||
)
|
||||
|
||||
assert_opening_valuation(account: account, balance: 50000, cash_balance: 50000)
|
||||
assert_account_created_with(account: account, name: "Bitcoin Wallet", balance: 50000, cash_balance: 50000)
|
||||
end
|
||||
|
||||
test "creates manual credit card account" do
|
||||
account = @family.create_credit_card_account!(
|
||||
name: "Visa Card",
|
||||
current_debt: 2000,
|
||||
opening_date: 6.months.ago.to_date
|
||||
)
|
||||
|
||||
assert_opening_valuation(account: account, balance: 0, cash_balance: 0)
|
||||
assert_account_created_with(account: account, name: "Visa Card", balance: 2000, cash_balance: 0)
|
||||
end
|
||||
|
||||
test "creates manual loan account" do
|
||||
account = @family.create_loan_account!(
|
||||
name: "Home Mortgage",
|
||||
current_principal: 200000,
|
||||
original_principal: 250000,
|
||||
origination_date: 5.years.ago.to_date
|
||||
)
|
||||
|
||||
assert_opening_valuation(account: account, balance: 250000)
|
||||
assert_account_created_with(account: account, name: "Home Mortgage", balance: 200000, cash_balance: 0)
|
||||
end
|
||||
|
||||
test "creates property account without purchase price" do
|
||||
account = @family.create_property_account!(
|
||||
name: "My House",
|
||||
current_value: 500000
|
||||
)
|
||||
|
||||
assert_opening_valuation(account: account, balance: 500000)
|
||||
assert_account_created_with(account: account, name: "My House", balance: 500000, cash_balance: 0)
|
||||
end
|
||||
|
||||
test "creates linked depository account" do
|
||||
# TODO
|
||||
end
|
||||
|
||||
test "creates linked investment account" do
|
||||
# TODO
|
||||
end
|
||||
|
||||
test "creates linked credit card account" do
|
||||
# TODO
|
||||
end
|
||||
|
||||
test "creates linked loan account" do
|
||||
# TODO
|
||||
end
|
||||
|
||||
private
|
||||
def assert_account_created_with(account:, name:, balance:, cash_balance:)
|
||||
assert_equal name, account.name
|
||||
assert_equal balance, account.balance
|
||||
assert_equal cash_balance, account.cash_balance
|
||||
end
|
||||
|
||||
def assert_opening_valuation(account:, balance:, cash_balance: 0)
|
||||
valuations = account.valuations
|
||||
assert_equal 1, valuations.count
|
||||
|
||||
opening_valuation = valuations.first
|
||||
assert_equal "opening_anchor", opening_valuation.kind
|
||||
assert_equal balance, opening_valuation.balance
|
||||
assert_equal cash_balance, opening_valuation.cash_balance
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,6 @@ class FamilyTest < ActiveSupport::TestCase
|
||||
include SyncableInterfaceTest
|
||||
|
||||
def setup
|
||||
@syncable = families(:dylan_family)
|
||||
@syncable = @family = families(:dylan_family)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,16 +16,19 @@ module EntriesTestHelper
|
||||
end
|
||||
|
||||
def create_valuation(attributes = {})
|
||||
entry_attributes = attributes.except(:kind)
|
||||
valuation_attributes = attributes.slice(:kind)
|
||||
|
||||
entry_defaults = {
|
||||
account: accounts(:depository),
|
||||
name: "Valuation",
|
||||
date: 1.day.ago.to_date,
|
||||
currency: "USD",
|
||||
amount: 5000,
|
||||
entryable: Valuation.new
|
||||
entryable: Valuation.new(valuation_attributes.merge(balance: 5000, cash_balance: 5000))
|
||||
}
|
||||
|
||||
Entry.create! entry_defaults.merge(attributes)
|
||||
Entry.create! entry_defaults.merge(entry_attributes)
|
||||
end
|
||||
|
||||
def create_trade(security, account:, qty:, date:, price: nil, currency: "USD")
|
||||
|
||||
@@ -23,20 +23,22 @@ class AccountsTest < ApplicationSystemTestCase
|
||||
end
|
||||
|
||||
test "can create property account" do
|
||||
# Step 1: Select property type and enter basic details
|
||||
# Step 1: Enter basic property details
|
||||
click_link "Property"
|
||||
|
||||
account_name = "[system test] Property Account"
|
||||
fill_in "Name*", with: account_name
|
||||
select "Single Family Home", from: "Property type*"
|
||||
fill_in "Year Built (optional)", with: 2005
|
||||
fill_in "Area (optional)", with: 2250
|
||||
fill_in "Name", with: account_name
|
||||
fill_in "account[current_estimated_value]", with: 500000
|
||||
fill_in "account[purchase_price]", with: 450000
|
||||
fill_in "account[purchase_date]", with: "01/15/2020"
|
||||
|
||||
click_button "Next"
|
||||
|
||||
# Step 2: Enter balance information
|
||||
assert_text "Value"
|
||||
fill_in "account[balance]", with: 500000
|
||||
# Step 2: Enter property details
|
||||
assert_text "Property type"
|
||||
select "Single Family Home", from: "Property type"
|
||||
fill_in "Year built", with: 2005
|
||||
fill_in "Area", with: 2250
|
||||
click_button "Next"
|
||||
|
||||
# Step 3: Enter address information
|
||||
|
||||
Reference in New Issue
Block a user