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

Multi-step account forms + clearer balance editing (#2427)
Some checks failed
Publish Docker image / ci (push) Has been cancelled
Publish Docker image / Build docker image (push) Has been cancelled

* Initial multi-step property form

* Improve form structure, add optional tooltip help icons to form fields

* Add basic inline alert component

* Clean up and improve property form lifecycle

* Implement Account status concept

* Lint fixes

* Remove whitespace

* Balance editing, scope updates for account

* Passing tests

* Fix brakeman warning

* Remove stale columns

* data constraint tweaks

* Redundant property
This commit is contained in:
Zach Gollwitzer 2025-07-03 09:33:07 -04:00 committed by GitHub
parent ba7e8d3893
commit 662f2c04ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 1036 additions and 427 deletions

View file

@ -334,6 +334,19 @@
} }
} }
/* New form field structure components */
.form-field__header {
@apply flex items-center justify-between gap-2;
}
.form-field__body {
@apply flex flex-col gap-1;
}
.form-field__actions {
@apply flex items-center gap-1;
}
.form-field__label { .form-field__label {
@apply block text-xs text-secondary peer-disabled:text-subdued; @apply block text-xs text-secondary peer-disabled:text-subdued;
} }
@ -347,10 +360,6 @@
@apply transition-opacity duration-300; @apply transition-opacity duration-300;
@apply placeholder:text-subdued; @apply placeholder:text-subdued;
&select {
@apply pr-8;
}
@variant theme-dark { @variant theme-dark {
&::-webkit-calendar-picker-indicator { &::-webkit-calendar-picker-indicator {
filter: invert(1); filter: invert(1);
@ -359,6 +368,14 @@
} }
} }
select.form-field__input {
@apply pr-10 appearance-none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right -0.15rem center;
background-repeat: no-repeat;
background-size: 1.25rem 1.25rem;
}
.form-field__radio { .form-field__radio {
@apply text-primary; @apply text-primary;
} }
@ -425,7 +442,5 @@
@variant theme-dark { @variant theme-dark {
fill: var(--color-white); fill: var(--color-white);
} }
} }
} }

View file

@ -0,0 +1,7 @@
<div class="<%= container_classes %>">
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %>
<div class="flex-1 text-sm">
<%= message %>
</div>
</div>

View file

@ -0,0 +1,52 @@
class AlertComponent < ViewComponent::Base
def initialize(message:, variant: :info)
@message = message
@variant = variant
end
private
attr_reader :message, :variant
def container_classes
base_classes = "flex items-start gap-3 p-4 rounded-lg border"
variant_classes = case variant
when :info
"bg-blue-50 text-blue-700 border-blue-200 theme-dark:bg-blue-900/20 theme-dark:text-blue-400 theme-dark:border-blue-800"
when :success
"bg-green-50 text-green-700 border-green-200 theme-dark:bg-green-900/20 theme-dark:text-green-400 theme-dark:border-green-800"
when :warning
"bg-yellow-50 text-yellow-700 border-yellow-200 theme-dark:bg-yellow-900/20 theme-dark:text-yellow-400 theme-dark:border-yellow-800"
when :error, :destructive
"bg-red-50 text-red-700 border-red-200 theme-dark:bg-red-900/20 theme-dark:text-red-400 theme-dark:border-red-800"
end
"#{base_classes} #{variant_classes}"
end
def icon_name
case variant
when :info
"info"
when :success
"check-circle"
when :warning
"alert-triangle"
when :error, :destructive
"x-circle"
end
end
def icon_color
case variant
when :success
"success"
when :warning
"warning"
when :error, :destructive
"destructive"
else
"blue-600"
end
end
end

View file

@ -1,7 +1,6 @@
<%= link_to href, **merged_opts do %> <%= link_to href, **merged_opts do %>
<% if icon && (icon_position != "right") %> <% if icon && (icon_position != "right") %>
<%= helpers.icon(icon, size: size, color: icon_color) %> <%= helpers.icon(icon, size: size, color: icon_color) %>
<% end %> <% end %>
<% unless icon_only? %> <% unless icon_only? %>
@ -10,6 +9,5 @@
<% if icon && icon_position == "right" %> <% if icon && icon_position == "right" %>
<%= helpers.icon(icon, size: size, color: icon_color) %> <%= helpers.icon(icon, size: size, color: icon_color) %>
<% end %> <% end %>
<% end %> <% end %>

View file

@ -32,7 +32,7 @@ class AccountableSparklinesController < ApplicationController
end end
def account_ids def account_ids
family.accounts.active.where(accountable_type: accountable.name).pluck(:id) family.accounts.visible.where(accountable_type: accountable.name).pluck(:id)
end end
def cache_key def cache_key

View file

@ -1,5 +1,5 @@
class AccountsController < ApplicationController class AccountsController < ApplicationController
before_action :set_account, only: %i[sync chart sparkline] before_action :set_account, only: %i[sync chart sparkline toggle_active]
include Periodable include Periodable
def index def index
@ -33,6 +33,15 @@ class AccountsController < ApplicationController
end end
end end
def toggle_active
if @account.active?
@account.disable!
elsif @account.disabled?
@account.enable!
end
redirect_to accounts_path
end
private private
def family def family
Current.family Current.family

View file

@ -9,7 +9,7 @@ class Api::V1::AccountsController < Api::V1::BaseController
def index def index
# Test with Pagy pagination # Test with Pagy pagination
family = current_resource_owner.family family = current_resource_owner.family
accounts_query = family.accounts.active.alphabetically accounts_query = family.accounts.visible.alphabetically
# Handle pagination with Pagy # Handle pagination with Pagy
@pagy, @accounts = pagy( @pagy, @accounts = pagy(

View file

@ -10,7 +10,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController
def index def index
family = current_resource_owner.family family = current_resource_owner.family
transactions_query = family.transactions.active transactions_query = family.transactions.visible
# Apply filters # Apply filters
transactions_query = apply_filters(transactions_query) transactions_query = apply_filters(transactions_query)

View file

@ -43,9 +43,25 @@ module AccountableResource
end end
def update def update
@account.update_with_sync!(account_params.except(:return_to)) # Handle balance update if provided
@account.lock_saved_attributes! 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
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(", ")
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) redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
end end
@ -74,7 +90,7 @@ module AccountableResource
def account_params def account_params
params.require(:account).permit( params.require(:account).permit(
:name, :is_active, :balance, :subtype, :currency, :accountable_type, :return_to, :name, :balance, :subtype, :currency, :accountable_type, :return_to,
accountable_attributes: self.class.permitted_accountable_attributes accountable_attributes: self.class.permitted_accountable_attributes
) )
end end

View file

@ -5,7 +5,7 @@ class PagesController < ApplicationController
def dashboard def dashboard
@balance_sheet = Current.family.balance_sheet @balance_sheet = Current.family.balance_sheet
@accounts = Current.family.accounts.active.with_attached_logo @accounts = Current.family.accounts.visible.with_attached_logo
period_param = params[:cashflow_period] period_param = params[:cashflow_period]
@cashflow_period = if period_param.present? @cashflow_period = if period_param.present?

View file

@ -1,21 +1,99 @@
class PropertiesController < ApplicationController class PropertiesController < ApplicationController
include AccountableResource include AccountableResource, StreamExtensions
permitted_accountable_attributes( before_action :set_property, only: [ :balances, :address, :update_balances, :update_address ]
:id, :year_built, :area_unit, :area_value,
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
)
def new def new
@account = Current.family.accounts.build( @account = Current.family.accounts.build(accountable: Property.new)
currency: Current.family.currency, end
accountable: Property.new(
address: Address.new def create
) @account = Current.family.accounts.create!(
property_params.merge(currency: Current.family.currency, balance: 0, status: "draft")
) )
redirect_to balances_property_path(@account)
end
def update
if @account.update(property_params)
@success_message = "Property details updated successfully."
if @account.active?
render :edit
else
redirect_to balances_property_path(@account)
end
else
@error_message = "Unable to update property details."
render :edit, status: :unprocessable_entity
end
end end
def edit def edit
@account.accountable.address ||= Address.new end
def balances
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."
if @account.active?
render :balances
else
redirect_to address_property_path(@account)
end
else
@error_message = result.error_message
render :balances, status: :unprocessable_entity
end
end
def address
@property = @account.property
@property.address ||= Address.new
end
def update_address
if @account.property.update(address_params)
if @account.draft?
@account.activate!
respond_to do |format|
format.html { redirect_to account_path(@account) }
format.turbo_stream { stream_redirect_to account_path(@account) }
end
else
@success_message = "Address updated successfully."
render :address
end
else
@error_message = "Unable to update address. Please check the required fields."
render :address, status: :unprocessable_entity
end
end
private
def balance_params
params.require(:account).permit(:balance, :currency)
end
def address_params
params.require(:property)
.permit(address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ])
end
def property_params
params.require(:account)
.permit(:name, :subtype, :accountable_type, accountable_attributes: [ :id, :year_built, :area_unit, :area_value ])
end
def set_property
@account = Current.family.accounts.find(params[:id])
@property = @account.property
end end
end end

View file

@ -2,7 +2,7 @@ class TransferMatchesController < ApplicationController
before_action :set_entry before_action :set_entry
def new def new
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id) @accounts = Current.family.accounts.visible.alphabetically.where.not(id: @entry.account_id)
@transfer_match_candidates = @entry.transaction.transfer_match_candidates @transfer_match_candidates = @entry.transaction.transfer_match_candidates
end end

View file

@ -1,30 +1,42 @@
class ValuationsController < ApplicationController class ValuationsController < ApplicationController
include EntryableResource include EntryableResource, StreamExtensions
def create def create
account = Current.family.accounts.find(params.dig(:entry, :account_id)) account = Current.family.accounts.find(params.dig(:entry, :account_id))
@entry = account.entries.new(entry_params.merge(entryable: Valuation.new))
if @entry.save result = account.update_balance(
@entry.sync_account_later balance: entry_params[:amount],
date: entry_params[:date],
currency: entry_params[:currency],
notes: entry_params[:notes]
)
flash[:notice] = "Balance created" if result.success?
@success_message = result.updated? ? "Balance updated" : "No changes made. Account is already up to date."
respond_to do |format| respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account) } format.html { redirect_back_or_to account_path(account), notice: @success_message }
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) } format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: @success_message) }
end end
else else
@error_message = result.error_message
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
def update def update
if @entry.update(entry_params) result = @entry.account.update_balance(
@entry.sync_account_later date: @entry.date,
balance: entry_params[:amount],
currency: entry_params[:currency],
notes: entry_params[:notes]
)
if result.success?
@entry.reload
respond_to do |format| respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: "Balance updated" } 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 format.turbo_stream do
render turbo_stream: [ render turbo_stream: [
turbo_stream.replace( turbo_stream.replace(
@ -37,6 +49,7 @@ class ValuationsController < ApplicationController
end end
end end
else else
@error_message = result.error_message
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
end end
end end
@ -44,6 +57,6 @@ class ValuationsController < ApplicationController
private private
def entry_params def entry_params
params.require(:entry) params.require(:entry)
.permit(:name, :date, :amount, :currency, :notes) .permit(:date, :amount, :currency, :notes)
end end
end end

View file

@ -1,42 +1,38 @@
class StyledFormBuilder < ActionView::Helpers::FormBuilder class StyledFormBuilder < ActionView::Helpers::FormBuilder
# Fields that visually inherit from "text field"
class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ] class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]
# Wraps "text" inputs with custom structure + base styles
text_field_helpers.each do |selector| text_field_helpers.each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {}) def #{selector}(method, options = {})
merged_options = { class: "form-field__input" }.merge(options) form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)
label = build_label(method, options) html_options = options.except(:label, :label_tooltip, :inline, :container_class)
field = super(method, merged_options)
build_styled_field(label, field, merged_options) build_field(method, form_options, html_options) do |merged_options|
super(method, merged_options)
end
end end
RUBY_EVAL RUBY_EVAL
end end
def radio_button(method, tag_value, options = {}) def radio_button(method, tag_value, options = {})
merged_options = { class: "form-field__radio" }.merge(options) merged_options = { class: "form-field__radio" }.merge(options)
super(method, tag_value, merged_options) super(method, tag_value, merged_options)
end end
def select(method, choices, options = {}, html_options = {}) def select(method, choices, options = {}, html_options = {})
merged_html_options = { class: "form-field__input" }.merge(html_options) field_options = normalize_options(options, html_options)
label = build_label(method, options.merge(required: merged_html_options[:required])) build_field(method, field_options, html_options) do |merged_html_options|
field = super(method, choices, options, merged_html_options) super(method, choices, options, merged_html_options)
end
build_styled_field(label, field, options, remove_padding_right: true)
end end
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {}) def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
merged_html_options = { class: "form-field__input" }.merge(html_options) field_options = normalize_options(options, html_options)
label = build_label(method, options.merge(required: merged_html_options[:required])) build_field(method, field_options, html_options) do |merged_html_options|
field = super(method, collection, value_method, text_method, options, merged_html_options) super(method, collection, value_method, text_method, options, merged_html_options)
end
build_styled_field(label, field, options, remove_padding_right: true)
end end
def money_field(amount_method, options = {}) def money_field(amount_method, options = {})
@ -48,22 +44,15 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
} }
end end
# A custom styled "toggle" switch input. Underlying input is a `check_box` (uses same API)
def toggle(method, options = {}, checked_value = "1", unchecked_value = "0") def toggle(method, options = {}, checked_value = "1", unchecked_value = "0")
if object field_id = field_id(method)
id = "#{object.id}_#{object_name}_#{method}" field_name = field_name(method)
name = "#{object_name}[#{method}]" checked = object ? object.send(method) : options[:checked]
checked = object.send(method)
else
id = "#{method}_toggle_id"
name = method
checked = options[:checked]
end
@template.render( @template.render(
ToggleComponent.new( ToggleComponent.new(
id: id, id: field_id,
name: name, name: field_name,
checked: checked, checked: checked,
disabled: options[:disabled], disabled: options[:disabled],
checked_value: checked_value, checked_value: checked_value,
@ -74,7 +63,6 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end end
def submit(value = nil, options = {}) def submit(value = nil, options = {})
# Rails superclass logic to extract the submit text
value, options = nil, value if value.is_a?(Hash) value, options = nil, value if value.is_a?(Hash)
value ||= submit_default_value value ||= submit_default_value
@ -88,15 +76,38 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end end
private private
def build_styled_field(label, field, options, remove_padding_right: false) def build_field(method, options = {}, html_options = {}, &block)
if options[:inline] if options[:inline] || options[:label] == false
label + field return yield({ class: "form-field__input" }.merge(html_options))
end
label_element = build_label(method, options)
field_element = yield({ class: "form-field__input" }.merge(html_options))
container_classes = [ "form-field", options[:container_class] ].compact
@template.tag.div class: container_classes do
if options[:label_tooltip]
@template.tag.div(class: "form-field__header") do
label_element +
@template.tag.div(class: "form-field__actions") do
build_tooltip(options[:label_tooltip])
end
end +
@template.tag.div(class: "form-field__body") do
field_element
end
else else
@template.tag.div class: [ "form-field", options[:container_class], ("pr-0" if remove_padding_right) ] do @template.tag.div(class: "form-field__body") do
label + field label_element + field_element
end end
end end
end end
end
def normalize_options(options, html_options)
options.merge(required: options[:required] || html_options[:required])
end
def build_label(method, options) def build_label(method, options)
return "".html_safe unless options[:label] return "".html_safe unless options[:label]
@ -113,4 +124,15 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
return label(method, class: "form-field__label") if label_text == true return label(method, class: "form-field__label") if label_text == true
label(method, label_text, class: "form-field__label") label(method, label_text, class: "form-field__label")
end end
def build_tooltip(tooltip_text)
return nil unless tooltip_text
@template.tag.div(data: { controller: "tooltip" }) do
@template.safe_join([
@template.icon("help-circle", size: "sm", color: "default", class: "cursor-help"),
@template.tag.div(tooltip_text, role: "tooltip", data: { tooltip_target: "tooltip" }, class: "tooltip bg-gray-700 text-sm p-2 rounded w-64 text-white")
])
end
end
end end

View file

@ -1,5 +1,6 @@
class Account < ApplicationRecord class Account < ApplicationRecord
include Syncable, Monetizable, Chartable, Linkable, Enrichable include Syncable, Monetizable, Chartable, Linkable, Enrichable
include AASM
validates :name, :balance, :currency, presence: true validates :name, :balance, :currency, presence: true
@ -18,7 +19,7 @@ class Account < ApplicationRecord
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
scope :active, -> { where(is_active: true) } scope :visible, -> { where(status: [ "draft", "active" ]) }
scope :assets, -> { where(classification: "asset") } scope :assets, -> { where(classification: "asset") }
scope :liabilities, -> { where(classification: "liability") } scope :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) } scope :alphabetically, -> { order(:name) }
@ -30,6 +31,30 @@ class Account < ApplicationRecord
accepts_nested_attributes_for :accountable, update_only: true accepts_nested_attributes_for :accountable, update_only: true
# Account state machine
aasm column: :status, timestamps: true do
state :active, initial: true
state :draft
state :disabled
state :pending_deletion
event :activate do
transitions from: [ :draft, :disabled ], to: :active
end
event :disable do
transitions from: [ :draft, :active ], to: :disabled
end
event :enable do
transitions from: :disabled, to: :active
end
event :mark_for_deletion do
transitions from: [ :draft, :active, :disabled ], to: :pending_deletion
end
end
class << self class << self
def create_and_sync(attributes) def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
@ -77,10 +102,20 @@ class Account < ApplicationRecord
end end
def destroy_later def destroy_later
update!(scheduled_for_deletion: true, is_active: false) mark_for_deletion!
DestroyJob.perform_later(self) DestroyJob.perform_later(self)
end end
# Override destroy to handle error recovery for accounts
def destroy
super
rescue => e
# If destruction fails, transition back to disabled state
# This provides a cleaner recovery path than the generic scheduled_for_deletion flag
disable! if may_disable?
raise e
end
def current_holdings def current_holdings
holdings.where(currency: currency) holdings.where(currency: currency)
.where.not(qty: 0) .where.not(qty: 0)
@ -92,49 +127,9 @@ class Account < ApplicationRecord
.order(amount: :desc) .order(amount: :desc)
end end
def update_with_sync!(attributes)
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
initial_balance = attributes.dig(:accountable_attributes, :initial_balance) def update_balance(balance:, date: Date.current, currency: nil, notes: nil)
should_update_initial_balance = initial_balance && initial_balance.to_d != accountable.initial_balance Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:).update
transaction do
update!(attributes)
update_balance!(attributes[:balance]) if should_update_balance
update_inital_balance!(attributes[:accountable_attributes][:initial_balance]) if should_update_initial_balance
end
sync_later
end
def update_balance!(balance)
valuation = entries.valuations.find_by(date: Date.current)
if valuation
valuation.update! amount: balance
else
entries.create! \
date: Date.current,
name: "Balance update",
amount: balance,
currency: currency,
entryable: Valuation.new
end
end
def update_inital_balance!(initial_balance)
valuation = first_valuation
if valuation
valuation.update! amount: initial_balance
else
entries.create! \
date: Date.current,
name: "Initial Balance",
amount: initial_balance,
currency: currency,
entryable: Valuation.new
end
end end
def start_date def start_date

View file

@ -0,0 +1,47 @@
class Account::BalanceUpdater
def initialize(account, balance:, currency: nil, date: Date.current, notes: nil)
@account = account
@balance = balance.to_d
@currency = currency
@date = date.to_date
@notes = notes
end
def update
return Result.new(success?: true, updated?: false) unless requires_update?
Account.transaction do
if date == Date.current
account.balance = balance
account.currency = currency if currency.present?
account.save!
end
valuation_entry = account.entries.valuations.find_or_initialize_by(date: date) do |entry|
entry.entryable = Valuation.new
end
valuation_entry.amount = balance
valuation_entry.currency = currency if currency.present?
valuation_entry.name = "Manual #{account.accountable.balance_display_name} update"
valuation_entry.notes = notes if notes.present?
valuation_entry.save!
end
account.sync_later
Result.new(success?: true, updated?: true)
rescue => e
message = Rails.env.development? ? e.message : "Unable to update account values. Please try again."
Result.new(success?: false, updated?: false, error_message: message)
end
private
attr_reader :account, :balance, :currency, :date, :notes
Result = Struct.new(:success?, :updated?, :error_message)
def requires_update?
date != Date.current || account.balance != balance || account.currency != currency
end
end

View file

@ -56,7 +56,7 @@ class Assistant::Function
end end
def family_account_names def family_account_names
@family_account_names ||= family.accounts.active.pluck(:name) @family_account_names ||= family.accounts.visible.pluck(:name)
end end
def family_category_names def family_category_names

View file

@ -22,7 +22,7 @@ class Assistant::Function::GetAccounts < Assistant::Function
type: account.accountable_type, type: account.accountable_type,
start_date: account.start_date, start_date: account.start_date,
is_plaid_linked: account.plaid_account_id.present?, is_plaid_linked: account.plaid_account_id.present?,
is_active: account.is_active, status: account.status,
historical_balances: historical_balances(account) historical_balances: historical_balances(account)
} }
end end

View file

@ -44,7 +44,7 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
private private
def historical_data(period, classification: nil) def historical_data(period, classification: nil)
scope = family.accounts.active scope = family.accounts.visible
scope = scope.where(classification: classification) if classification.present? scope = scope.where(classification: classification) if classification.present?
if period.start_date == Date.current if period.start_date == Date.current

View file

@ -134,7 +134,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
def call(params = {}) def call(params = {})
search_params = params.except("order", "page") search_params = params.except("order", "page")
transactions_query = family.transactions.active.search(search_params) transactions_query = family.transactions.visible.search(search_params)
pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological
# By default, we give a small page size to force the AI to use filters effectively and save on tokens # By default, we give a small page size to force the AI to use filters effectively and save on tokens

View file

@ -23,8 +23,8 @@ class BalanceSheet::AccountTotals
delegate_missing_to :account delegate_missing_to :account
end end
def active_accounts def visible_accounts
@active_accounts ||= family.accounts.active.with_attached_logo @visible_accounts ||= family.accounts.visible.with_attached_logo
end end
def account_rows def account_rows
@ -46,7 +46,7 @@ class BalanceSheet::AccountTotals
def query def query
@query ||= Rails.cache.fetch(cache_key) do @query ||= Rails.cache.fetch(cache_key) do
active_accounts visible_accounts
.joins(ActiveRecord::Base.sanitize_sql_array([ .joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates ON exchange_rates.date = ? AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", "LEFT JOIN exchange_rates ON exchange_rates.date = ? AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?",
Date.current, Date.current,

View file

@ -6,7 +6,7 @@ class BalanceSheet::NetWorthSeriesBuilder
def net_worth_series(period: Period.last_30_days) def net_worth_series(period: Period.last_30_days)
Rails.cache.fetch(cache_key(period)) do Rails.cache.fetch(cache_key(period)) do
builder = Balance::ChartSeriesBuilder.new( builder = Balance::ChartSeriesBuilder.new(
account_ids: active_account_ids, account_ids: visible_account_ids,
currency: family.currency, currency: family.currency,
period: period, period: period,
favorable_direction: "up" favorable_direction: "up"
@ -19,8 +19,8 @@ class BalanceSheet::NetWorthSeriesBuilder
private private
attr_reader :family attr_reader :family
def active_account_ids def visible_account_ids
@active_account_ids ||= family.accounts.active.with_attached_logo.pluck(:id) @visible_account_ids ||= family.accounts.visible.with_attached_logo.pluck(:id)
end end
def cache_key(period) def cache_key(period)

View file

@ -17,7 +17,7 @@ class BalanceSheet::SyncStatusMonitor
def syncing_account_ids def syncing_account_ids
Rails.cache.fetch(cache_key) do Rails.cache.fetch(cache_key) do
Sync.visible Sync.visible
.where(syncable_type: "Account", syncable_id: family.accounts.active.pluck(:id)) .where(syncable_type: "Account", syncable_id: family.accounts.visible.pluck(:id))
.pluck(:syncable_id) .pluck(:syncable_id)
.to_set .to_set
end end

View file

@ -88,7 +88,7 @@ class Budget < ApplicationRecord
end end
def transactions def transactions
family.transactions.active.in_period(period) family.transactions.visible.in_period(period)
end end
def name def name

View file

@ -72,6 +72,14 @@ module Accountable
self.class.display_name self.class.display_name
end end
def balance_display_name
"account value"
end
def opening_balance_display_name
"opening balance"
end
def icon def icon
self.class.icon self.class.icon
end end

View file

@ -14,8 +14,8 @@ class Entry < ApplicationRecord
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? } validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } } validates :date, comparison: { greater_than: -> { min_supported_date } }
scope :active, -> { scope :visible, -> {
joins(:account).where(accounts: { is_active: true }) joins(:account).where(accounts: { status: [ "draft", "active" ] })
} }
scope :chronological, -> { scope :chronological, -> {

View file

@ -14,7 +14,7 @@ module Entryable
scope :with_entry, -> { joins(:entry) } scope :with_entry, -> { joins(:entry) }
scope :active, -> { with_entry.merge(Entry.active) } scope :visible, -> { with_entry.merge(Entry.visible) }
scope :in_period, ->(period) { scope :in_period, ->(period) {
with_entry.where(entries: { date: period.start_date..period.end_date }) with_entry.where(entries: { date: period.start_date..period.end_date })

View file

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

View file

@ -30,8 +30,8 @@ module Family::AutoTransferMatchable
.joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id") .joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
.joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id") .joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
.where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id) .where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id)
.where("inflow_accounts.is_active = true") .where("inflow_accounts.status IN ('draft', 'active')")
.where("outflow_accounts.is_active = true") .where("outflow_accounts.status IN ('draft', 'active')")
.where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'") .where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'")
.where(" .where("
( (

View file

@ -10,7 +10,7 @@ class IncomeStatement
end end
def totals(transactions_scope: nil) def totals(transactions_scope: nil)
transactions_scope ||= family.transactions.active transactions_scope ||= family.transactions.visible
result = totals_query(transactions_scope: transactions_scope) result = totals_query(transactions_scope: transactions_scope)
@ -62,7 +62,7 @@ class IncomeStatement
end end
def build_period_total(classification:, period:) def build_period_total(classification:, period:)
totals = totals_query(transactions_scope: family.transactions.active.in_period(period)).select { |t| t.classification == classification } totals = totals_query(transactions_scope: family.transactions.visible.in_period(period)).select { |t| t.classification == classification }
classification_total = totals.sum(&:total) classification_total = totals.sum(&:total)
uncategorized_category = family.categories.uncategorized uncategorized_category = family.categories.uncategorized

View file

@ -42,6 +42,14 @@ class Property < ApplicationRecord
Trend.new(current: account.balance_money, previous: first_valuation_amount) Trend.new(current: account.balance_money, previous: first_valuation_amount)
end end
def balance_display_name
"market value"
end
def opening_balance_display_name
"original purchase price"
end
private private
def first_valuation_amount def first_valuation_amount
account.entries.valuations.order(:date).first&.amount_money || account.balance_money account.entries.valuations.order(:date).first&.amount_money || account.balance_money

View file

@ -1,6 +1,6 @@
class Rule::Registry::TransactionResource < Rule::Registry class Rule::Registry::TransactionResource < Rule::Registry
def resource_scope def resource_scope
family.transactions.active.with_entry.where(entry: { date: rule.effective_date.. }) family.transactions.visible.with_entry.where(entry: { date: rule.effective_date.. })
end end
def condition_filters def condition_filters

View file

@ -81,7 +81,7 @@ class Transaction::Search
def apply_active_accounts_filter(query, active_accounts_only_filter) def apply_active_accounts_filter(query, active_accounts_only_filter)
if active_accounts_only_filter if active_accounts_only_filter
query.where(accounts: { is_active: true }) query.where(accounts: { status: [ "draft", "active" ] })
else else
query query
end end

View file

@ -6,7 +6,7 @@
<%= render "accounts/logo", account: account, size: "md" %> <%= render "accounts/logo", account: account, size: "md" %>
<div> <div>
<% if account.scheduled_for_deletion? %> <% if account.pending_deletion? %>
<p class="text-sm font-medium text-primary"> <p class="text-sm font-medium text-primary">
<span> <span>
<%= account.name %> <%= account.name %>
@ -16,31 +16,45 @@
</span> </span>
</p> </p>
<% else %> <% else %>
<%= link_to account.name, account, class: [(account.is_active ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %> <%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
<% if account.long_subtype_label %> <% if account.long_subtype_label %>
<p class="text-sm text-secondary truncate"><%= account.long_subtype_label %></p> <p class="text-sm text-secondary truncate"><%= account.long_subtype_label %></p>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
<% unless account.scheduled_for_deletion? %> <% unless account.pending_deletion? %>
<%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %> <%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %>
<%= icon("pencil-line", size: "sm") %> <%= icon("pencil-line", size: "sm") %>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
<div class="flex items-center gap-8"> <div class="flex items-center gap-8">
<% if account.syncing? %> <% if account.draft? %>
<!-- Balance hidden for draft accounts -->
<% elsif account.syncing? %>
<div class="w-16 h-6 bg-loader rounded-full animate-pulse"></div> <div class="w-16 h-6 bg-loader rounded-full animate-pulse"></div>
<% else %> <% else %>
<p class="text-sm font-medium <%= account.is_active ? "text-primary" : "text-subdued" %>"> <p class="text-sm font-medium <%= account.active? ? "text-primary" : "text-subdued" %>">
<%= format_money account.balance_money %> <%= format_money account.balance_money %>
</p> </p>
<% end %> <% end %>
<% unless account.scheduled_for_deletion? %> <% if account.draft? %>
<%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %> <%= render LinkComponent.new(
<%= f.toggle :is_active, { data: { auto_submit_form_target: "auto" } } %> text: "Complete setup",
href: edit_account_path(account, return_to: return_to),
variant: :outline,
frame: :modal
) %>
<% elsif account.active? || account.disabled? %>
<%= form_with model: account, url: toggle_active_account_path(account), method: :patch, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
<%= render ToggleComponent.new(
id: "account_#{account.id}_active",
name: "active",
checked: account.active?,
data: { auto_submit_form_target: "auto" }
) %>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>

View file

@ -1,5 +1,9 @@
<%# locals: (account:, url:) %> <%# locals: (account:, url:) %>
<% if @error_message.present? %>
<%= render AlertComponent.new(message: @error_message, variant: :error) %>
<% 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, data: { turbo: false }, class: "flex flex-col gap-4 justify-between grow text-primary" do |form| %>
<div class="grow space-y-2"> <div class="grow space-y-2">
<%= form.hidden_field :accountable_type %> <%= form.hidden_field :accountable_type %>

View file

@ -12,7 +12,18 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="truncate"> <div class="truncate">
<div class="flex items-center gap-3">
<h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title || account.name %></h2> <h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title || account.name %></h2>
<% if account.draft? %>
<%= render LinkComponent.new(
text: "Complete setup",
href: edit_account_path(account),
variant: :outline,
size: :sm,
frame: :modal
) %>
<% end %>
</div>
<% if subtitle.present? %> <% if subtitle.present? %>
<p class="text-sm text-secondary"><%= subtitle %></p> <p class="text-sm text-secondary"><%= subtitle %></p>
<% end %> <% end %>

View file

@ -22,7 +22,7 @@
<%= form.select :col_sep, Import::SEPARATORS, label: true %> <%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
<%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> <%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<% end %> <% end %>
<div class="flex flex-col items-center justify-center w-full h-64 border border-secondary border-dashed rounded-xl cursor-pointer" data-controller="file-upload" data-action="click->file-upload#triggerFileInput" data-file-upload-target="uploadArea"> <div class="flex flex-col items-center justify-center w-full h-64 border border-secondary border-dashed rounded-xl cursor-pointer" data-controller="file-upload" data-action="click->file-upload#triggerFileInput" data-file-upload-target="uploadArea">
@ -54,7 +54,7 @@
<%= form.select :col_sep, Import::SEPARATORS, label: true %> <%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
<%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> <%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<% end %> <% end %>
<%= form.text_area :raw_file_str, <%= form.text_area :raw_file_str,

View file

@ -0,0 +1,7 @@
<%# locals: (notice: nil, error: nil) %>
<% if notice.present? %>
<%= render AlertComponent.new(message: notice, variant: :success) %>
<% elsif error.present? %>
<%= render AlertComponent.new(message: error, variant: :error) %>
<% end %>

View file

@ -0,0 +1,16 @@
<%# locals: (label:, href: nil, active: false) %>
<% classes = class_names(
"flex items-center px-3 py-2 rounded-lg text-sm font-medium",
active ? "bg-surface-inset text-primary" : "text-secondary",
) %>
<% if href.present? %>
<%= link_to href, data: { turbo_frame: :modal }, class: class_names(classes, "cursor-pointer hover:bg-surface-inset-hover hover:text-primary") do %>
<%= label %>
<% end %>
<% else %>
<%= tag.span class: classes do %>
<%= label %>
<% end %>
<% end %>

View file

@ -0,0 +1,7 @@
<%# locals: (account:, active_tab:) %>
<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: "Address", href: account.new_record? ? nil : address_property_path(@account), active: active_tab == "address" %>
</div>

View file

@ -0,0 +1,35 @@
<%# locals: (form:) %>
<div class="flex flex-col gap-2">
<%= form.text_field :name,
label: "Name",
placeholder: "Vacation home",
required: true %>
<%= form.select :subtype,
Property::SUBTYPES.map { |k, v| [v[:long], k] },
{ prompt: "Select type", label: "Property type" }, required: true %>
<%= form.hidden_field :accountable_type, value: "Property" %>
<%= form.fields_for :accountable do |property_form| %>
<div class="flex items-center gap-2">
<%= property_form.number_field :year_built,
label: "Year Built (optional)",
placeholder: "1990",
min: 1800,
max: Time.current.year %>
</div>
<div class="flex items-center gap-2">
<%= property_form.number_field :area_value,
label: "Area (optional)",
placeholder: "1200",
min: 0 %>
<%= property_form.select :area_unit,
[["Square Feet", "sqft"], ["Square Meters", "sqm"]],
{ label: "Area Unit" } %>
</div>
<% end %>
</div>

View file

@ -0,0 +1,50 @@
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: "Enter property manually") %>
<% dialog.with_body do %>
<div class="flex gap-4">
<!-- Left sidebar with tabs -->
<%= render "properties/form_tabs", account: @account, active_tab: "address" %>
<!-- Right content area with form -->
<div class="flex-1">
<%= styled_form_with model: @property, url: update_address_property_path(@account), method: :patch, data: { turbo_frame: @property.address.persisted? ? nil : :_top } do |form| %>
<div class="flex flex-col gap-2 min-h-[320px]">
<%= render "properties/form_alert", notice: @success_message, error: @error_message %>
<%= form.fields_for :address do |address_form| %>
<%= address_form.text_field :line1,
label: "Address Line 1",
placeholder: "123 Main Street" %>
<div class="flex items-center gap-2">
<%= address_form.text_field :locality,
label: "City",
placeholder: "San Francisco" %>
<%= address_form.text_field :region,
label: "State/Region",
placeholder: "CA" %>
</div>
<div class="flex items-center gap-2">
<%= address_form.text_field :postal_code,
label: "Postal Code",
placeholder: "12345" %>
<%= address_form.text_field :country,
label: "Country",
placeholder: "USA" %>
</div>
<% end %>
</div>
<!-- Save button -->
<div class="flex justify-end mt-4">
<%= render ButtonComponent.new(
text: "Save",
variant: "primary",
) %>
</div>
<% end %>
</div>
</div>
<% end %>
<% end %>

View file

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

View file

@ -1,6 +1,27 @@
<%= render DialogComponent.new do |dialog| %> <%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".edit", account: @account.name)) %> <% dialog.with_header(title: "Enter property manually") %>
<% dialog.with_body do %> <% dialog.with_body do %>
<%= render "form", account: @account, url: property_path(@account) %> <div class="flex gap-4">
<!-- Left sidebar with tabs -->
<%= render "properties/form_tabs", account: @account, active_tab: "overview" %>
<!-- Right content area with form -->
<div class="flex-1">
<%= 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 %>
</div>
<!-- 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 %>
<% end %> <% end %>

View file

@ -1,6 +1,27 @@
<%= render DialogComponent.new do |dialog| %> <%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %> <% dialog.with_header(title: "Enter property manually") %>
<% dialog.with_body do %> <% dialog.with_body do %>
<%= render "properties/form", account: @account, url: properties_path(return_to: params[:return_to]) %> <div class="flex gap-4">
<!-- Left sidebar with tabs -->
<%= render "properties/form_tabs", account: @account, active_tab: "overview" %>
<!-- Right content area with form -->
<div class="flex-1">
<%= 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 %>
</div>
<!-- Create button -->
<div class="flex justify-end mt-4">
<%= render ButtonComponent.new(
text: "Next",
variant: "primary",
) %>
</div>
<% end %>
</div>
</div>
<% end %> <% end %>
<% end %> <% end %>

View file

@ -7,11 +7,33 @@
end end
currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %> currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %>
<div class="form-field pr-0 <%= options[:container_class] %>" data-controller="money-field"> <div class="form-field <%= options[:container_class] %>" data-controller="money-field">
<% if options[:label_tooltip] %>
<div class="form-field__header">
<%= form.label options[:label] || t(".label"), class: "form-field__label" do %> <%= form.label options[:label] || t(".label"), class: "form-field__label" do %>
<%= options[:label] || t(".label") %> <%= options[:label] || t(".label") %>
<% if options[:required] %> <% if options[:required] %>
<span class="text-red-500">*</span> <span class="text-red-500 ml-0.5">*</span>
<% end %>
<% end %>
<div class="form-field__actions">
<div data-controller="tooltip">
<%= icon "help-circle", size: "sm", color: "default", class: "cursor-help" %>
<div role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64 text-white">
<%= options[:label_tooltip] %>
</div>
</div>
</div>
</div>
<% end %>
<div class="form-field__body">
<% unless options[:label_tooltip] %>
<%= form.label options[:label] || t(".label"), class: "form-field__label" do %>
<%= options[:label] || t(".label") %>
<% if options[:required] %>
<span class="text-red-500 ml-0.5">*</span>
<% end %>
<% end %> <% end %>
<% end %> <% end %>
@ -59,4 +81,5 @@
</div> </div>
<% end %> <% end %>
</div> </div>
</div>
</div> </div>

View file

@ -45,7 +45,7 @@
<% end %> <% end %>
<% if %w[deposit withdrawal].include?(type) %> <% if %w[deposit withdrawal].include?(type) %>
<%= form.collection_select :transfer_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %> <%= form.collection_select :transfer_account_id, Current.family.accounts.visible.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %>
<% end %> <% end %>
<% if %w[buy sell].include?(type) %> <% if %w[buy sell].include?(type) %>

View file

@ -1,10 +1,10 @@
<%# locals: (entry:) %> <%# locals: (entry:, error_message:) %>
<%= styled_form_with model: entry, url: valuations_path, class: "space-y-4" do |form| %> <%= styled_form_with model: entry, url: valuations_path, class: "space-y-4" do |form| %>
<%= form.hidden_field :account_id %> <%= form.hidden_field :account_id %>
<% if entry.errors.any? %> <% if error_message.present? %>
<%= render "shared/form_errors", model: entry %> <%= render AlertComponent.new(message: error_message, variant: :error) %>
<% end %> <% end %>
<div class="space-y-3"> <div class="space-y-3">

View file

@ -2,7 +2,7 @@
<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %> <%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %>
<span class="text-secondary text-sm"> <span class="text-secondary text-sm">
<%= t(".balance") %> <%= entry.name %>
</span> </span>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">

View file

@ -25,15 +25,7 @@
</div> </div>
</div> </div>
<div class="col-span-2 justify-self-end font-medium text-sm"> <div class="col-span-4 justify-self-end">
<% if balance_trend&.trend %>
<%= tag.span format_money(balance_trend.trend.value), style: "color: #{balance_trend.trend.color}" %>
<% else %>
<%= tag.span "--", class: "text-gray-400" %>
<% end %>
</div>
<div class="col-span-2 justify-self-end">
<%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-primary" %> <%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-primary" %>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
<%= render DialogComponent.new do |dialog| %> <%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %> <% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %> <% dialog.with_body do %>
<%= render "form", entry: @entry %> <%= render "form", entry: @entry, error_message: @error_message %>
<% end %> <% end %>
<% end %> <% end %>

View file

@ -6,17 +6,18 @@
<% end %> <% end %>
<% dialog.with_body do %> <% dialog.with_body do %>
<% if @error_message.present? %>
<div class="mb-4">
<%= render AlertComponent.new(message: @error_message, variant: :error) %>
</div>
<% end %>
<% dialog.with_section(title: t(".overview"), open: true) do %> <% dialog.with_section(title: t(".overview"), open: true) do %>
<div class="pb-4"> <div class="pb-4">
<%= styled_form_with model: entry, <%= styled_form_with model: entry,
url: entry_path(entry), url: entry_path(entry),
class: "space-y-2", class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %> data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_field :name,
label: t(".name_label"),
placeholder: t(".name_placeholder"),
"data-auto-submit-form-target": "auto" %>
<%= f.date_field :date, <%= f.date_field :date,
label: t(".date_label"), label: t(".date_label"),
max: Date.current, max: Date.current,

View file

@ -1,5 +1,28 @@
{ {
"ignored_warnings": [ "ignored_warnings": [
{
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "85e2c11853dd6c69b1953a6ec3ad661cd0ce3df55e4e5beff92365b6ed601171",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/transactions_controller.rb",
"line": 255,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.require(:transaction).permit(:account_id, :date, :amount, :name, :description, :notes, :currency, :category_id, :merchant_id, :nature, :tag_ids => ([]))",
"render_path": null,
"location": {
"type": "method",
"class": "Api::V1::TransactionsController",
"method": "transaction_params"
},
"user_input": ":account_id",
"confidence": "High",
"cwe_id": [
915
],
"note": "account_id is properly validated in create action - line 79 ensures account belongs to user's family: family.accounts.find(transaction_params[:account_id])"
},
{ {
"warning_type": "Mass Assignment", "warning_type": "Mass Assignment",
"warning_code": 105, "warning_code": 105,
@ -26,13 +49,13 @@
{ {
"warning_type": "Dangerous Eval", "warning_type": "Dangerous Eval",
"warning_code": 13, "warning_code": 13,
"fingerprint": "c193307bb82f931950d3bf2855f82f9a7f50d94c5bd950ee2803cb8a8abe5253", "fingerprint": "c154514a0f86341473e4abf35e77721495b326c7855e4967d284b4942371819c",
"check_name": "Evaluation", "check_name": "Evaluation",
"message": "Dynamic string evaluated as code", "message": "Dynamic string evaluated as code",
"file": "app/helpers/styled_form_builder.rb", "file": "app/helpers/styled_form_builder.rb",
"line": 7, "line": 5,
"link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/", "link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/",
"code": "class_eval(\" def #{selector}(method, options = {})\\n merged_options = { class: \\\"form-field__input\\\" }.merge(options)\\n label = build_label(method, options)\\n field = super(method, merged_options)\\n\\n build_styled_field(label, field, merged_options)\\n end\\n\", \"app/helpers/styled_form_builder.rb\", (7 + 1))", "code": "class_eval(\" def #{selector}(method, options = {})\\n form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)\\n html_options = options.except(:label, :label_tooltip, :inline, :container_class)\\n\\n build_field(method, form_options, html_options) do |merged_options|\\n super(method, merged_options)\\n end\\n end\\n\", \"app/helpers/styled_form_builder.rb\", (5 + 1))",
"render_path": null, "render_path": null,
"location": { "location": {
"type": "method", "type": "method",
@ -45,7 +68,7 @@
913, 913,
95 95
], ],
"note": "This is safe as 'selector' comes from a predefined list of Rails form helpers (StyledFormBuilder.text_field_helpers)." "note": "Uses similar pattern to Rails internal form builder"
}, },
{ {
"warning_type": "Dynamic Render Path", "warning_type": "Dynamic Render Path",
@ -80,29 +103,6 @@
22 22
], ],
"note": "" "note": ""
},
{
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "85e2c11853dd6c69b1953a6ec3ad661cd0ce3df55e4e5beff92365b6ed601171",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/api/v1/transactions_controller.rb",
"line": 255,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.require(:transaction).permit(:account_id, :date, :amount, :name, :description, :notes, :currency, :category_id, :merchant_id, :nature, :tag_ids => ([]))",
"render_path": null,
"location": {
"type": "method",
"class": "Api::V1::TransactionsController",
"method": "transaction_params"
},
"user_input": ":account_id",
"confidence": "High",
"cwe_id": [
915
],
"note": "account_id is properly validated in create action - line 79 ensures account belongs to user's family: family.accounts.find(transaction_params[:account_id])"
} }
], ],
"brakeman_version": "7.0.2" "brakeman_version": "7.0.2"

View file

@ -108,14 +108,6 @@ Rails.application.routes.draw do
resources :mappings, only: :update, module: :import resources :mappings, only: :update, module: :import
end end
resources :accounts, only: %i[index new], shallow: true do
member do
post :sync
get :chart
get :sparkline
end
end
resources :holdings, only: %i[index new show destroy] resources :holdings, only: %i[index new show destroy]
resources :trades, only: %i[show new create update destroy] resources :trades, only: %i[show new create update destroy]
resources :valuations, only: %i[show new create update destroy] resources :valuations, only: %i[show new create update destroy]
@ -155,18 +147,36 @@ Rails.application.routes.draw do
end end
end end
resources :accounts, only: %i[index new], shallow: true do
member do
post :sync
get :chart
get :sparkline
patch :toggle_active
end
end
# Convenience routes for polymorphic paths # Convenience routes for polymorphic paths
# Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123 # Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123
direct :account do |model, options| direct :account do |model, options|
route_for model.accountable_name, model, options route_for model.accountable_name, model, options
end end
direct :edit_account do |model, options| direct :edit_account do |model, options|
route_for "edit_#{model.accountable_name}", model, options route_for "edit_#{model.accountable_name}", model, options
end end
resources :depositories, except: :index resources :depositories, except: :index
resources :investments, except: :index resources :investments, except: :index
resources :properties, except: :index resources :properties, except: :index do
member do
get :balances
patch :update_balances
get :address
patch :update_address
end
end
resources :vehicles, except: :index resources :vehicles, except: :index
resources :credit_cards, except: :index resources :credit_cards, except: :index
resources :loans, except: :index resources :loans, except: :index

View file

@ -0,0 +1,41 @@
class AddAccountStatus < ActiveRecord::Migration[7.2]
def up
add_column :accounts, :status, :string, default: "active"
change_column_null :entries, :amount, false
# Migrate existing data
execute <<-SQL
UPDATE accounts
SET status = CASE
WHEN scheduled_for_deletion = true THEN 'pending_deletion'
WHEN is_active = true THEN 'active'
WHEN is_active = false THEN 'disabled'
ELSE 'draft'
END
SQL
remove_column :accounts, :is_active
remove_column :accounts, :scheduled_for_deletion
end
def down
add_column :accounts, :is_active, :boolean, default: true, null: false
add_column :accounts, :scheduled_for_deletion, :boolean, default: false
# Restore the original boolean fields based on status
execute <<-SQL
UPDATE accounts
SET is_active = CASE
WHEN status = 'active' THEN true
WHEN status IN ('disabled', 'pending_deletion') THEN false
ELSE false
END,
scheduled_for_deletion = CASE
WHEN status = 'pending_deletion' THEN true
ELSE false
END
SQL
remove_column :accounts, :status
end
end

16
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_06_23_162207) do ActiveRecord::Schema[7.2].define(version: 2025_07_01_161640) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -29,13 +29,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_23_162207) do
t.uuid "accountable_id" t.uuid "accountable_id"
t.decimal "balance", precision: 19, scale: 4 t.decimal "balance", precision: 19, scale: 4
t.string "currency" t.string "currency"
t.boolean "is_active", default: true, null: false 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.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.uuid "import_id" t.uuid "import_id"
t.uuid "plaid_account_id" t.uuid "plaid_account_id"
t.boolean "scheduled_for_deletion", default: false
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
t.jsonb "locked_attributes", default: {} t.jsonb "locked_attributes", default: {}
t.string "status", default: "active"
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type"
@ -205,7 +204,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_23_162207) do
t.uuid "account_id", null: false t.uuid "account_id", null: false
t.string "entryable_type" t.string "entryable_type"
t.uuid "entryable_id" t.uuid "entryable_id"
t.decimal "amount", precision: 19, scale: 4 t.decimal "amount", precision: 19, scale: 4, null: false
t.string "currency" t.string "currency"
t.date "date" t.date "date"
t.string "name", null: false t.string "name", null: false
@ -216,12 +215,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_23_162207) do
t.boolean "excluded", default: false t.boolean "excluded", default: false
t.string "plaid_id" t.string "plaid_id"
t.jsonb "locked_attributes", default: {} 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 ["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" t.index ["import_id"], name: "index_entries_on_import_id"
end end
@ -232,7 +226,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_23_162207) do
t.date "date", null: false t.date "date", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_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", "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 ["from_currency"], name: "index_exchange_rates_on_from_currency"
t.index ["to_currency"], name: "index_exchange_rates_on_to_currency" t.index ["to_currency"], name: "index_exchange_rates_on_to_currency"
@ -691,7 +684,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_23_162207) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["tag_id"], name: "index_taggings_on_tag_id" 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" t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable"
end end

View file

@ -0,0 +1,7 @@
class AlertComponentPreview < Lookbook::Preview
# @param message text
# @param variant select [info, success, warning, error]
def default(message: "This is an alert message.", variant: :info)
render AlertComponent.new(message: message, variant: variant.to_sym)
end
end

View file

@ -81,7 +81,7 @@ end
test "should only return active accounts" do test "should only return active accounts" do
# Make one account inactive # Make one account inactive
inactive_account = accounts(:depository) inactive_account = accounts(:depository)
inactive_account.update!(is_active: false) inactive_account.disable!
access_token = Doorkeeper::AccessToken.create!( access_token = Doorkeeper::AccessToken.create!(
application: @oauth_app, application: @oauth_app,

View file

@ -8,72 +8,169 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
@account = accounts(:property) @account = accounts(:property)
end end
test "creates with property details" do test "creates property in draft status and redirects to balances step" do
assert_difference -> { Account.count } => 1, assert_difference -> { Account.count } => 1 do
-> { Property.count } => 1,
-> { Valuation.count } => 2,
-> { Entry.count } => 2 do
post properties_path, params: { post properties_path, params: {
account: { account: {
name: "Property", name: "New Property",
balance: 500000, subtype: "house",
currency: "USD",
accountable_type: "Property", accountable_type: "Property",
accountable_attributes: { accountable_attributes: {
year_built: 2002, year_built: 1990,
area_value: 1000, area_value: 1200,
area_unit: "sqft", area_unit: "sqft"
address_attributes: {
line1: "123 Main St",
line2: "Apt 1",
locality: "Los Angeles",
region: "CA", # ISO3166-2 code
country: "US", # ISO3166-1 Alpha-2 code
postal_code: "90001"
}
} }
} }
} }
end end
created_account = Account.order(:created_at).last created_account = Account.order(:created_at).last
assert created_account.accountable.is_a?(Property)
assert created_account.accountable.year_built.present? assert_equal "draft", created_account.status
assert created_account.accountable.address.line1.present? assert_equal 0, created_account.balance
assert_equal 1990, created_account.accountable.year_built
assert_redirected_to created_account assert_equal 1200, created_account.accountable.area_value
assert_equal "Property account created", flash[:notice] assert_equal "sqft", created_account.accountable.area_unit
assert_enqueued_with(job: SyncJob) assert_redirected_to balances_property_path(created_account)
end end
test "updates with property details" do test "updates property overview" do
assert_no_difference [ "Account.count", "Property.count" ] do assert_no_difference [ "Account.count", "Property.count" ] do
patch account_path(@account), params: { patch property_path(@account), params: {
account: { account: {
name: "Updated Property", name: "Updated Property",
balance: 500000, subtype: "condo"
currency: "USD",
accountable_type: "Property",
accountable_attributes: {
id: @account.accountable_id,
year_built: 2002,
area_value: 1000,
area_unit: "sqft",
address_attributes: {
line1: "123 Main St",
line2: "Apt 1",
locality: "Los Angeles",
region: "CA", # ISO3166-2 code
country: "US", # ISO3166-1 Alpha-2 code
postal_code: "90001"
}
}
} }
} }
end end
assert_redirected_to @account @account.reload
assert_equal "Property account updated", flash[:notice] assert_equal "Updated Property", @account.name
assert_enqueued_with(job: SyncJob) assert_equal "condo", @account.subtype
# If account is active, it renders edit view; otherwise redirects to balances
if @account.active?
assert_response :success
else
assert_redirected_to balances_property_path(@account)
end
end
# Tab view tests
test "shows balances tab" do
get balances_property_path(@account)
assert_response :success
end
test "shows address tab" do
get address_property_path(@account)
assert_response :success
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: {
account: {
balance: 600000,
currency: "EUR"
}
}
# If account is active, it renders balances view; otherwise redirects to address
if @account.reload.active?
assert_response :success
else
assert_redirected_to address_property_path(@account)
end
end
test "updates address tab" do
patch update_address_property_path(@account), params: {
property: {
address_attributes: {
line1: "456 New Street",
locality: "San Francisco",
region: "CA",
country: "US",
postal_code: "94102"
}
}
}
@account.reload
assert_equal "456 New Street", @account.accountable.address.line1
assert_equal "San Francisco", @account.accountable.address.locality
# If account is draft, it activates and redirects; otherwise renders address
if @account.draft?
assert_redirected_to account_path(@account)
else
assert_response :success
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)
patch update_address_property_path(@account), params: {
property: {
address_attributes: {
line1: "123 Test St"
}
}
}
assert_response :unprocessable_entity
end
test "address update activates draft account" do
# Create a draft property account
draft_account = Account.create!(
family: @user.family,
name: "Draft Property",
accountable: Property.new,
status: "draft",
balance: 500000,
currency: "USD"
)
assert draft_account.draft?
patch update_address_property_path(draft_account), params: {
property: {
address_attributes: {
line1: "789 Activate St",
locality: "New York",
region: "NY",
country: "US",
postal_code: "10001"
}
}
}
draft_account.reload
assert draft_account.active?
assert_redirected_to account_path(draft_account)
end end
end end

View file

@ -8,35 +8,24 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
@entry = entries(:valuation) @entry = entries(:valuation)
end end
test "error when valuation already exists for date" do
assert_no_difference [ "Entry.count", "Valuation.count" ] do
post valuations_url(@entry.account), params: {
entry: {
account_id: @entry.account_id,
amount: 19800,
date: @entry.date,
currency: "USD"
}
}
end
assert_response :unprocessable_entity
end
test "creates entry with basic attributes" do test "creates entry with basic attributes" do
account = accounts(:investment)
assert_difference [ "Entry.count", "Valuation.count" ], 1 do assert_difference [ "Entry.count", "Valuation.count" ], 1 do
post valuations_url, params: { post valuations_url, params: {
entry: { entry: {
name: "New entry", amount: account.balance + 100,
amount: 10000,
currency: "USD", currency: "USD",
date: Date.current, date: Date.current.to_s,
account_id: @entry.account_id account_id: account.id
} }
} }
end end
created_entry = Entry.order(created_at: :desc).first created_entry = Entry.order(created_at: :desc).first
assert_equal "Manual account value update", created_entry.name
assert_equal Date.current, created_entry.date
assert_equal account.balance + 100, created_entry.amount_money.to_f
assert_enqueued_with job: SyncJob assert_enqueued_with job: SyncJob
@ -47,7 +36,6 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
assert_no_difference [ "Entry.count", "Valuation.count" ] do assert_no_difference [ "Entry.count", "Valuation.count" ] do
patch valuation_url(@entry), params: { patch valuation_url(@entry), params: {
entry: { entry: {
name: "Updated entry",
amount: 20000, amount: 20000,
currency: "USD", currency: "USD",
date: Date.current date: Date.current

View file

@ -5,6 +5,7 @@ other_asset:
currency: USD currency: USD
accountable_type: OtherAsset accountable_type: OtherAsset
accountable: one accountable: one
status: active
other_liability: other_liability:
family: dylan_family family: dylan_family
@ -13,6 +14,7 @@ other_liability:
currency: USD currency: USD
accountable_type: OtherLiability accountable_type: OtherLiability
accountable: one accountable: one
status: active
depository: depository:
family: dylan_family family: dylan_family
@ -21,6 +23,7 @@ depository:
currency: USD currency: USD
accountable_type: Depository accountable_type: Depository
accountable: one accountable: one
status: active
connected: connected:
family: dylan_family family: dylan_family
@ -31,6 +34,7 @@ connected:
accountable_type: Depository accountable_type: Depository
accountable: two accountable: two
plaid_account: one plaid_account: one
status: active
credit_card: credit_card:
family: dylan_family family: dylan_family
@ -39,6 +43,7 @@ credit_card:
currency: USD currency: USD
accountable_type: CreditCard accountable_type: CreditCard
accountable: one accountable: one
status: active
investment: investment:
family: dylan_family family: dylan_family
@ -48,6 +53,7 @@ investment:
currency: USD currency: USD
accountable_type: Investment accountable_type: Investment
accountable: one accountable: one
status: active
loan: loan:
family: dylan_family family: dylan_family
@ -56,6 +62,7 @@ loan:
currency: USD currency: USD
accountable_type: Loan accountable_type: Loan
accountable: one accountable: one
status: active
property: property:
family: dylan_family family: dylan_family
@ -64,6 +71,7 @@ property:
currency: USD currency: USD
accountable_type: Property accountable_type: Property
accountable: one accountable: one
status: active
vehicle: vehicle:
family: dylan_family family: dylan_family
@ -72,6 +80,7 @@ vehicle:
currency: USD currency: USD
accountable_type: Vehicle accountable_type: Vehicle
accountable: one accountable: one
status: active
crypto: crypto:
family: dylan_family family: dylan_family
@ -80,3 +89,4 @@ crypto:
currency: USD currency: USD
accountable_type: Crypto accountable_type: Crypto
accountable: one accountable: one
status: active

View file

@ -26,66 +26,4 @@ module AccountableResourceInterfaceTest
assert_enqueued_with job: DestroyJob assert_enqueued_with job: DestroyJob
assert_equal "#{@account.accountable_name.underscore.humanize} account scheduled for deletion", flash[:notice] assert_equal "#{@account.accountable_name.underscore.humanize} account scheduled for deletion", flash[:notice]
end end
test "updates basic account balances" do
assert_no_difference [ "Account.count", "@account.accountable_class.count" ] do
patch account_url(@account), params: {
account: {
name: "Updated name",
balance: 10000,
currency: "USD"
}
}
end
assert_redirected_to @account
assert_equal "#{@account.accountable_name.underscore.humanize} account updated", flash[:notice]
end
test "creates with basic attributes" do
assert_difference [ "Account.count", "@account.accountable_class.count" ], 1 do
post "/#{@account.accountable_name.pluralize}", params: {
account: {
accountable_type: @account.accountable_class,
name: "New accountable",
balance: 10000,
currency: "USD",
subtype: "checking"
}
}
end
assert_redirected_to Account.order(:created_at).last
assert_equal "#{@account.accountable_name.humanize} account created", flash[:notice]
end
test "updates account balance by creating new valuation if balance has changed" do
assert_difference [ "Entry.count", "Valuation.count" ], 1 do
patch account_url(@account), params: {
account: {
balance: 12000
}
}
end
assert_redirected_to @account
assert_enqueued_with job: SyncJob
assert_equal "#{@account.accountable_name.humanize} account updated", flash[:notice]
end
test "updates account balance by editing existing valuation for today" do
@account.entries.create! date: Date.current, amount: 6000, currency: "USD", name: "Balance update", entryable: Valuation.new
assert_no_difference [ "Entry.count", "Valuation.count" ] do
patch account_url(@account), params: {
account: {
balance: 12000
}
}
end
assert_redirected_to @account
assert_enqueued_with job: SyncJob
assert_equal "#{@account.accountable_name.humanize} account updated", flash[:notice]
end
end end

View file

@ -67,21 +67,21 @@ class EntryTest < ActiveSupport::TestCase
assert_equal 0, family.entries.search(params).size assert_equal 0, family.entries.search(params).size
end end
test "active scope only returns entries from active accounts" do test "visible scope only returns entries from visible accounts" do
# Create transactions for all account types # Create transactions for all account types
active_transaction = create_transaction(account: accounts(:depository), name: "Active transaction") visible_transaction = create_transaction(account: accounts(:depository), name: "Visible transaction")
inactive_transaction = create_transaction(account: accounts(:credit_card), name: "Inactive transaction") invisible_transaction = create_transaction(account: accounts(:credit_card), name: "Invisible transaction")
# Update account statuses # Update account statuses
accounts(:credit_card).update!(is_active: false) accounts(:credit_card).disable!
# Test the scope # Test the scope
active_entries = Entry.active visible_entries = Entry.visible
# Should include entry from active account # Should include entry from active account
assert_includes active_entries, active_transaction assert_includes visible_entries, visible_transaction
# Should not include entry from inactive account # Should not include entry from disabled account
assert_not_includes active_entries, inactive_transaction assert_not_includes visible_entries, invisible_transaction
end end
end end

View file

@ -39,7 +39,7 @@ class BalanceSheetTest < ActiveSupport::TestCase
create_account(balance: 10000, accountable: Depository.new) create_account(balance: 10000, accountable: Depository.new)
other_liability = create_account(balance: 5000, accountable: OtherLiability.new) other_liability = create_account(balance: 5000, accountable: OtherLiability.new)
other_liability.update!(is_active: false) other_liability.disable!
assert_equal 10000 - 1000, BalanceSheet.new(@family).net_worth assert_equal 10000 - 1000, BalanceSheet.new(@family).net_worth
assert_equal 10000, BalanceSheet.new(@family).assets.total assert_equal 10000, BalanceSheet.new(@family).assets.total

View file

@ -89,7 +89,7 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase
end end
test "does not consider inactive accounts when matching transfers" do test "does not consider inactive accounts when matching transfers" do
@depository.update!(is_active: false) @depository.disable!
outflow = create_transaction(date: Date.current, account: @depository, amount: 500) outflow = create_transaction(date: Date.current, account: @depository, amount: 500)
inflow = create_transaction(date: Date.current, account: @credit_card, amount: -500) inflow = create_transaction(date: Date.current, account: @credit_card, amount: -500)

View file

@ -23,15 +23,40 @@ class AccountsTest < ApplicationSystemTestCase
end end
test "can create property account" do test "can create property account" do
assert_account_created "Property" do # Step 1: Select property type and enter basic details
fill_in "Year built", with: 2005 click_link "Property"
fill_in "Living area", with: 2250
fill_in "Street address", with: "123 Main St" 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
click_button "Next"
# Step 2: Enter balance information
assert_text "Value"
fill_in "account[balance]", with: 500000
click_button "Next"
# Step 3: Enter address information
assert_text "Address"
fill_in "Address Line 1", with: "123 Main St"
fill_in "City", with: "San Francisco" fill_in "City", with: "San Francisco"
fill_in "State/Province", with: "CA" fill_in "State/Region", with: "CA"
fill_in "ZIP/Postal code", with: "94101" fill_in "Postal Code", with: "94101"
fill_in "Country", with: "US" fill_in "Country", with: "US"
end
click_button "Save"
# Verify account was created and is now active
assert_text account_name
created_account = Account.order(:created_at).last
assert_equal "active", created_account.status
assert_equal 500000, created_account.balance
assert_equal "123 Main St", created_account.property.address.line1
assert_equal "San Francisco", created_account.property.address.locality
end end
test "can create vehicle account" do test "can create vehicle account" do