1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +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 {
@apply block text-xs text-secondary peer-disabled:text-subdued;
}
@ -347,10 +360,6 @@
@apply transition-opacity duration-300;
@apply placeholder:text-subdued;
&select {
@apply pr-8;
}
@variant theme-dark {
&::-webkit-calendar-picker-indicator {
filter: invert(1);
@ -358,6 +367,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 {
@apply text-primary;
@ -425,7 +442,5 @@
@variant theme-dark {
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 %>
<% if icon && (icon_position != "right") %>
<%= helpers.icon(icon, size: size, color: icon_color) %>
<% end %>
<% unless icon_only? %>
@ -10,6 +9,5 @@
<% if icon && icon_position == "right" %>
<%= helpers.icon(icon, size: size, color: icon_color) %>
<% end %>
<% end %>

View file

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

View file

@ -1,5 +1,5 @@
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
def index
@ -33,6 +33,15 @@ class AccountsController < ApplicationController
end
end
def toggle_active
if @account.active?
@account.disable!
elsif @account.disabled?
@account.enable!
end
redirect_to accounts_path
end
private
def family
Current.family

View file

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

View file

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

View file

@ -43,9 +43,25 @@ module AccountableResource
end
def update
@account.update_with_sync!(account_params.except(:return_to))
@account.lock_saved_attributes!
# 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
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)
end
@ -74,7 +90,7 @@ module AccountableResource
def account_params
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
)
end

View file

@ -5,7 +5,7 @@ class PagesController < ApplicationController
def dashboard
@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]
@cashflow_period = if period_param.present?

View file

@ -1,21 +1,99 @@
class PropertiesController < ApplicationController
include AccountableResource
include AccountableResource, StreamExtensions
permitted_accountable_attributes(
:id, :year_built, :area_unit, :area_value,
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
)
before_action :set_property, only: [ :balances, :address, :update_balances, :update_address ]
def new
@account = Current.family.accounts.build(
currency: Current.family.currency,
accountable: Property.new(
address: Address.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")
)
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
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

View file

@ -2,7 +2,7 @@ class TransferMatchesController < ApplicationController
before_action :set_entry
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
end

View file

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

View file

@ -1,42 +1,38 @@
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 ]
# Wraps "text" inputs with custom structure + base styles
text_field_helpers.each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
merged_options = { class: "form-field__input" }.merge(options)
label = build_label(method, options)
field = super(method, merged_options)
form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)
html_options = options.except(:label, :label_tooltip, :inline, :container_class)
build_styled_field(label, field, merged_options)
build_field(method, form_options, html_options) do |merged_options|
super(method, merged_options)
end
end
RUBY_EVAL
end
def radio_button(method, tag_value, options = {})
merged_options = { class: "form-field__radio" }.merge(options)
super(method, tag_value, merged_options)
end
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]))
field = super(method, choices, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
build_field(method, field_options, html_options) do |merged_html_options|
super(method, choices, options, merged_html_options)
end
end
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]))
field = super(method, collection, value_method, text_method, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
build_field(method, field_options, html_options) do |merged_html_options|
super(method, collection, value_method, text_method, options, merged_html_options)
end
end
def money_field(amount_method, options = {})
@ -48,22 +44,15 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
}
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")
if object
id = "#{object.id}_#{object_name}_#{method}"
name = "#{object_name}[#{method}]"
checked = object.send(method)
else
id = "#{method}_toggle_id"
name = method
checked = options[:checked]
end
field_id = field_id(method)
field_name = field_name(method)
checked = object ? object.send(method) : options[:checked]
@template.render(
ToggleComponent.new(
id: id,
name: name,
id: field_id,
name: field_name,
checked: checked,
disabled: options[:disabled],
checked_value: checked_value,
@ -74,7 +63,6 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end
def submit(value = nil, options = {})
# Rails superclass logic to extract the submit text
value, options = nil, value if value.is_a?(Hash)
value ||= submit_default_value
@ -88,16 +76,39 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end
private
def build_styled_field(label, field, options, remove_padding_right: false)
if options[:inline]
label + field
else
@template.tag.div class: [ "form-field", options[:container_class], ("pr-0" if remove_padding_right) ] do
label + field
def build_field(method, options = {}, html_options = {}, &block)
if options[:inline] || options[:label] == false
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
@template.tag.div(class: "form-field__body") do
label_element + field_element
end
end
end
end
def normalize_options(options, html_options)
options.merge(required: options[:required] || html_options[:required])
end
def build_label(method, options)
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
label(method, label_text, class: "form-field__label")
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

View file

@ -1,5 +1,6 @@
class Account < ApplicationRecord
include Syncable, Monetizable, Chartable, Linkable, Enrichable
include AASM
validates :name, :balance, :currency, presence: true
@ -18,7 +19,7 @@ class Account < ApplicationRecord
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 :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) }
@ -30,6 +31,30 @@ class Account < ApplicationRecord
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
def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
@ -77,10 +102,20 @@ class Account < ApplicationRecord
end
def destroy_later
update!(scheduled_for_deletion: true, is_active: false)
mark_for_deletion!
DestroyJob.perform_later(self)
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
holdings.where(currency: currency)
.where.not(qty: 0)
@ -92,49 +127,9 @@ class Account < ApplicationRecord
.order(amount: :desc)
end
def update_with_sync!(attributes)
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)
should_update_initial_balance = initial_balance && initial_balance.to_d != accountable.initial_balance
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
def update_balance(balance:, date: Date.current, currency: nil, notes: nil)
Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:).update
end
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
def family_account_names
@family_account_names ||= family.accounts.active.pluck(:name)
@family_account_names ||= family.accounts.visible.pluck(:name)
end
def family_category_names

View file

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

View file

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

View file

@ -134,7 +134,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
def call(params = {})
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
# 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
end
def active_accounts
@active_accounts ||= family.accounts.active.with_attached_logo
def visible_accounts
@visible_accounts ||= family.accounts.visible.with_attached_logo
end
def account_rows
@ -46,7 +46,7 @@ class BalanceSheet::AccountTotals
def query
@query ||= Rails.cache.fetch(cache_key) do
active_accounts
visible_accounts
.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 = ?",
Date.current,

View file

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

View file

@ -17,7 +17,7 @@ class BalanceSheet::SyncStatusMonitor
def syncing_account_ids
Rails.cache.fetch(cache_key) do
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)
.to_set
end

View file

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

View file

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

View file

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

View file

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

View file

@ -100,7 +100,8 @@ class Family < ApplicationRecord
[
id,
key,
data_invalidation_key
data_invalidation_key,
accounts.maximum(:updated_at)
].compact.join("_")
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 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.is_active = true")
.where("outflow_accounts.is_active = true")
.where("inflow_accounts.status IN ('draft', 'active')")
.where("outflow_accounts.status IN ('draft', 'active')")
.where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'")
.where("
(

View file

@ -10,7 +10,7 @@ class IncomeStatement
end
def totals(transactions_scope: nil)
transactions_scope ||= family.transactions.active
transactions_scope ||= family.transactions.visible
result = totals_query(transactions_scope: transactions_scope)
@ -62,7 +62,7 @@ class IncomeStatement
end
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)
uncategorized_category = family.categories.uncategorized

View file

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

View file

@ -1,6 +1,6 @@
class Rule::Registry::TransactionResource < Rule::Registry
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
def condition_filters

View file

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

View file

@ -6,7 +6,7 @@
<%= render "accounts/logo", account: account, size: "md" %>
<div>
<% if account.scheduled_for_deletion? %>
<% if account.pending_deletion? %>
<p class="text-sm font-medium text-primary">
<span>
<%= account.name %>
@ -16,31 +16,45 @@
</span>
</p>
<% 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 %>
<p class="text-sm text-secondary truncate"><%= account.long_subtype_label %></p>
<% end %>
<% end %>
</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 %>
<%= icon("pencil-line", size: "sm") %>
<% end %>
<% end %>
</div>
<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>
<% 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 %>
</p>
<% end %>
<% unless account.scheduled_for_deletion? %>
<%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
<%= f.toggle :is_active, { data: { auto_submit_form_target: "auto" } } %>
<% if account.draft? %>
<%= render LinkComponent.new(
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 %>
</div>

View file

@ -1,5 +1,9 @@
<%# 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| %>
<div class="grow space-y-2">
<%= form.hidden_field :accountable_type %>

View file

@ -12,7 +12,18 @@
<div class="flex items-center gap-2">
<div class="truncate">
<h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title || account.name %></h2>
<div class="flex items-center gap-3">
<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? %>
<p class="text-sm text-secondary"><%= subtitle %></p>
<% end %>

View file

@ -22,7 +22,7 @@
<%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% 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 %>
<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 %>
<% 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 %>
<%= 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| %>
<% dialog.with_header(title: t(".edit", account: @account.name)) %>
<% dialog.with_header(title: "Enter property manually") %>
<% 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 %>

View file

@ -1,6 +1,27 @@
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_header(title: "Enter property manually") %>
<% 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 %>

View file

@ -1,62 +1,85 @@
<%# locals: (form:, amount_method:, currency_method:, **options) %>
<% currency_value = if options[:currency_value_override].present?
options[:currency_value_override]
elsif form.object && form.object.respond_to?(currency_method)
form.object.public_send(currency_method)
end
options[:currency_value_override]
elsif form.object && form.object.respond_to?(currency_method)
form.object.public_send(currency_method)
end
currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %>
<div class="form-field pr-0 <%= options[:container_class] %>" data-controller="money-field">
<%= form.label options[:label] || t(".label"), class: "form-field__label" do %>
<%= options[:label] || t(".label") %>
<% if options[:required] %>
<span class="text-red-500">*</span>
<% end %>
<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 %>
<%= options[:label] || t(".label") %>
<% if options[:required] %>
<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="flex items-center gap-1">
<div class="flex items-center grow gap-1">
<span class="text-subdued text-sm" data-money-field-target="symbol">
<%= currency.symbol %>
</span>
<%= form.number_field amount_method,
class: "form-field__input",
inline: true,
placeholder: "100",
value: if options[:value]
sprintf("%.#{currency.default_precision}f", options[:value])
elsif form.object && form.object.respond_to?(amount_method)
val = form.object.public_send(amount_method)
sprintf("%.#{currency.default_precision}f", val) if val.present?
end,
min: options[:min] || -99999999999999,
max: options[:max] || 99999999999999,
step: currency.step,
disabled: options[:disabled],
data: {
"money-field-target": "amount",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact,
required: options[:required] %>
</div>
<% unless options[:hide_currency] %>
<div>
<%= form.select currency_method,
Money::Currency.as_options.map(&:iso_code),
{ inline: true, selected: currency.iso_code },
{
class: "w-fit pr-5 disabled:text-subdued form-field__input",
disabled: options[:disable_currency],
data: {
"money-field-target": "currency",
action: "change->money-field#handleCurrencyChange",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact
} %>
</div>
<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 %>
<div class="flex items-center gap-1">
<div class="flex items-center grow gap-1">
<span class="text-subdued text-sm" data-money-field-target="symbol">
<%= currency.symbol %>
</span>
<%= form.number_field amount_method,
class: "form-field__input",
inline: true,
placeholder: "100",
value: if options[:value]
sprintf("%.#{currency.default_precision}f", options[:value])
elsif form.object && form.object.respond_to?(amount_method)
val = form.object.public_send(amount_method)
sprintf("%.#{currency.default_precision}f", val) if val.present?
end,
min: options[:min] || -99999999999999,
max: options[:max] || 99999999999999,
step: currency.step,
disabled: options[:disabled],
data: {
"money-field-target": "amount",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact,
required: options[:required] %>
</div>
<% unless options[:hide_currency] %>
<div>
<%= form.select currency_method,
Money::Currency.as_options.map(&:iso_code),
{ inline: true, selected: currency.iso_code },
{
class: "w-fit pr-5 disabled:text-subdued form-field__input",
disabled: options[:disable_currency],
data: {
"money-field-target": "currency",
action: "change->money-field#handleCurrencyChange",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact
} %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -45,7 +45,7 @@
<% end %>
<% 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 %>
<% 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| %>
<%= form.hidden_field :account_id %>
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% if error_message.present? %>
<%= render AlertComponent.new(message: error_message, variant: :error) %>
<% end %>
<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 %>
<span class="text-secondary text-sm">
<%= t(".balance") %>
<%= entry.name %>
</span>
<div class="flex items-center gap-4">

View file

@ -25,15 +25,7 @@
</div>
</div>
<div class="col-span-2 justify-self-end font-medium text-sm">
<% 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">
<div class="col-span-4 justify-self-end">
<%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-primary" %>
</div>
</div>

View file

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

View file

@ -6,17 +6,18 @@
<% end %>
<% 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 %>
<div class="pb-4">
<%= styled_form_with model: entry,
url: entry_path(entry),
class: "space-y-2",
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,
label: t(".date_label"),
max: Date.current,