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)
* 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:
parent
ba7e8d3893
commit
662f2c04ce
66 changed files with 1036 additions and 427 deletions
|
@ -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);
|
||||
|
@ -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 {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
@ -425,7 +442,5 @@
|
|||
@variant theme-dark {
|
||||
fill: var(--color-white);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
7
app/components/alert_component.html.erb
Normal file
7
app/components/alert_component.html.erb
Normal 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>
|
52
app/components/alert_component.rb
Normal file
52
app/components/alert_component.rb
Normal 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
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,15 +76,38 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
|||
end
|
||||
|
||||
private
|
||||
def build_styled_field(label, field, options, remove_padding_right: false)
|
||||
if options[:inline]
|
||||
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", options[:container_class], ("pr-0" if remove_padding_right) ] do
|
||||
label + field
|
||||
@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
|
||||
|
|
|
@ -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
|
||||
|
|
47
app/models/account/balance_updater.rb
Normal file
47
app/models/account/balance_updater.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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, -> {
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -100,7 +100,8 @@ class Family < ApplicationRecord
|
|||
[
|
||||
id,
|
||||
key,
|
||||
data_invalidation_key
|
||||
data_invalidation_key,
|
||||
accounts.maximum(:updated_at)
|
||||
].compact.join("_")
|
||||
end
|
||||
|
||||
|
|
|
@ -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("
|
||||
(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -12,7 +12,18 @@
|
|||
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<% 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 %>
|
||||
|
|
|
@ -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,
|
||||
|
|
7
app/views/properties/_form_alert.html.erb
Normal file
7
app/views/properties/_form_alert.html.erb
Normal 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 %>
|
16
app/views/properties/_form_tab.html.erb
Normal file
16
app/views/properties/_form_tab.html.erb
Normal 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 %>
|
7
app/views/properties/_form_tabs.html.erb
Normal file
7
app/views/properties/_form_tabs.html.erb
Normal 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>
|
35
app/views/properties/_overview_fields.html.erb
Normal file
35
app/views/properties/_overview_fields.html.erb
Normal 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>
|
50
app/views/properties/address.html.erb
Normal file
50
app/views/properties/address.html.erb
Normal file
|
@ -0,0 +1,50 @@
|
|||
<%= render DialogComponent.new do |dialog| %>
|
||||
<% dialog.with_header(title: "Enter property manually") %>
|
||||
<% dialog.with_body do %>
|
||||
<div class="flex gap-4">
|
||||
<!-- Left sidebar with tabs -->
|
||||
<%= render "properties/form_tabs", account: @account, active_tab: "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 %>
|
30
app/views/properties/balances.html.erb
Normal file
30
app/views/properties/balances.html.erb
Normal 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 %>
|
|
@ -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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -7,11 +7,33 @@
|
|||
end
|
||||
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 %>
|
||||
<%= options[:label] || t(".label") %>
|
||||
<% 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 %>
|
||||
|
||||
|
@ -60,3 +82,4 @@
|
|||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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) %>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,5 +1,28 @@
|
|||
{
|
||||
"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_code": 105,
|
||||
|
@ -26,13 +49,13 @@
|
|||
{
|
||||
"warning_type": "Dangerous Eval",
|
||||
"warning_code": 13,
|
||||
"fingerprint": "c193307bb82f931950d3bf2855f82f9a7f50d94c5bd950ee2803cb8a8abe5253",
|
||||
"fingerprint": "c154514a0f86341473e4abf35e77721495b326c7855e4967d284b4942371819c",
|
||||
"check_name": "Evaluation",
|
||||
"message": "Dynamic string evaluated as code",
|
||||
"file": "app/helpers/styled_form_builder.rb",
|
||||
"line": 7,
|
||||
"line": 5,
|
||||
"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,
|
||||
"location": {
|
||||
"type": "method",
|
||||
|
@ -45,7 +68,7 @@
|
|||
913,
|
||||
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",
|
||||
|
@ -80,29 +103,6 @@
|
|||
22
|
||||
],
|
||||
"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"
|
||||
|
|
|
@ -108,14 +108,6 @@ Rails.application.routes.draw do
|
|||
resources :mappings, only: :update, module: :import
|
||||
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 :trades, 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
|
||||
|
||||
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
|
||||
# Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123
|
||||
direct :account do |model, options|
|
||||
route_for model.accountable_name, model, options
|
||||
end
|
||||
|
||||
direct :edit_account do |model, options|
|
||||
route_for "edit_#{model.accountable_name}", model, options
|
||||
end
|
||||
|
||||
resources :depositories, 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 :credit_cards, except: :index
|
||||
resources :loans, except: :index
|
||||
|
|
41
db/migrate/20250701161640_add_account_status.rb
Normal file
41
db/migrate/20250701161640_add_account_status.rb
Normal 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
16
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_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
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -29,13 +29,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_23_162207) do
|
|||
t.uuid "accountable_id"
|
||||
t.decimal "balance", precision: 19, scale: 4
|
||||
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)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.uuid "import_id"
|
||||
t.uuid "plaid_account_id"
|
||||
t.boolean "scheduled_for_deletion", default: false
|
||||
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
|
||||
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_type"], name: "index_accounts_on_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.string "entryable_type"
|
||||
t.uuid "entryable_id"
|
||||
t.decimal "amount", precision: 19, scale: 4
|
||||
t.decimal "amount", precision: 19, scale: 4, null: false
|
||||
t.string "currency"
|
||||
t.date "date"
|
||||
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.string "plaid_id"
|
||||
t.jsonb "locked_attributes", default: {}
|
||||
t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date"
|
||||
t.index ["account_id"], name: "index_entries_on_account_id"
|
||||
t.index ["amount"], name: "index_entries_on_amount"
|
||||
t.index ["date"], name: "index_entries_on_date"
|
||||
t.index ["entryable_id", "entryable_type"], name: "index_entries_on_entryable"
|
||||
t.index ["excluded"], name: "index_entries_on_excluded"
|
||||
t.index ["import_id"], name: "index_entries_on_import_id"
|
||||
end
|
||||
|
||||
|
@ -232,7 +226,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_23_162207) do
|
|||
t.date "date", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["date", "from_currency", "to_currency"], name: "index_exchange_rates_on_date_and_currencies"
|
||||
t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true
|
||||
t.index ["from_currency"], name: "index_exchange_rates_on_from_currency"
|
||||
t.index ["to_currency"], name: "index_exchange_rates_on_to_currency"
|
||||
|
@ -691,7 +684,6 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_23_162207) do
|
|||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["tag_id"], name: "index_taggings_on_tag_id"
|
||||
t.index ["taggable_id", "taggable_type"], name: "index_taggings_on_taggable_id_and_type"
|
||||
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable"
|
||||
end
|
||||
|
||||
|
|
7
test/components/previews/alert_component_preview.rb
Normal file
7
test/components/previews/alert_component_preview.rb
Normal 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
|
|
@ -81,7 +81,7 @@ end
|
|||
test "should only return active accounts" do
|
||||
# Make one account inactive
|
||||
inactive_account = accounts(:depository)
|
||||
inactive_account.update!(is_active: false)
|
||||
inactive_account.disable!
|
||||
|
||||
access_token = Doorkeeper::AccessToken.create!(
|
||||
application: @oauth_app,
|
||||
|
|
|
@ -8,72 +8,169 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
|
|||
@account = accounts(:property)
|
||||
end
|
||||
|
||||
test "creates with property details" do
|
||||
assert_difference -> { Account.count } => 1,
|
||||
-> { Property.count } => 1,
|
||||
-> { Valuation.count } => 2,
|
||||
-> { Entry.count } => 2 do
|
||||
test "creates property in draft status and redirects to balances step" do
|
||||
assert_difference -> { Account.count } => 1 do
|
||||
post properties_path, params: {
|
||||
account: {
|
||||
name: "Property",
|
||||
balance: 500000,
|
||||
currency: "USD",
|
||||
name: "New Property",
|
||||
subtype: "house",
|
||||
accountable_type: "Property",
|
||||
accountable_attributes: {
|
||||
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"
|
||||
}
|
||||
year_built: 1990,
|
||||
area_value: 1200,
|
||||
area_unit: "sqft"
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
created_account = Account.order(:created_at).last
|
||||
|
||||
assert created_account.accountable.year_built.present?
|
||||
assert created_account.accountable.address.line1.present?
|
||||
|
||||
assert_redirected_to created_account
|
||||
assert_equal "Property account created", flash[:notice]
|
||||
assert_enqueued_with(job: SyncJob)
|
||||
assert created_account.accountable.is_a?(Property)
|
||||
assert_equal "draft", created_account.status
|
||||
assert_equal 0, created_account.balance
|
||||
assert_equal 1990, created_account.accountable.year_built
|
||||
assert_equal 1200, created_account.accountable.area_value
|
||||
assert_equal "sqft", created_account.accountable.area_unit
|
||||
assert_redirected_to balances_property_path(created_account)
|
||||
end
|
||||
|
||||
test "updates with property details" do
|
||||
test "updates property overview" do
|
||||
assert_no_difference [ "Account.count", "Property.count" ] do
|
||||
patch account_path(@account), params: {
|
||||
patch property_path(@account), params: {
|
||||
account: {
|
||||
name: "Updated Property",
|
||||
balance: 500000,
|
||||
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"
|
||||
}
|
||||
}
|
||||
subtype: "condo"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to @account
|
||||
assert_equal "Property account updated", flash[:notice]
|
||||
assert_enqueued_with(job: SyncJob)
|
||||
@account.reload
|
||||
assert_equal "Updated Property", @account.name
|
||||
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
|
||||
|
|
|
@ -8,35 +8,24 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
|
|||
@entry = entries(:valuation)
|
||||
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
|
||||
account = accounts(:investment)
|
||||
|
||||
assert_difference [ "Entry.count", "Valuation.count" ], 1 do
|
||||
post valuations_url, params: {
|
||||
entry: {
|
||||
name: "New entry",
|
||||
amount: 10000,
|
||||
amount: account.balance + 100,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
account_id: @entry.account_id
|
||||
date: Date.current.to_s,
|
||||
account_id: account.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
|
@ -47,7 +36,6 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_no_difference [ "Entry.count", "Valuation.count" ] do
|
||||
patch valuation_url(@entry), params: {
|
||||
entry: {
|
||||
name: "Updated entry",
|
||||
amount: 20000,
|
||||
currency: "USD",
|
||||
date: Date.current
|
||||
|
|
10
test/fixtures/accounts.yml
vendored
10
test/fixtures/accounts.yml
vendored
|
@ -5,6 +5,7 @@ other_asset:
|
|||
currency: USD
|
||||
accountable_type: OtherAsset
|
||||
accountable: one
|
||||
status: active
|
||||
|
||||
other_liability:
|
||||
family: dylan_family
|
||||
|
@ -13,6 +14,7 @@ other_liability:
|
|||
currency: USD
|
||||
accountable_type: OtherLiability
|
||||
accountable: one
|
||||
status: active
|
||||
|
||||
depository:
|
||||
family: dylan_family
|
||||
|
@ -21,6 +23,7 @@ depository:
|
|||
currency: USD
|
||||
accountable_type: Depository
|
||||
accountable: one
|
||||
status: active
|
||||
|
||||
connected:
|
||||
family: dylan_family
|
||||
|
@ -31,6 +34,7 @@ connected:
|
|||
accountable_type: Depository
|
||||
accountable: two
|
||||
plaid_account: one
|
||||
status: active
|
||||
|
||||
credit_card:
|
||||
family: dylan_family
|
||||
|
@ -39,6 +43,7 @@ credit_card:
|
|||
currency: USD
|
||||
accountable_type: CreditCard
|
||||
accountable: one
|
||||
status: active
|
||||
|
||||
investment:
|
||||
family: dylan_family
|
||||
|
@ -48,6 +53,7 @@ investment:
|
|||
currency: USD
|
||||
accountable_type: Investment
|
||||
accountable: one
|
||||
status: active
|
||||
|
||||
loan:
|
||||
family: dylan_family
|
||||
|
@ -56,6 +62,7 @@ loan:
|
|||
currency: USD
|
||||
accountable_type: Loan
|
||||
accountable: one
|
||||
status: active
|
||||
|
||||
property:
|
||||
family: dylan_family
|
||||
|
@ -64,6 +71,7 @@ property:
|
|||
currency: USD
|
||||
accountable_type: Property
|
||||
accountable: one
|
||||
status: active
|
||||
|
||||
vehicle:
|
||||
family: dylan_family
|
||||
|
@ -72,6 +80,7 @@ vehicle:
|
|||
currency: USD
|
||||
accountable_type: Vehicle
|
||||
accountable: one
|
||||
status: active
|
||||
|
||||
crypto:
|
||||
family: dylan_family
|
||||
|
@ -80,3 +89,4 @@ crypto:
|
|||
currency: USD
|
||||
accountable_type: Crypto
|
||||
accountable: one
|
||||
status: active
|
||||
|
|
|
@ -26,66 +26,4 @@ module AccountableResourceInterfaceTest
|
|||
assert_enqueued_with job: DestroyJob
|
||||
assert_equal "#{@account.accountable_name.underscore.humanize} account scheduled for deletion", flash[:notice]
|
||||
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
|
||||
|
|
|
@ -67,21 +67,21 @@ class EntryTest < ActiveSupport::TestCase
|
|||
assert_equal 0, family.entries.search(params).size
|
||||
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
|
||||
active_transaction = create_transaction(account: accounts(:depository), name: "Active transaction")
|
||||
inactive_transaction = create_transaction(account: accounts(:credit_card), name: "Inactive transaction")
|
||||
visible_transaction = create_transaction(account: accounts(:depository), name: "Visible transaction")
|
||||
invisible_transaction = create_transaction(account: accounts(:credit_card), name: "Invisible transaction")
|
||||
|
||||
# Update account statuses
|
||||
accounts(:credit_card).update!(is_active: false)
|
||||
accounts(:credit_card).disable!
|
||||
|
||||
# Test the scope
|
||||
active_entries = Entry.active
|
||||
visible_entries = Entry.visible
|
||||
|
||||
# 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
|
||||
assert_not_includes active_entries, inactive_transaction
|
||||
# Should not include entry from disabled account
|
||||
assert_not_includes visible_entries, invisible_transaction
|
||||
end
|
||||
end
|
||||
|
|
|
@ -39,7 +39,7 @@ class BalanceSheetTest < ActiveSupport::TestCase
|
|||
create_account(balance: 10000, accountable: Depository.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, BalanceSheet.new(@family).assets.total
|
||||
|
|
|
@ -89,7 +89,7 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
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)
|
||||
inflow = create_transaction(date: Date.current, account: @credit_card, amount: -500)
|
||||
|
|
|
@ -23,15 +23,40 @@ class AccountsTest < ApplicationSystemTestCase
|
|||
end
|
||||
|
||||
test "can create property account" do
|
||||
assert_account_created "Property" do
|
||||
fill_in "Year built", with: 2005
|
||||
fill_in "Living area", with: 2250
|
||||
fill_in "Street address", with: "123 Main St"
|
||||
# Step 1: Select property type and enter basic details
|
||||
click_link "Property"
|
||||
|
||||
account_name = "[system test] Property Account"
|
||||
fill_in "Name*", with: account_name
|
||||
select "Single Family Home", from: "Property type*"
|
||||
fill_in "Year Built (optional)", with: 2005
|
||||
fill_in "Area (optional)", with: 2250
|
||||
|
||||
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 "State/Province", with: "CA"
|
||||
fill_in "ZIP/Postal code", with: "94101"
|
||||
fill_in "State/Region", with: "CA"
|
||||
fill_in "Postal Code", with: "94101"
|
||||
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
|
||||
|
||||
test "can create vehicle account" do
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue