diff --git a/app/components/disclosure_component.html.erb b/app/components/disclosure_component.html.erb index 38db2d7f..bf6f61d1 100644 --- a/app/components/disclosure_component.html.erb +++ b/app/components/disclosure_component.html.erb @@ -2,20 +2,22 @@ <%= tag.summary class: class_names( "px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface" ) do %> -
- <% if align == :left %> - <%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> - <% end %> - - <%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %> - <%= title %> - <% end %> -
- - <% if align == :right %> - <%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %> - <% elsif summary_content? %> + <% if summary_content? %> <%= summary_content %> + <% else %> +
+ <% if align == :left %> + <%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + <% end %> + + <%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %> + <%= title %> + <% end %> +
+ + <% if align == :right %> + <%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %> + <% end %> <% end %> <% end %> diff --git a/app/components/disclosure_component.rb b/app/components/disclosure_component.rb index 013e3e9d..5aba01b2 100644 --- a/app/components/disclosure_component.rb +++ b/app/components/disclosure_component.rb @@ -3,7 +3,7 @@ class DisclosureComponent < ViewComponent::Base attr_reader :title, :align, :open, :opts - def initialize(title:, align: "right", open: false, **opts) + def initialize(title: nil, align: "right", open: false, **opts) @title = title @align = align.to_sym @open = open diff --git a/app/models/family/auto_transfer_matchable.rb b/app/models/family/auto_transfer_matchable.rb index 754ca225..988fda22 100644 --- a/app/models/family/auto_transfer_matchable.rb +++ b/app/models/family/auto_transfer_matchable.rb @@ -9,8 +9,6 @@ module Family::AutoTransferMatchable JOIN entries outflow_candidates ON ( inflow_candidates.amount < 0 AND outflow_candidates.amount > 0 AND - inflow_candidates.amount = -outflow_candidates.amount AND - inflow_candidates.currency = outflow_candidates.currency AND inflow_candidates.account_id <> outflow_candidates.account_id AND inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4 ) @@ -24,12 +22,26 @@ module Family::AutoTransferMatchable rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id )") + .joins("LEFT JOIN exchange_rates ON ( + exchange_rates.date = outflow_candidates.date AND + exchange_rates.from_currency = outflow_candidates.currency AND + exchange_rates.to_currency = inflow_candidates.currency + )") .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_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'") + .where(" + ( + inflow_candidates.currency = outflow_candidates.currency AND + inflow_candidates.amount = -outflow_candidates.amount + ) OR ( + inflow_candidates.currency <> outflow_candidates.currency AND + ABS(inflow_candidates.amount / NULLIF(outflow_candidates.amount * exchange_rates.rate, 0)) BETWEEN 0.95 AND 1.05 + ) + ") .where(existing_transfers: { id: nil }) .order("date_diff ASC") # Closest matches first end diff --git a/app/models/plaid_account/processor.rb b/app/models/plaid_account/processor.rb index 1a8205a1..6c999911 100644 --- a/app/models/plaid_account/processor.rb +++ b/app/models/plaid_account/processor.rb @@ -84,9 +84,12 @@ class PlaidAccount::Processor if plaid_account.plaid_type == "investment" @balance_calculator ||= PlaidAccount::Investments::BalanceCalculator.new(plaid_account, security_resolver: security_resolver) else + balance = plaid_account.current_balance || plaid_account.available_balance || 0 + + # We don't currently distinguish "cash" vs. "non-cash" balances for non-investment accounts. OpenStruct.new( - balance: plaid_account.current_balance || plaid_account.available_balance, - cash_balance: plaid_account.available_balance || 0 + balance: balance, + cash_balance: balance ) end end diff --git a/app/models/sync.rb b/app/models/sync.rb index 7baf9e63..ea66ebb4 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -19,6 +19,8 @@ class Sync < ApplicationRecord scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) } scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) } + after_commit :update_family_sync_timestamp + validate :window_valid # Sync state machine @@ -169,7 +171,6 @@ class Sync < ApplicationRecord def handle_transition log_status_change - family.touch(:latest_sync_activity_at) end def handle_completion_transition @@ -182,6 +183,10 @@ class Sync < ApplicationRecord end end + def update_family_sync_timestamp + family.touch(:latest_sync_activity_at) + end + def family if syncable.is_a?(Family) syncable diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index 26b69be3..ccca2088 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -2,13 +2,14 @@
<% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %> - <%= render DisclosureComponent.new(title: account_group.name, align: :left, open: is_open) do |disclosure| %> + <%= render DisclosureComponent.new(align: :left, open: is_open) do |disclosure| %> <% disclosure.with_summary_content do %> - <% if account_group.syncing? %> -
- <%= render partial: "shared/sync_indicator", locals: { size: "xs" } %> -
- <% end %> +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + <%= tag.span class: class_names("text-sm text-primary font-medium", "animate-pulse" => account_group.syncing?) do %> + <%= account_group.name %> + <% end %> +
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %> @@ -32,10 +33,7 @@
- <%= tag.p account.name, class: "text-sm text-primary font-medium truncate" %> - <% if account.syncing? %> - <%= render partial: "shared/sync_indicator", locals: { size: "xs" } %> - <% end %> + <%= tag.p account.name, class: class_names("text-sm text-primary font-medium truncate", "animate-pulse" => account.syncing?) %>
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 283b4e05..ea0514a5 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -12,15 +12,11 @@
-

<%= title || account.name %>

+

"><%= title || account.name %>

<% if subtitle.present? %>

<%= subtitle %>

<% end %>
- - <% if account.syncing? %> - <%= render partial: "shared/sync_indicator", locals: { size: "sm" } %> - <% end %>
<% end %> diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 60f7786b..8ad17567 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -5,7 +5,7 @@

- + "> <%= classification_group.name %> @@ -14,10 +14,6 @@ <%= classification_group.total_money.format(precision: 0) %> <% end %>

- - <% if classification_group.syncing? %> - <%= render partial: "shared/sync_indicator", locals: { size: "sm" } %> - <% end %>
<% if classification_group.account_groups.any? %> diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb index fabc0267..56a31f24 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -7,13 +7,9 @@

<%= t(".title") %>

- - <% if balance_sheet.syncing? %> - <%= render partial: "shared/sync_indicator", locals: { size: "sm" } %> - <% end %>
-

+

"> <%= series.trend.current.format %>

diff --git a/app/views/shared/_sync_indicator.html.erb b/app/views/shared/_sync_indicator.html.erb deleted file mode 100644 index 2ef56bf5..00000000 --- a/app/views/shared/_sync_indicator.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<%# locals: (size: "md") %> - -
- <%= icon "loader-circle", color: "current", size: size %> -
diff --git a/test/models/family/auto_transfer_matchable_test.rb b/test/models/family/auto_transfer_matchable_test.rb index 77bb80f0..6f03ad85 100644 --- a/test/models/family/auto_transfer_matchable_test.rb +++ b/test/models/family/auto_transfer_matchable_test.rb @@ -18,6 +18,51 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase end end + test "auto-matches multi-currency transfers" do + load_exchange_prices + create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 500) + create_transaction(date: Date.current, account: @credit_card, amount: -700, currency: "CAD") + + assert_difference -> { Transfer.count } => 1 do + @family.auto_match_transfers! + end + + # test match within lower 5% bound + create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1000) + create_transaction(date: Date.current, account: @credit_card, amount: -1330, currency: "CAD") + + assert_difference -> { Transfer.count } => 1 do + @family.auto_match_transfers! + end + + # test match within upper 5% bound + create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1500) + create_transaction(date: Date.current, account: @credit_card, amount: -2189, currency: "CAD") + + assert_difference -> { Transfer.count } => 1 do + @family.auto_match_transfers! + end + + # test no match outside of slippage tolerance + create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1000) + create_transaction(date: Date.current, account: @credit_card, amount: -1320, currency: "CAD") + + assert_difference -> { Transfer.count } => 0 do + @family.auto_match_transfers! + end + end + + test "only matches inflow with correct currency when duplicate amounts exist" do + load_exchange_prices + create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 500) + create_transaction(date: Date.current, account: @credit_card, amount: -500, currency: "CAD") + create_transaction(date: Date.current, account: @credit_card, amount: -500) + + assert_difference -> { Transfer.count } => 1 do + @family.auto_match_transfers! + end + end + # In this scenario, our matching logic should find 4 potential matches. These matches should be ranked based on # days apart, then de-duplicated so that we aren't auto-matching the same transaction across multiple transfers. test "when 2 options exist, only auto-match one at a time, ranked by days apart" do @@ -53,4 +98,51 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase @family.auto_match_transfers! end end + + test "does not match transactions outside the 4-day window" do + create_transaction(date: 10.days.ago.to_date, account: @depository, amount: 500) + create_transaction(date: Date.current, account: @credit_card, amount: -500) + + assert_no_difference -> { Transfer.count } do + @family.auto_match_transfers! + end + end + + test "does not match multi-currency transfer with missing exchange rate" do + create_transaction(date: Date.current, account: @depository, amount: 500) + create_transaction(date: Date.current, account: @credit_card, amount: -700, currency: "GBP") + + assert_no_difference -> { Transfer.count } do + @family.auto_match_transfers! + end + end + + private + def load_exchange_prices + rates = { + 4.days.ago.to_date => 1.36, + 3.days.ago.to_date => 1.37, + 2.days.ago.to_date => 1.38, + 1.day.ago.to_date => 1.39, + Date.current => 1.40 + } + + rates.each do |date, rate| + # USD to CAD + ExchangeRate.create!( + from_currency: "USD", + to_currency: "CAD", + date: date, + rate: rate + ) + + # CAD to USD (inverse) + ExchangeRate.create!( + from_currency: "CAD", + to_currency: "USD", + date: date, + rate: (1.0 / rate).round(6) + ) + end + end end