1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-08 15:05:22 +02:00

Merge remote-tracking branch 'origin/main' into bugfix/ui-pwa-ios

This commit is contained in:
Ken Tandrian 2025-06-26 01:34:37 +00:00
commit 5dbdd18fa4
11 changed files with 144 additions and 49 deletions

View file

@ -2,6 +2,9 @@
<%= tag.summary class: class_names(
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
) do %>
<% if summary_content? %>
<%= summary_content %>
<% else %>
<div class="flex items-center gap-3">
<% if align == :left %>
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
@ -14,8 +17,7 @@
<% if align == :right %>
<%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %>
<% elsif summary_content? %>
<%= summary_content %>
<% end %>
<% end %>
<% end %>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -2,13 +2,14 @@
<div id="<%= account_group.dom_id(tab: all_tab ? :all : nil, mobile: mobile) %>">
<% 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? %>
<div class="ml-2 group-open:hidden">
<%= render partial: "shared/sync_indicator", locals: { size: "xs" } %>
</div>
<div class="flex items-center gap-3">
<%= 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 %>
</div>
<div class="ml-auto text-right grow">
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
@ -32,10 +33,7 @@
<div class="min-w-0 grow">
<div class="flex items-center gap-2 mb-0.5">
<%= 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?) %>
</div>
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
</div>

View file

@ -12,15 +12,11 @@
<div class="flex items-center gap-2">
<div class="truncate">
<h2 class="font-medium text-xl truncate"><%= title || account.name %></h2>
<h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title || account.name %></h2>
<% if subtitle.present? %>
<p class="text-sm text-secondary"><%= subtitle %></p>
<% end %>
</div>
<% if account.syncing? %>
<%= render partial: "shared/sync_indicator", locals: { size: "sm" } %>
<% end %>
</div>
</div>
<% end %>

View file

@ -5,7 +5,7 @@
<div class="bg-container shadow-border-xs rounded-xl space-y-4 p-4">
<div class="flex items-center gap-2">
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
<span>
<span class="<%= "animate-pulse" if classification_group.syncing? %>">
<%= classification_group.name %>
</span>
@ -14,10 +14,6 @@
<span class="text-secondary font-medium text-lg"><%= classification_group.total_money.format(precision: 0) %></span>
<% end %>
</h2>
<% if classification_group.syncing? %>
<%= render partial: "shared/sync_indicator", locals: { size: "sm" } %>
<% end %>
</div>
<% if classification_group.account_groups.any? %>

View file

@ -7,13 +7,9 @@
<div class="space-y-2">
<div class="flex items-center gap-2">
<p class="text-sm text-secondary font-medium"><%= t(".title") %></p>
<% if balance_sheet.syncing? %>
<%= render partial: "shared/sync_indicator", locals: { size: "sm" } %>
<% end %>
</div>
<p class="text-primary -space-x-0.5 text-3xl font-medium">
<p class="text-primary -space-x-0.5 text-3xl font-medium <%= "animate-pulse" if balance_sheet.syncing? %>">
<%= series.trend.current.format %>
</p>

View file

@ -1,5 +0,0 @@
<%# locals: (size: "md") %>
<div class="animate-spin text-gray-500">
<%= icon "loader-circle", color: "current", size: size %>
</div>

View file

@ -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