diff --git a/Gemfile.lock b/Gemfile.lock index 89cac3d3..cacdf57a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -122,7 +122,7 @@ GEM bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.0.2) + brakeman (7.1.0) racc builder (3.3.0) capybara (3.40.0) diff --git a/app/components/DS/tooltip.html.erb b/app/components/DS/tooltip.html.erb new file mode 100644 index 00000000..3bd7a1ba --- /dev/null +++ b/app/components/DS/tooltip.html.erb @@ -0,0 +1,9 @@ + + <%= helpers.icon icon_name, size: size, color: color %> + + + diff --git a/app/components/DS/tooltip.rb b/app/components/DS/tooltip.rb new file mode 100644 index 00000000..7189c4f3 --- /dev/null +++ b/app/components/DS/tooltip.rb @@ -0,0 +1,17 @@ +class DS::Tooltip < ApplicationComponent + attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color + + def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default") + @text = text + @placement = placement + @offset = offset + @cross_axis = cross_axis + @icon_name = icon + @size = size + @color = color + end + + def tooltip_content + content? ? content : @text + end +end diff --git a/app/components/DS/tooltip_controller.js b/app/components/DS/tooltip_controller.js new file mode 100644 index 00000000..8f198bae --- /dev/null +++ b/app/components/DS/tooltip_controller.js @@ -0,0 +1,87 @@ +import { + autoUpdate, + computePosition, + flip, + offset, + shift, +} from "@floating-ui/dom"; +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["tooltip"]; + static values = { + placement: { type: String, default: "top" }, + offset: { type: Number, default: 10 }, + crossAxis: { type: Number, default: 0 }, + }; + + connect() { + this._cleanup = null; + this.boundUpdate = this.update.bind(this); + this.addEventListeners(); + } + + disconnect() { + this.removeEventListeners(); + this.stopAutoUpdate(); + } + + addEventListeners() { + this.element.addEventListener("mouseenter", this.show); + this.element.addEventListener("mouseleave", this.hide); + } + + removeEventListeners() { + this.element.removeEventListener("mouseenter", this.show); + this.element.removeEventListener("mouseleave", this.hide); + } + + show = () => { + this.tooltipTarget.classList.remove("hidden"); + this.startAutoUpdate(); + this.update(); + }; + + hide = () => { + this.tooltipTarget.classList.add("hidden"); + this.stopAutoUpdate(); + }; + + startAutoUpdate() { + if (!this._cleanup) { + const reference = this.element.querySelector("[data-icon]"); + this._cleanup = autoUpdate( + reference || this.element, + this.tooltipTarget, + this.boundUpdate + ); + } + } + + stopAutoUpdate() { + if (this._cleanup) { + this._cleanup(); + this._cleanup = null; + } + } + + update() { + const reference = this.element.querySelector("[data-icon]"); + computePosition(reference || this.element, this.tooltipTarget, { + placement: this.placementValue, + middleware: [ + offset({ + mainAxis: this.offsetValue, + crossAxis: this.crossAxisValue, + }), + flip(), + shift({ padding: 5 }), + ], + }).then(({ x, y }) => { + Object.assign(this.tooltipTarget.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + } +} \ No newline at end of file diff --git a/app/components/UI/account/activity_date.html.erb b/app/components/UI/account/activity_date.html.erb new file mode 100644 index 00000000..0d7f5057 --- /dev/null +++ b/app/components/UI/account/activity_date.html.erb @@ -0,0 +1,103 @@ +<%= tag.div id: id, data: { bulk_select_target: "group" }, class: "bg-container-inset rounded-xl p-1 w-full" do %> +
+ +
+
+ <%= check_box_tag "#{date}_entries_selection", + class: ["checkbox checkbox--light", "hidden": entries.size == 0], + id: "selection_entry_#{date}", + data: { action: "bulk-select#toggleGroupSelection" } %> + +

+ <%= tag.span I18n.l(date, format: :long) %> + · + <%= tag.span entries.size %> +

+
+ +
+
+ <%= balance_trend.current.format %> + <%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %> +
+ <%= helpers.icon "chevron-down", class: "group-open:rotate-180" %> +
+
+
+ +
+
+
+ Start of day balance + <%= render DS::Tooltip.new(text: "The account balance at the beginning of this day, before any transactions or value changes", placement: "left", size: "sm") %> +
+
+
<%= start_balance_money.format %>
+
+ + <% if account.balance_type == :investment %> +
+
+ Δ Cash + <%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %> +
+
+
<%= cash_change_money.format %>
+
+ +
+
+ Δ Holdings + <%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %> +
+
+
<%= holdings_change_money.format %>
+
+ <% else %> +
+
+ Δ Cash + <%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %> +
+
+
<%= cash_change_money.format %>
+
+ <% end %> + +
+
+ End of day balance + <%= render DS::Tooltip.new(text: "The calculated balance after all transactions but before any manual adjustments or reconciliations", placement: "left", size: "sm") %> +
+
+
<%= end_balance_before_adjustments_money.format %>
+
+ +
+ +
+
+ Δ Value adjustments + <%= render DS::Tooltip.new(text: "Adjustments are either manual reconciliations made by the user or adjustments due to market price changes throughout the day", placement: "left", size: "sm") %> +
+
+
<%= adjustments_money.format %>
+
+ +
+
+ Closing balance + <%= render DS::Tooltip.new(text: "The final account balance for the day, after all transactions and adjustments have been applied", placement: "left", size: "sm") %> +
+
+
<%= end_balance_money.format %>
+
+
+
+ +
+ <% entries.each do |entry| %> + <%= render entry, view_ctx: "account" %> + <% end %> +
+<% end %> diff --git a/app/components/UI/account/activity_date.rb b/app/components/UI/account/activity_date.rb new file mode 100644 index 00000000..17fa2255 --- /dev/null +++ b/app/components/UI/account/activity_date.rb @@ -0,0 +1,51 @@ +class UI::Account::ActivityDate < ApplicationComponent + attr_reader :account, :data + + delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data + + def initialize(account:, data:) + @account = account + @data = data + end + + def id + dom_id(account, "entries_#{date}") + end + + def broadcast_channel + account + end + + def start_balance_money + balance_trend.previous + end + + def cash_change_money + cash_balance_trend.value + end + + def holdings_change_money + holdings_value_trend.value + end + + def end_balance_before_adjustments_money + balance_trend.previous + cash_change_money + holdings_change_money + end + + def adjustments_money + end_balance_money - end_balance_before_adjustments_money + end + + def end_balance_money + balance_trend.current + end + + def broadcast_refresh! + Turbo::StreamsChannel.broadcast_replace_to( + broadcast_channel, + target: id, + renderable: self, + layout: false + ) + end +end diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb new file mode 100644 index 00000000..c6740a0a --- /dev/null +++ b/app/components/UI/account/activity_feed.html.erb @@ -0,0 +1,94 @@ +<%= turbo_frame_tag dom_id(account, "entries") do %> +
+
+ <%= tag.h2 t(".title"), class: "font-medium text-lg" %> + + <% if account.manual? %> + <%= render DS::Menu.new(variant: "button") do |menu| %> + <% menu.with_button(text: "New", variant: "secondary", icon: "plus") %> + + <% menu.with_item( + variant: "link", + text: "New balance", + icon: "circle-dollar-sign", + href: new_valuation_path(account_id: account.id), + data: { turbo_frame: :modal }) %> + + <% unless account.crypto? %> + <% menu.with_item( + variant: "link", + text: "New transaction", + icon: "credit-card", + href: account.investment? ? new_trade_path(account_id: account.id) : new_transaction_path(account_id: account.id), + data: { turbo_frame: :modal }) %> + <% end %> + <% end %> + <% end %> +
+ +
+ <%= form_with url: account_path(account), + id: "entries-search", + scope: :q, + method: :get, + data: { controller: "auto-submit-form" } do |form| %> +
+
+
+ <%= helpers.icon("search") %> + + <%= hidden_field_tag :account_id, account.id %> + + <%= form.search_field :search, + placeholder: "Search entries by name", + value: search, + class: "form-field__input placeholder:text-sm placeholder:text-secondary", + "data-auto-submit-form-target": "auto" %> +
+
+
+ <% end %> +
+ + <% if activity_dates.empty? %> +

No entries yet

+ <% else %> + <%= tag.div id: dom_id(account, "entries_bulk_select"), + data: { + controller: "bulk-select", + bulk_select_singular_label_value: "entry", + bulk_select_plural_label_value: "entries" + } do %> + + +
+
+ <%= check_box_tag "selection_entry", + class: "checkbox checkbox--light", + data: { action: "bulk-select#togglePageSelection" } %> +

Date

+
+ + <%= tag.p "Amount", class: "col-span-4 justify-self-end" %> +
+ +
+
+ <% activity_dates.each do |activity_date_data| %> + <%= render UI::Account::ActivityDate.new( + account: account, + data: activity_date_data + ) %> + <% end %> +
+ +
+ <%= render "shared/pagination", pagy: pagy %> +
+
+ <% end %> + <% end %> +
+<% end %> diff --git a/app/components/UI/account/activity_feed.rb b/app/components/UI/account/activity_feed.rb new file mode 100644 index 00000000..bb7fa3a1 --- /dev/null +++ b/app/components/UI/account/activity_feed.rb @@ -0,0 +1,35 @@ +class UI::Account::ActivityFeed < ApplicationComponent + attr_reader :feed_data, :pagy, :search + + def initialize(feed_data:, pagy:, search: nil) + @feed_data = feed_data + @pagy = pagy + @search = search + end + + def id + dom_id(account, :activity_feed) + end + + def broadcast_channel + account + end + + def broadcast_refresh! + Turbo::StreamsChannel.broadcast_replace_to( + broadcast_channel, + target: id, + renderable: self, + layout: false + ) + end + + def activity_dates + feed_data.entries_by_date + end + + private + def account + feed_data.account + end +end diff --git a/app/components/UI/account_page.html.erb b/app/components/UI/account_page.html.erb index 9b7c01ab..37d3bd41 100644 --- a/app/components/UI/account_page.html.erb +++ b/app/components/UI/account_page.html.erb @@ -1,6 +1,6 @@ <%= turbo_stream_from account %> -<%= turbo_frame_tag dom_id(account, :container) do %> +<%= turbo_frame_tag id do %> <%= tag.div class: "space-y-4 pb-32" do %> <%= render "accounts/show/header", account: account, title: title, subtitle: subtitle %> @@ -17,12 +17,12 @@ <% tabs.each do |tab| %> <% tabs_container.with_panel(tab_id: tab) do %> - <%= render tab_partial_name(tab), account: account %> + <%= tab_content_for(tab) %> <% end %> <% end %> <% end %> <% else %> - <%= render tab_partial_name(tabs.first), account: account %> + <%= tab_content_for(tabs.first) %> <% end %> <% end %> diff --git a/app/components/UI/account_page.rb b/app/components/UI/account_page.rb index 159ed56f..5d3dd54a 100644 --- a/app/components/UI/account_page.rb +++ b/app/components/UI/account_page.rb @@ -1,6 +1,8 @@ class UI::AccountPage < ApplicationComponent attr_reader :account, :chart_view, :chart_period + renders_one :activity_feed, ->(feed_data:, pagy:, search:) { UI::Account::ActivityFeed.new(feed_data: feed_data, pagy: pagy, search: search) } + def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil) @account = account @chart_view = chart_view @@ -8,6 +10,18 @@ class UI::AccountPage < ApplicationComponent @active_tab = active_tab end + def id + dom_id(account, :container) + end + + def broadcast_channel + account + end + + def broadcast_refresh! + Turbo::StreamsChannel.broadcast_replace_to(broadcast_channel, target: id, renderable: self, layout: false) + end + def title account.name end @@ -33,13 +47,13 @@ class UI::AccountPage < ApplicationComponent end end - def tab_partial_name(tab) + def tab_content_for(tab) case tab when :activity - "accounts/show/activity" + activity_feed when :holdings, :overview # Accountable is responsible for implementing the partial in the correct folder - "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}" + render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account end end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 7bf4470c..0b2252d7 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -16,6 +16,8 @@ class AccountsController < ApplicationController entries = @account.entries.search(@q).reverse_chronological @pagy, @entries = pagy(entries, limit: params[:per_page] || "10") + + @activity_feed_data = Account::ActivityFeedData.new(@account, @entries) end def sync diff --git a/app/models/account/activity_feed_data.rb b/app/models/account/activity_feed_data.rb new file mode 100644 index 00000000..28f92a64 --- /dev/null +++ b/app/models/account/activity_feed_data.rb @@ -0,0 +1,219 @@ +# Data used to build the paginated feed of account "activity" (events like transfers, deposits, withdrawals, etc.) +# This data object is useful for avoiding N+1 queries and having an easy way to pass around the required data to the +# activity feed component in controllers and background jobs that refresh it. +class Account::ActivityFeedData + ActivityDateData = Data.define(:date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers) + + attr_reader :account, :entries + + def initialize(account, entries) + @account = account + @entries = entries.to_a + end + + def entries_by_date + @entries_by_date_objects ||= begin + grouped_entries.map do |date, date_entries| + ActivityDateData.new( + date: date, + entries: date_entries, + balance_trend: balance_trend_for_date(date), + cash_balance_trend: cash_balance_trend_for_date(date), + holdings_value_trend: holdings_value_trend_for_date(date), + transfers: transfers_for_date(date) + ) + end + end + end + + private + def balance_trend_for_date(date) + build_trend_for_date(date, :balance_money) + end + + def cash_balance_trend_for_date(date) + date_entries = grouped_entries[date] || [] + has_valuation = date_entries.any?(&:valuation?) + + if has_valuation + # When there's a valuation, calculate cash change from transaction entries only + transactions = date_entries.select { |e| e.transaction? } + cash_change = sum_entries_with_exchange_rates(transactions, date) * -1 + + start_balance = start_balance_for_date(date) + Trend.new( + current: start_balance.cash_balance_money + cash_change, + previous: start_balance.cash_balance_money + ) + else + build_trend_for_date(date, :cash_balance_money) + end + end + + def holdings_value_trend_for_date(date) + date_entries = grouped_entries[date] || [] + has_valuation = date_entries.any?(&:valuation?) + + if has_valuation + # When there's a valuation, calculate holdings change from trade entries only + trades = date_entries.select { |e| e.trade? } + holdings_change = sum_entries_with_exchange_rates(trades, date) + + start_balance = start_balance_for_date(date) + start_holdings = start_balance.balance_money - start_balance.cash_balance_money + Trend.new( + current: start_holdings + holdings_change, + previous: start_holdings + ) + else + build_trend_for_date(date) do |balance| + balance.balance_money - balance.cash_balance_money + end + end + end + + def transfers_for_date(date) + date_entries = grouped_entries[date] || [] + return [] if date_entries.empty? + + date_transaction_ids = date_entries.select(&:transaction?).map(&:entryable_id) + return [] if date_transaction_ids.empty? + + # Convert to Set for O(1) lookups + date_transaction_id_set = Set.new(date_transaction_ids) + + transfers.select { |txfr| + date_transaction_id_set.include?(txfr.inflow_transaction_id) || + date_transaction_id_set.include?(txfr.outflow_transaction_id) + } + end + + def build_trend_for_date(date, method = nil) + start_balance = start_balance_for_date(date) + end_balance = end_balance_for_date(date) + + if block_given? + Trend.new( + current: yield(end_balance), + previous: yield(start_balance) + ) + else + Trend.new( + current: end_balance.send(method), + previous: start_balance.send(method) + ) + end + end + + # Finds the balance on date, or the most recent balance before it ("last observation carried forward") + def start_balance_for_date(date) + @start_balance_for_date ||= {} + @start_balance_for_date[date] ||= last_observed_balance_before_date(date.prev_day) || generate_fallback_balance(date) + end + + # Finds the balance on date, or the most recent balance before it ("last observation carried forward") + def end_balance_for_date(date) + @end_balance_for_date ||= {} + @end_balance_for_date[date] ||= last_observed_balance_before_date(date) || generate_fallback_balance(date) + end + + RequiredExchangeRate = Data.define(:date, :from, :to) + + def grouped_entries + @grouped_entries ||= entries.group_by(&:date) + end + + def needs_exchange_rates? + entries.any? { |entry| entry.currency != account.currency } + end + + def required_exchange_rates + multi_currency_entries = entries.select { |entry| entry.currency != account.currency } + + multi_currency_entries.map do |entry| + RequiredExchangeRate.new(date: entry.date, from: entry.currency, to: account.currency) + end.uniq + end + + # If the account has entries denominated in a different currency than the main account, we attach necessary + # exchange rates required to "roll up" the entry group balance into the normal account currency. + def exchange_rates + return [] unless needs_exchange_rates? + + @exchange_rates ||= begin + rate_requirements = required_exchange_rates + return [] if rate_requirements.empty? + + # Use ActiveRecord's or chain for better performance + conditions = rate_requirements.map do |req| + ExchangeRate.where(date: req.date, from_currency: req.from, to_currency: req.to) + end.reduce(:or) + + conditions.to_a + end + end + + def exchange_rate_for(date, from_currency, to_currency) + return 1.0 if from_currency == to_currency + + rate = exchange_rates.find { |r| r.date == date && r.from_currency == from_currency && r.to_currency == to_currency } + rate&.rate || 1.0 # Fallback to 1:1 if no rate found + end + + def sum_entries_with_exchange_rates(entries, date) + return Money.new(0, account.currency) if entries.empty? + + entries.sum do |entry| + amount = entry.amount_money + if entry.currency != account.currency + rate = exchange_rate_for(date, entry.currency, account.currency) + Money.new(amount.amount * rate, account.currency) + else + amount + end + end + end + + # We read balances so we can show "start of day" -> "end of day" balances for each entry date group in the feed + def balances + @balances ||= begin + return [] if entries.empty? + + min_date = entries.min_by(&:date).date.prev_day + max_date = entries.max_by(&:date).date + + account.balances.where(date: min_date..max_date, currency: account.currency).order(:date).to_a + end + end + + def transaction_ids + entries.select { |entry| entry.transaction? }.map(&:entryable_id) + end + + def transfers + return [] if entries.select { |e| e.transaction? && e.transaction.transfer? }.empty? + return [] if transaction_ids.empty? + + @transfers ||= Transfer.where(inflow_transaction_id: transaction_ids).or(Transfer.where(outflow_transaction_id: transaction_ids)).to_a + end + + # Use binary search since balances are sorted by date + def last_observed_balance_before_date(date) + idx = balances.bsearch_index { |b| b.date > date } + + if idx + idx > 0 ? balances[idx - 1] : nil + else + balances.last + end + end + + def generate_fallback_balance(date) + Balance.new( + account: account, + date: date, + balance: 0, + currency: account.currency + ) + end +end diff --git a/app/models/account/reconcileable.rb b/app/models/account/reconcileable.rb index b8805236..67a6b52c 100644 --- a/app/models/account/reconcileable.rb +++ b/app/models/account/reconcileable.rb @@ -3,13 +3,13 @@ module Account::Reconcileable def create_reconciliation(balance:, date:, dry_run: false) result = reconciliation_manager.reconcile_balance(balance: balance, date: date, dry_run: dry_run) - sync_later if result.success? + sync_later if result.success? && !dry_run result end def update_reconciliation(existing_valuation_entry, balance:, date:, dry_run: false) result = reconciliation_manager.reconcile_balance(balance: balance, date: date, existing_valuation_entry: existing_valuation_entry, dry_run: dry_run) - sync_later if result.success? + sync_later if result.success? && !dry_run result end diff --git a/app/models/balance.rb b/app/models/balance.rb index 90c4df41..ff28db90 100644 --- a/app/models/balance.rb +++ b/app/models/balance.rb @@ -3,7 +3,7 @@ class Balance < ApplicationRecord belongs_to :account validates :account, :date, :balance, presence: true - monetize :balance + monetize :balance, :cash_balance scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) } scope :chronological, -> { order(:date) } end diff --git a/app/models/balance/trend_calculator.rb b/app/models/balance/trend_calculator.rb deleted file mode 100644 index 990a8339..00000000 --- a/app/models/balance/trend_calculator.rb +++ /dev/null @@ -1,30 +0,0 @@ -# The current system calculates a single, end-of-day balance every day for each account for simplicity. -# In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances -# to show users how each entry affects their balances. This class calculates intraday balances by -# interpolating between end-of-day balances. -class Balance::TrendCalculator - BalanceTrend = Struct.new(:trend, :cash, keyword_init: true) - - def initialize(balances) - @balances = balances - end - - def trend_for(date) - balance = @balances.find { |b| b.date == date } - prior_balance = @balances.find { |b| b.date == date - 1.day } - - return BalanceTrend.new(trend: nil) unless balance.present? - - BalanceTrend.new( - trend: Trend.new( - current: Money.new(balance.balance, balance.currency), - previous: prior_balance.present? ? Money.new(prior_balance.balance, balance.currency) : nil, - favorable_direction: balance.account.favorable_direction - ), - cash: Money.new(balance.cash_balance, balance.currency), - ) - end - - private - attr_reader :balances -end diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index a24e8b0b..170f1298 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -3,4 +3,6 @@ chart_view: @chart_view, chart_period: @period, active_tab: @tab - ) %> + ) do |account_page| %> + <%= account_page.with_activity_feed(feed_data: @activity_feed_data, pagy: @pagy, search: @q[:search]) %> +<% end %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 63e77110..da5e5ecb 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -76,11 +76,9 @@
- <% calculator = Balance::TrendCalculator.new(@account.balances) %> - <%= entries_by_date(@entries) do |entries| %> <% entries.each_with_index do |entry, index| %> - <%= render entry, balance_trend: index == 0 ? calculator.trend_for(entry.date) : nil, view_ctx: "account" %> + <%= render entry, view_ctx: "account" %> <% end %> <% end %>
diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb index ddaced9a..02245edf 100644 --- a/app/views/trades/_trade.html.erb +++ b/app/views/trades/_trade.html.erb @@ -5,7 +5,7 @@ <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(trade) do %>
text-sm font-medium p-4"> -
+
<%= check_box_tag dom_id(entry, "selection"), class: "checkbox checkbox--light", data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %> @@ -38,16 +38,6 @@ format_money(-entry.amount_money), class: ["text-green-600": entry.amount.negative?] %>
- -
- <% if balance_trend&.trend %> -
- <%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-primary" %> -
- <% else %> - <%= tag.p "--", class: "font-medium text-sm text-gray-400" %> - <% end %> -
<% end %> <% end %> diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index e52c8da2..bf0e3331 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -6,7 +6,7 @@ <%= turbo_frame_tag dom_id(transaction) do %>
"> -
"> +
<%= check_box_tag dom_id(entry, "selection"), disabled: transaction.transfer.present?, class: "checkbox checkbox--light", @@ -93,22 +93,11 @@ <%= render "transactions/transaction_category", transaction: transaction %>
-
+
<%= content_tag :p, transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money), class: ["text-green-600": entry.amount.negative?] %>
- - <% if view_ctx != "global" %> - - <% end %>
<% end %> <% end %> diff --git a/app/views/valuations/_confirmation_contents.html.erb b/app/views/valuations/_confirmation_contents.html.erb index 19d2ff5f..d8fdf032 100644 --- a/app/views/valuations/_confirmation_contents.html.erb +++ b/app/views/valuations/_confirmation_contents.html.erb @@ -2,8 +2,8 @@
<% if account.investment? %> - <% holdings_value = reconciliation_dry_run.new_balance - reconciliation_dry_run.new_cash_balance %> - <% brokerage_cash = reconciliation_dry_run.new_cash_balance %> + <% brokerage_cash = reconciliation_dry_run.new_cash_balance || 0 %> + <% holdings_value = reconciliation_dry_run.new_balance - brokerage_cash %>

This will <%= action_verb %> the account value on <%= entry.date.strftime("%B %d, %Y") %> to:

diff --git a/app/views/valuations/_valuation.html.erb b/app/views/valuations/_valuation.html.erb index eeef903c..9bb41ce1 100644 --- a/app/views/valuations/_valuation.html.erb +++ b/app/views/valuations/_valuation.html.erb @@ -1,9 +1,9 @@ -<%# locals: (entry:, balance_trend: nil, **) %> +<%# locals: (entry:, **) %> <% valuation = entry.entryable %> -<% color = balance_trend&.trend&.color || "#D444F1" %> -<% icon = balance_trend&.trend&.icon || "plus" %> +<% color = valuation.opening_anchor? ? "#D444F1" : "var(--color-gray)" %> +<% icon = valuation.opening_anchor? ? "plus" : "minus" %> <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(valuation) do %> @@ -26,7 +26,7 @@
- <%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-primary" %> + <%= tag.p format_money(entry.amount_money), class: "font-bold text-sm text-primary" %>
<% end %> diff --git a/app/views/valuations/show.html.erb b/app/views/valuations/show.html.erb index f58be03e..089d98a4 100644 --- a/app/views/valuations/show.html.erb +++ b/app/views/valuations/show.html.erb @@ -24,7 +24,7 @@ max: Date.current %> <%= f.money_field :amount, - label: t(".amount"), + label: "Account value on date", disable_currency: true %>
diff --git a/test/components/previews/tooltip_component_preview.rb b/test/components/previews/tooltip_component_preview.rb new file mode 100644 index 00000000..68bd6c32 --- /dev/null +++ b/test/components/previews/tooltip_component_preview.rb @@ -0,0 +1,32 @@ +class TooltipComponentPreview < ViewComponent::Preview + # @param text text + # @param placement select [top, right, bottom, left] + # @param offset number + # @param cross_axis number + # @param icon text + # @param size select [xs, sm, md, lg, xl, 2xl] + # @param color select [default, white, success, warning, destructive, current] + def default(text: "This is helpful information", placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default") + render DS::Tooltip.new( + text: text, + placement: placement, + offset: offset, + cross_axis: cross_axis, + icon: icon, + size: size, + color: color + ) + end + + def with_block_content + render DS::Tooltip.new(icon: "help-circle", color: "warning") do + tag.div do + tag.p("Custom content with formatting:", class: "font-medium mb-1") + + tag.ul(class: "list-disc list-inside text-xs") do + tag.li("First item") + + tag.li("Second item") + end + end + end + end +end diff --git a/test/models/account/activity_feed_data_test.rb b/test/models/account/activity_feed_data_test.rb new file mode 100644 index 00000000..2139076c --- /dev/null +++ b/test/models/account/activity_feed_data_test.rb @@ -0,0 +1,355 @@ +require "test_helper" + +class Account::ActivityFeedDataTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @family = families(:empty) + @checking = @family.accounts.create!(name: "Test Checking", accountable: Depository.new, currency: "USD", balance: 0) + @savings = @family.accounts.create!(name: "Test Savings", accountable: Depository.new, currency: "USD", balance: 0) + @investment = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0) + + @test_period_start = Date.current - 4.days + + setup_test_data + end + + test "calculates balance trend with complete balance history" do + entries = @checking.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(@checking, entries) + + activities = feed_data.entries_by_date + day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) + + assert_not_nil day2_activity + trend = day2_activity.balance_trend + assert_equal 1100, trend.current.amount.to_i # End of day 2 + assert_equal 1000, trend.previous.amount.to_i # End of day 1 + assert_equal 100, trend.value.amount.to_i + assert_equal "up", trend.direction.to_s + end + + test "calculates balance trend for first day with zero starting balance" do + entries = @checking.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(@checking, entries) + + activities = feed_data.entries_by_date + day1_activity = find_activity_for_date(activities, @test_period_start) + + assert_not_nil day1_activity + trend = day1_activity.balance_trend + assert_equal 1000, trend.current.amount.to_i # End of first day + assert_equal 0, trend.previous.amount.to_i # Fallback to 0 + assert_equal 1000, trend.value.amount.to_i + end + + test "uses last observed balance when intermediate balances are missing" do + @checking.balances.where(date: [ @test_period_start + 1.day, @test_period_start + 3.days ]).destroy_all + + entries = @checking.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(@checking, entries) + + activities = feed_data.entries_by_date + + # When day 2 balance is missing, both start and end use day 1 balance + day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) + assert_not_nil day2_activity + trend = day2_activity.balance_trend + assert_equal 1000, trend.current.amount.to_i # LOCF from day 1 + assert_equal 1000, trend.previous.amount.to_i # LOCF from day 1 + assert_equal 0, trend.value.amount.to_i + assert_equal "flat", trend.direction.to_s + end + + test "returns zero balance when no balance history exists" do + @checking.balances.destroy_all + + entries = @checking.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(@checking, entries) + + activities = feed_data.entries_by_date + # Use first day which has a transaction + day1_activity = find_activity_for_date(activities, @test_period_start) + + assert_not_nil day1_activity + trend = day1_activity.balance_trend + assert_equal 0, trend.current.amount.to_i # Fallback to 0 + assert_equal 0, trend.previous.amount.to_i # Fallback to 0 + assert_equal 0, trend.value.amount.to_i + assert_equal "flat", trend.direction.to_s + end + + test "calculates cash and holdings trends for investment accounts" do + entries = @investment.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(@investment, entries) + + activities = feed_data.entries_by_date + day3_activity = find_activity_for_date(activities, @test_period_start + 2.days) + + assert_not_nil day3_activity + + # Cash trend for day 3 (after foreign currency transaction) + cash_trend = day3_activity.cash_balance_trend + assert_equal 400, cash_trend.current.amount.to_i # End of day 3 cash balance + assert_equal 500, cash_trend.previous.amount.to_i # End of day 2 cash balance + assert_equal(-100, cash_trend.value.amount.to_i) + assert_equal "down", cash_trend.direction.to_s + + # Holdings trend for day 3 (after trade) + holdings_trend = day3_activity.holdings_value_trend + assert_equal 1500, holdings_trend.current.amount.to_i # Total balance - cash balance + assert_equal 0, holdings_trend.previous.amount.to_i # No holdings before trade + assert_equal 1500, holdings_trend.value.amount.to_i + assert_equal "up", holdings_trend.direction.to_s + end + + test "identifies transfers for a specific date" do + entries = @checking.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(@checking, entries) + + activities = feed_data.entries_by_date + + # Day 2 has the transfer + day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) + assert_not_nil day2_activity + assert_equal 1, day2_activity.transfers.size + assert_equal @transfer, day2_activity.transfers.first + + # Other days have no transfers + day1_activity = find_activity_for_date(activities, @test_period_start) + assert_not_nil day1_activity + assert_empty day1_activity.transfers + end + + test "returns complete ActivityDateData objects with all required fields" do + entries = @investment.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(@investment, entries) + + activities = feed_data.entries_by_date + + # Check that we get ActivityDateData objects + assert activities.all? { |a| a.is_a?(Account::ActivityFeedData::ActivityDateData) } + + # Check that each ActivityDate has the required fields + activities.each do |activity| + assert_respond_to activity, :date + assert_respond_to activity, :entries + assert_respond_to activity, :balance_trend + assert_respond_to activity, :cash_balance_trend + assert_respond_to activity, :holdings_value_trend + assert_respond_to activity, :transfers + end + end + + test "handles valuations correctly by summing entry changes" do + # Create account with known balances + account = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0) + + # Day 1: Starting balance + account.balances.create!( + date: @test_period_start, + balance: 7321.56, + cash_balance: 1000, + currency: "USD" + ) + + # Day 2: Add transactions, trades and a valuation + account.balances.create!( + date: @test_period_start + 1.day, + balance: 8500, # Valuation sets this + cash_balance: 1070, # Cash increased by transactions + currency: "USD" + ) + + # Create transactions + create_transaction( + account: account, + date: @test_period_start + 1.day, + amount: -50, + name: "Interest payment" + ) + create_transaction( + account: account, + date: @test_period_start + 1.day, + amount: -20, + name: "Interest payment" + ) + + # Create a trade + create_trade( + securities(:aapl), + account: account, + qty: 5, + date: @test_period_start + 1.day, + price: 150 # 5 * 150 = 750 + ) + + # Create valuation + create_valuation( + account: account, + date: @test_period_start + 1.day, + amount: 8500 + ) + + entries = account.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(account, entries) + + activities = feed_data.entries_by_date + day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) + + assert_not_nil day2_activity + + # Cash change should be $70 (50 + 20 from transactions only, not trades) + assert_equal 70, day2_activity.cash_balance_trend.value.amount.to_i + + # Holdings change should be 750 (from the trade) + assert_equal 750, day2_activity.holdings_value_trend.value.amount.to_i + + # Total balance change + assert_in_delta 1178.44, day2_activity.balance_trend.value.amount.to_f, 0.01 + end + + test "normalizes multi-currency entries on valuation days" do + # Create EUR account + eur_account = @family.accounts.create!(name: "EUR Investment", accountable: Investment.new, currency: "EUR", balance: 0) + + # Day 1: Starting balance + eur_account.balances.create!( + date: @test_period_start, + balance: 1000, + cash_balance: 500, + currency: "EUR" + ) + + # Day 2: Multi-currency transactions and valuation + eur_account.balances.create!( + date: @test_period_start + 1.day, + balance: 2000, + cash_balance: 600, + currency: "EUR" + ) + + # Create USD transaction (should be converted to EUR) + create_transaction( + account: eur_account, + date: @test_period_start + 1.day, + amount: -100, + currency: "USD", + name: "USD Payment" + ) + + # Create exchange rate: 1 USD = 0.9 EUR + ExchangeRate.create!( + date: @test_period_start + 1.day, + from_currency: "USD", + to_currency: "EUR", + rate: 0.9 + ) + + # Create valuation + create_valuation( + account: eur_account, + date: @test_period_start + 1.day, + amount: 2000 + ) + + entries = eur_account.entries.includes(:entryable).to_a + feed_data = Account::ActivityFeedData.new(eur_account, entries) + + activities = feed_data.entries_by_date + day2_activity = find_activity_for_date(activities, @test_period_start + 1.day) + + assert_not_nil day2_activity + + # Cash change should be 90 EUR (100 USD * 0.9) + # The transaction is -100 USD, which becomes +100 when inverted, then 100 * 0.9 = 90 EUR + assert_equal 90, day2_activity.cash_balance_trend.value.amount.to_i + assert_equal "EUR", day2_activity.cash_balance_trend.value.currency.iso_code + end + + private + def find_activity_for_date(activities, date) + activities.find { |a| a.date == date } + end + + def setup_test_data + # Create daily balances for checking account + 5.times do |i| + date = @test_period_start + i.days + @checking.balances.create!( + date: date, + balance: 1000 + (i * 100), + currency: "USD" + ) + end + + # Create daily balances for investment account with cash_balance + @investment.balances.create!( + date: @test_period_start, + balance: 500, + cash_balance: 500, + currency: "USD" + ) + @investment.balances.create!( + date: @test_period_start + 1.day, + balance: 500, + cash_balance: 500, + currency: "USD" + ) + @investment.balances.create!( + date: @test_period_start + 2.days, + balance: 1900, # 1500 holdings + 400 cash + cash_balance: 400, # After -100 EUR transaction + currency: "USD" + ) + + # Day 1: Regular transaction + create_transaction( + account: @checking, + date: @test_period_start, + amount: -50, + name: "Grocery Store" + ) + + # Day 2: Transfer between accounts + @transfer = create_transfer( + from_account: @checking, + to_account: @savings, + amount: 200, + date: @test_period_start + 1.day + ) + + # Day 3: Trade in investment account + create_trade( + securities(:aapl), + account: @investment, + qty: 10, + date: @test_period_start + 2.days, + price: 150 + ) + + # Day 3: Foreign currency transaction + create_transaction( + account: @investment, + date: @test_period_start + 2.days, + amount: -100, + currency: "EUR", + name: "International Wire" + ) + + # Create exchange rate for foreign currency + ExchangeRate.create!( + date: @test_period_start + 2.days, + from_currency: "EUR", + to_currency: "USD", + rate: 1.1 + ) + + # Day 4: Valuation + create_valuation( + account: @investment, + date: @test_period_start + 3.days, + amount: 25 + ) + end +end diff --git a/test/support/entries_test_helper.rb b/test/support/entries_test_helper.rb index 35e5450f..f586b148 100644 --- a/test/support/entries_test_helper.rb +++ b/test/support/entries_test_helper.rb @@ -15,33 +15,6 @@ module EntriesTestHelper Entry.create! entry_defaults.merge(entry_attributes) end - def create_opening_anchor_valuation(account:, balance:, date:) - create_valuation( - account: account, - kind: "opening_anchor", - amount: balance, - date: date - ) - end - - def create_reconciliation_valuation(account:, balance:, date:) - create_valuation( - account: account, - kind: "reconciliation", - amount: balance, - date: date - ) - end - - def create_current_anchor_valuation(account:, balance:, date: Date.current) - create_valuation( - account: account, - kind: "current_anchor", - amount: balance, - date: date - ) - end - def create_valuation(attributes = {}) entry_attributes = attributes.except(:kind) valuation_attributes = attributes.slice(:kind) @@ -77,4 +50,33 @@ module EntriesTestHelper currency: currency, entryable: trade end + + def create_transfer(from_account:, to_account:, amount:, date: Date.current, currency: "USD") + outflow_transaction = Transaction.create!(kind: "funds_movement") + inflow_transaction = Transaction.create!(kind: "funds_movement") + + transfer = Transfer.create!( + outflow_transaction: outflow_transaction, + inflow_transaction: inflow_transaction + ) + + # Create entries for both accounts + from_account.entries.create!( + name: "Transfer to #{to_account.name}", + date: date, + amount: -amount.abs, + currency: currency, + entryable: outflow_transaction + ) + + to_account.entries.create!( + name: "Transfer from #{from_account.name}", + date: date, + amount: amount.abs, + currency: currency, + entryable: inflow_transaction + ) + + transfer + end end diff --git a/test/system/settings/api_keys_test.rb b/test/system/settings/api_keys_test.rb index 839068bb..d6beeeee 100644 --- a/test/system/settings/api_keys_test.rb +++ b/test/system/settings/api_keys_test.rb @@ -124,17 +124,14 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase # Click the revoke button to open the modal click_button "Revoke Key" - # Wait for the modal to appear and click Confirm - # The dialog might take a moment to appear - sleep 0.5 - + # Wait for the dialog and then confirm + assert_selector "#confirm-dialog", visible: true within "#confirm-dialog" do - assert_text "Are you sure you want to revoke this API key?" click_button "Confirm" end - # Wait for the page to update after revoke - sleep 0.5 + # Wait for redirect after revoke + assert_no_selector "#confirm-dialog" assert_text "Create Your API Key" assert_text "Get programmatic access to your Maybe data" diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb index ad9cc926..be1f9b54 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -118,22 +118,25 @@ class TransactionsTest < ApplicationSystemTestCase assert_text "No entries found" + # Wait for Turbo to finish updating the DOM + sleep 0.5 + # Page reload doesn't affect results visit current_url assert_text "No entries found" - within "ul#transaction-search-filters" do - find("li", text: account.name).first("button").click - find("li", text: "on or after #{10.days.ago.to_date}").first("button").click - find("li", text: "on or before #{1.day.ago.to_date}").first("button").click - find("li", text: "Income").first("button").click - find("li", text: "less than 200").first("button").click - find("li", text: category.name).first("button").click - find("li", text: merchant.name).first("button").click + # Remove all filters by clicking their X buttons + # Get all the filter buttons at once to avoid stale elements + filter_count = page.all("ul#transaction-search-filters li button").count + + # Click each one with a small delay to let Turbo update + filter_count.times do + page.all("ul#transaction-search-filters li button").first.click + sleep 0.1 end - assert_selector "#" + dom_id(@transaction), count: 1 + assert_text @transaction.name end test "can select and deselect entire page of transactions" do @@ -191,7 +194,7 @@ class TransactionsTest < ApplicationSystemTestCase fill_in "Date", with: transfer_date fill_in "model[amount]", with: 175.25 click_button "Add transaction" - within "#entry-group-" + transfer_date.to_s do + within "#" + dom_id(investment_account, "entries_#{transfer_date}") do assert_text "175.25" end end