1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00

Improve speed of transactions page (#1752)
Some checks failed
Publish Docker image / ci (push) Has been cancelled
Publish Docker image / Build docker image (push) Has been cancelled

* Make demo data more realistic

* Fix N+1 transactions query

* Lint fixes

* Totals query

* Consolidate stats calcs

* Fix preload

* Fix filter clearing

* Fix N+1 queries for family sync detection

* Reduce queries for rendering transfers

* Fix tests

* Remove flaky test
This commit is contained in:
Zach Gollwitzer 2025-01-31 19:08:21 -05:00 committed by GitHub
parent 53f4b32c33
commit 2c2b600163
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 209 additions and 195 deletions

View file

@ -28,6 +28,7 @@ gem "good_job"
# Error logging
gem "stackprof"
gem "rack-mini-profiler"
gem "sentry-ruby"
gem "sentry-rails"
@ -67,6 +68,7 @@ group :development do
gem "ruby-lsp-rails"
gem "web-console"
gem "faker"
gem "benchmark-ips"
end
group :test do

View file

@ -101,6 +101,7 @@ GEM
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.0)
benchmark-ips (2.14.0)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
@ -317,6 +318,8 @@ GEM
raabro (1.4.0)
racc (1.8.1)
rack (3.1.8)
rack-mini-profiler (3.3.1)
rack (>= 1.2.0)
rack-session (2.1.0)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@ -501,6 +504,7 @@ PLATFORMS
DEPENDENCIES
aws-sdk-s3 (~> 1.177.0)
bcrypt (~> 3.1)
benchmark-ips
bootsnap
brakeman
capybara
@ -531,6 +535,7 @@ DEPENDENCIES
plaid
propshaft
puma (>= 5.0)
rack-mini-profiler
rails (~> 7.2.2)
rails-settings-cached
redcarpet

View file

@ -21,7 +21,7 @@ class PagesController < ApplicationController
@accounts = Current.family.accounts.active
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
@transaction_entries = Current.family.entries.incomes_and_expenses.limit(6).reverse_chronological
# TODO: Placeholders for trendlines
placeholder_series_data = 10.times.map do |i|

View file

@ -7,30 +7,36 @@ class TransactionsController < ApplicationController
def index
@q = search_params
search_query = Current.family.transactions.search(@q).reverse_chronological
search_query = Current.family.transactions.search(@q)
set_focused_record(search_query, params[:focused_record_id], default_per_page: 50)
@pagy, @transaction_entries = pagy(
search_query,
search_query.reverse_chronological.preload(
:account,
entryable: [
:category, :merchant, :tags,
:transfer_as_inflow,
transfer_as_outflow: {
inflow_transaction: { entry: :account },
outflow_transaction: { entry: :account }
}
]
),
limit: params[:per_page].presence || default_params[:per_page],
params: ->(params) { params.except(:focused_record_id) }
)
totals_query = search_query.incomes_and_expenses
family_currency = Current.family.currency
count_with_transfers = search_query.count
count_without_transfers = totals_query.count
@totals = {
count: ((count_with_transfers - count_without_transfers) / 2) + count_without_transfers,
income: totals_query.income_total(family_currency).abs,
expense: totals_query.expense_total(family_currency)
}
@transfers = @transaction_entries.map { |entry| entry.entryable.transfer_as_outflow }.compact
@totals = search_query.stats(Current.family.currency)
end
def clear_filter
updated_params = stored_params.deep_dup
updated_params = {
"q" => search_params,
"page" => params[:page],
"per_page" => params[:per_page]
}
q_params = updated_params["q"] || {}

View file

@ -8,10 +8,10 @@ module Account::EntriesHelper
transfers.map(&:transfer).uniq
end
def entries_by_date(entries, selectable: true, totals: false)
entries.reverse_chronological.group_by(&:date).map do |date, grouped_entries|
def entries_by_date(entries, transfers: [], selectable: true, totals: false)
entries.group_by(&:date).map do |date, grouped_entries|
content = capture do
yield grouped_entries
yield [ grouped_entries, transfers.select { |t| t.outflow_transaction.entry.date == date } ]
end
next if content.blank?

View file

@ -1,6 +1,8 @@
class Account::Entry < ApplicationRecord
include Monetizable
Stats = Struct.new(:currency, :count, :income_total, :expense_total, keyword_init: true)
monetize :amount
belongs_to :account
@ -33,11 +35,11 @@ class Account::Entry < ApplicationRecord
# All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses
scope :incomes_and_expenses, -> {
joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id")
.joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id")
.joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id")
.where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')")
.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id")
.joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id")
.joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id")
.where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')")
}
scope :incomes, -> {
@ -154,20 +156,24 @@ class Account::Entry < ApplicationRecord
all.size
end
def income_total(currency = "USD", start_date: nil, end_date: nil)
total = incomes.where(date: start_date..end_date)
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
def stats(currency = "USD")
result = all
.incomes_and_expenses
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
.select(
"COUNT(*) AS count",
"SUM(CASE WHEN account_entries.amount < 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS income_total",
"SUM(CASE WHEN account_entries.amount > 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS expense_total"
)
.to_a
.first
Money.new(total, currency)
end
def expense_total(currency = "USD", start_date: nil, end_date: nil)
total = expenses.where(date: start_date..end_date)
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
Money.new(total, currency)
Stats.new(
currency: currency,
count: result.count,
income_total: result.income_total ? result.income_total * -1 : 0,
expense_total: result.expense_total || 0
)
end
end
end

View file

@ -1,92 +1,115 @@
class Demo::Generator
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
def initialize
@family = reset_family!
end
# Builds a semi-realistic mirror of what production data might look like
def reset_and_clear_data!(family_names)
puts "Clearing existing data..."
def reset_and_clear_data!
reset_settings!
clear_data!
create_user!
destroy_everything!
puts "user reset"
end
puts "Data cleared"
def reset_data!
Family.transaction do
reset_settings!
clear_data!
create_user!
puts "user reset"
create_tags!
create_categories!
create_merchants!
puts "tags, categories, merchants created"
create_credit_card_account!
create_checking_account!
create_savings_account!
create_investment_account!
create_house_and_mortgage!
create_car_and_loan!
create_other_accounts!
create_transfer_transactions!
puts "accounts created"
puts "Demo data loaded successfully!"
family_names.each_with_index do |family_name, index|
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local")
end
puts "Users reset"
end
def reset_data!(family_names)
puts "Clearing existing data..."
destroy_everything!
puts "Data cleared"
family_names.each_with_index do |family_name, index|
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", data_enrichment_enabled: index == 0)
end
puts "Users reset"
load_securities!
puts "Securities loaded"
family_names.each do |family_name|
family = Family.find_by(name: family_name)
ActiveRecord::Base.transaction do
create_tags!(family)
create_categories!(family)
create_merchants!(family)
puts "tags, categories, merchants created for #{family_name}"
create_credit_card_account!(family)
create_checking_account!(family)
create_savings_account!(family)
create_investment_account!(family)
create_house_and_mortgage!(family)
create_car_and_loan!(family)
create_other_accounts!(family)
create_transfer_transactions!(family)
end
puts "accounts created for #{family_name}"
end
puts "Demo data loaded successfully!"
end
private
attr_reader :family
def reset_family!
family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id
family = Family.find_by(id: family_id)
Transfer.destroy_all
family.destroy! if family
Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload)
end
def clear_data!
Transfer.destroy_all
def destroy_everything!
Family.destroy_all
Setting.destroy_all
InviteCode.destroy_all
User.find_by_email("user@maybe.local")&.destroy
ExchangeRate.destroy_all
Security.destroy_all
Security::Price.destroy_all
end
def reset_settings!
Setting.destroy_all
end
def create_family_and_user!(family_name, user_email, data_enrichment_enabled: false)
base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0"
id = Digest::UUID.uuid_v5(base_uuid, family_name)
family = Family.create!(
id: id,
name: family_name,
stripe_subscription_status: "active",
data_enrichment_enabled: data_enrichment_enabled,
locale: "en",
country: "US",
timezone: "America/New_York",
date_format: "%m-%d-%Y"
)
def create_user!
family.users.create! \
email: "user@maybe.local",
email: user_email,
first_name: "Demo",
last_name: "User",
role: "admin",
password: "password",
onboarded_at: Time.current
family.users.create! \
email: "member_#{user_email}",
first_name: "Demo (member user)",
last_name: "User",
role: "member",
password: "password",
onboarded_at: Time.current
end
def create_tags!
def create_tags!(family)
[ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag|
family.tags.create!(name: tag)
end
end
def create_categories!
def create_categories!(family)
family.categories.bootstrap_defaults
food = family.categories.find_by(name: "Food & Drink")
@ -95,7 +118,7 @@ class Demo::Generator
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense")
end
def create_merchants!
def create_merchants!(family)
merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco",
"Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike",
"Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ]
@ -105,25 +128,25 @@ class Demo::Generator
end
end
def create_credit_card_account!
def create_credit_card_account!(family)
cc = family.accounts.create! \
accountable: CreditCard.new,
name: "Chase Credit Card",
balance: 2300,
currency: "USD"
50.times do
merchant = random_family_record(Merchant)
800.times do
merchant = random_family_record(Merchant, family)
create_transaction! \
account: cc,
name: merchant.name,
amount: Faker::Number.positive(to: 200),
tags: [ tag_for_merchant(merchant) ],
category: category_for_merchant(merchant),
tags: [ tag_for_merchant(merchant, family) ],
category: category_for_merchant(merchant, family),
merchant: merchant
end
5.times do
24.times do
create_transaction! \
account: cc,
amount: Faker::Number.negative(from: -1000),
@ -131,30 +154,30 @@ class Demo::Generator
end
end
def create_checking_account!
def create_checking_account!(family)
checking = family.accounts.create! \
accountable: Depository.new,
name: "Chase Checking",
balance: 15000,
currency: "USD"
10.times do
200.times do
create_transaction! \
account: checking,
name: "Expense",
amount: Faker::Number.positive(from: 100, to: 1000)
end
10.times do
50.times do
create_transaction! \
account: checking,
amount: Faker::Number.negative(from: -2000),
name: "Income",
category: income_category
category: family.categories.find_by(name: "Income")
end
end
def create_savings_account!
def create_savings_account!(family)
savings = family.accounts.create! \
accountable: Depository.new,
name: "Demo Savings",
@ -162,20 +185,17 @@ class Demo::Generator
currency: "USD",
subtype: "savings"
income_category = categories.find { |c| c.name == "Income" }
income_tag = tags.find { |t| t.name == "Emergency Fund" }
20.times do
100.times do
create_transaction! \
account: savings,
amount: Faker::Number.negative(from: -2000),
tags: [ income_tag ],
category: income_category,
tags: [ family.tags.find_by(name: "Emergency Fund") ],
category: family.categories.find_by(name: "Income"),
name: "Income"
end
end
def create_transfer_transactions!
def create_transfer_transactions!(family)
checking = family.accounts.find_by(name: "Chase Checking")
credit_card = family.accounts.find_by(name: "Chase Credit Card")
investment = family.accounts.find_by(name: "Robinhood")
@ -235,9 +255,7 @@ class Demo::Generator
end
end
def create_investment_account!
load_securities!
def create_investment_account!(family)
account = family.accounts.create! \
accountable: Investment.new,
name: "Robinhood",
@ -275,7 +293,7 @@ class Demo::Generator
end
end
def create_house_and_mortgage!
def create_house_and_mortgage!(family)
house = family.accounts.create! \
accountable: Property.new,
name: "123 Maybe Way",
@ -293,7 +311,7 @@ class Demo::Generator
currency: "USD"
end
def create_car_and_loan!
def create_car_and_loan!(family)
family.accounts.create! \
accountable: Vehicle.new,
name: "Honda Accord",
@ -307,7 +325,7 @@ class Demo::Generator
currency: "USD"
end
def create_other_accounts!
def create_other_accounts!(family)
family.accounts.create! \
accountable: OtherAsset.new,
name: "Other Asset",
@ -326,7 +344,7 @@ class Demo::Generator
transaction_attributes = attributes.slice(:category, :tags, :merchant)
entry_defaults = {
date: Faker::Number.between(from: 0, to: 90).days.ago.to_date,
date: Faker::Number.between(from: 0, to: 730).days.ago.to_date,
currency: "USD",
entryable: Account::Transaction.new(transaction_attributes)
}
@ -344,12 +362,12 @@ class Demo::Generator
entryable: Account::Valuation.new
end
def random_family_record(model)
def random_family_record(model, family)
family_records = model.where(family_id: family.id)
model.offset(rand(family_records.count)).first
end
def category_for_merchant(merchant)
def category_for_merchant(merchant, family)
mapping = {
"Amazon" => "Shopping",
"Starbucks" => "Food & Drink",
@ -369,41 +387,20 @@ class Demo::Generator
"Sephora" => "Shopping"
}
categories.find { |c| c.name == mapping[merchant.name] }
family.categories.find_by(name: mapping[merchant.name])
end
def tag_for_merchant(merchant)
def tag_for_merchant(merchant, family)
mapping = {
"Delta Airlines" => "Trips",
"Airbnb" => "Trips"
}
tag_from_merchant = tags.find { |t| t.name == mapping[merchant.name] }
tag_from_merchant || tags.find { |t| t.name == "Demo Tag" }
tag_from_merchant = family.tags.find_by(name: mapping[merchant.name])
tag_from_merchant || family.tags.find_by(name: "Demo Tag")
end
def securities
@securities ||= Security.all.to_a
end
def merchants
@merchants ||= family.merchants
end
def categories
@categories ||= family.categories
end
def tags
@tags ||= family.tags
end
def income_tag
@income_tag ||= tags.find { |t| t.name == "Emergency Fund" }
end
def income_category
@income_category ||= categories.find { |c| c.name == "Income" }
end
end

View file

@ -44,7 +44,12 @@ class Family < ApplicationRecord
end
def syncing?
super || accounts.manual.any?(&:syncing?) || plaid_items.any?(&:syncing?)
Sync.where(
"(syncable_type = 'Family' AND syncable_id = ?) OR
(syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))",
id, id, id
).where(status: [ "pending", "syncing" ]).exists?
end
def eu?

View file

@ -17,7 +17,8 @@ class User < ApplicationRecord
enum :role, { member: "member", admin: "admin", super_admin: "super_admin" }, validate: true
has_one_attached :profile_image do |attachable|
attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ]
attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ], convert: :webp, saver: { quality: 80 }
attachable.variant :small, resize_to_fill: [ 36, 36 ], convert: :webp, saver: { quality: 80 }
end
validate :profile_image_size

View file

@ -32,7 +32,7 @@
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
<% else %>
<div class="space-y-6">
<%= entries_by_date(@entries) do |entries| %>
<%= entries_by_date(@entries) do |entries, _transfers| %>
<%= render partial: "account/trades/trade", collection: entries, as: :entry %>
<% end %>
</div>

View file

@ -1,19 +1,18 @@
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
<% transaction, account = entry.account_transaction, entry.account %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4 <%= @focused_record == entry ? "border border-gray-900 rounded-lg" : "" %>">
<div class="pr-10 flex items-center gap-4 <%= balance_trend ? "col-span-6" : "col-span-8" %>">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
disabled: entry.account_transaction.transfer?,
disabled: entry.entryable.transfer?,
class: "maybe-checkbox maybe-checkbox--light",
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
<% end %>
<div class="max-w-full">
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
<% if transaction.merchant&.icon_url %>
<%= image_tag transaction.merchant.icon_url, class: "w-6 h-6 rounded-full", loading: "lazy" %>
<% if entry.entryable.merchant&.icon_url %>
<%= image_tag entry.entryable.merchant.icon_url, class: "w-6 h-6 rounded-full", loading: "lazy" %>
<% else %>
<%= render "shared/circle_logo", name: entry.display_name, size: "sm" %>
<% end %>
@ -24,8 +23,8 @@
<% if entry.new_record? %>
<%= content_tag :p, entry.display_name %>
<% else %>
<%= link_to entry.account_transaction.transfer? ? entry.account_transaction.transfer.name : entry.display_name,
entry.account_transaction.transfer? ? transfer_path(entry.account_transaction.transfer) : account_entry_path(entry),
<%= link_to entry.entryable.transfer? ? entry.entryable.transfer.name : entry.display_name,
entry.entryable.transfer? ? transfer_path(entry.entryable.transfer) : account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
@ -36,14 +35,14 @@
</span>
<% end %>
<% if entry.account_transaction.transfer? %>
<% if entry.entryable.transfer? %>
<%= render "account/transactions/transfer_match", entry: entry %>
<% end %>
</div>
<div class="text-gray-500 text-xs font-normal">
<% if entry.account_transaction.transfer? %>
<%= render "transfers/account_links", transfer: entry.account_transaction.transfer, is_inflow: entry.account_transaction.transfer_as_inflow.present? %>
<% if entry.entryable.transfer? %>
<%= render "transfers/account_links", transfer: entry.entryable.transfer, is_inflow: entry.entryable.transfer_as_inflow.present? %>
<% else %>
<%= link_to entry.account.name, account_path(entry.account, tab: "transactions", focused_record_id: entry.id), data: { turbo_frame: "_top" }, class: "hover:underline" %>
<% end %>

View file

@ -19,7 +19,7 @@
<p class="text-gray-500 py-4"><%= t(".no_transactions") %></p>
<% else %>
<div class="space-y-6">
<%= entries_by_date(@entries) do |entries| %>
<%= entries_by_date(@entries) do |entries, _transfers| %>
<%= render entries %>
<% end %>
</div>

View file

@ -77,7 +77,7 @@
<div class="rounded-tl-lg rounded-tr-lg bg-white border-alpha-black-25 shadow-xs">
<div class="space-y-4">
<% calculator = Account::BalanceTrendCalculator.for(@entries) %>
<%= entries_by_date(@entries) do |entries| %>
<%= entries_by_date(@entries) do |entries, _transfers| %>
<% entries.each do |entry| %>
<%= render entry, balance_trend: calculator&.trend_for(entry) %>
<% end %>

View file

@ -5,13 +5,13 @@
<div id="user-menu" data-controller="menu">
<button data-menu-target="button">
<div class="w-9 h-9">
<%= render "settings/user_avatar", user: Current.user %>
<%= render "settings/user_avatar", user: Current.user, variant: :small %>
</div>
</button>
<div data-menu-target="content" class="hidden absolute w-[240px] z-10 left-[255px] top-[72px] bg-white rounded-sm shadow-xs border border-alpha-black-25">
<div class="p-3 flex items-center gap-3">
<div class="w-9 h-9 shrink-0">
<%= render "settings/user_avatar", user: Current.user %>
<%= render "settings/user_avatar", user: Current.user, variant: :small, lazy: true %>
</div>
<div class="overflow-hidden text-ellipsis">

View file

@ -190,7 +190,7 @@
</div>
<% else %>
<div class="text-gray-500 p-1 space-y-1 bg-gray-25 rounded-xl">
<%= entries_by_date(@transaction_entries, selectable: false) do |entries| %>
<%= entries_by_date(@transaction_entries, selectable: false) do |entries, _transfers| %>
<%= render entries, selectable: false %>
<% end %>

View file

@ -1,7 +1,7 @@
<%# locals: (user:) %>
<%# locals: (user:, variant: :thumbnail, lazy: false) %>
<% if user.profile_image.attached? %>
<%= image_tag user.profile_image.variant(:thumbnail), class: "rounded-full w-full h-full object-cover" %>
<%= image_tag user.profile_image.variant(variant), class: "rounded-full w-full h-full object-cover", loading: lazy ? "lazy" : "eager" %>
<% else %>
<div class="text-white w-full h-full bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= user.initial %></div>
<% end %>

View file

@ -2,18 +2,18 @@
<div class="grid grid-cols-3 bg-white rounded-xl border border-alpha-black-25 shadow-xs divide-x divide-alpha-black-100">
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Total transactions</p>
<p class="text-gray-900 font-medium text-xl" id="total-transactions"><%= totals[:count] %></p>
<p class="text-gray-900 font-medium text-xl" id="total-transactions"><%= totals.count %></p>
</div>
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Income</p>
<p class="text-gray-900 font-medium text-xl" id="total-income">
<%= format_money totals[:income] %>
<%= format_money Money.new(totals.income_total, totals.currency) %>
</p>
</div>
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Expenses</p>
<p class="text-gray-900 font-medium text-xl" id="total-expense">
<%= format_money totals[:expense] %>
<%= format_money Money.new(totals.expense_total, totals.currency) %>
</p>
</div>
</div>

View file

@ -14,7 +14,7 @@
<%= render "account/transactions/selection_bar" %>
</div>
<% if @transaction_entries.present? %>
<% if @pagy.count > 0 %>
<div class="grow overflow-y-auto">
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">
<div class="pl-0.5 col-span-8 flex items-center gap-4">
@ -28,13 +28,11 @@
<p class="col-span-2 justify-self-end">amount</p>
</div>
<div class="space-y-6">
<%= entries_by_date(@transaction_entries, totals: true) do |entries| %>
<%# Render transfers by selecting one side of the transfer (to prevent double-rendering the same transfer across date groups) %>
<%= render partial: "transfers/transfer",
collection: entries.select { |e| e.account_transaction.transfer? && e.account_transaction.transfer_as_outflow.present? }.map { |e| e.account_transaction.transfer_as_outflow } %>
<%= entries_by_date(@transaction_entries, transfers: @transfers, totals: true) do |entries, transfers| %>
<%= render partial: "transfers/transfer", collection: transfers %>
<%# Render regular entries %>
<%= render partial: "account/entries/entry", collection: entries.reject { |e| e.account_transaction.transfer? } %>
<%= render partial: "account/entries/entry", collection: entries.reject { |e| e.entryable.transfer? } %>
<% end %>
</div>
</div>

View file

@ -41,7 +41,10 @@
</div>
<% end %>
<%= button_to clear_filter_transactions_path(param_key: param_key, param_value: param_value), method: :delete, data: { turbo: false }, class: "flex items-center" do %>
<%= button_to clear_filter_transactions_path(param_key: param_key, param_value: param_value, **request.query_parameters),
method: :delete,
data: { turbo: false },
class: "flex items-center" do %>
<%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %>
<% end %>
</li>

View file

@ -1,10 +1,12 @@
namespace :demo_data do
desc "Creates or resets demo data used in development environment"
task empty: :environment do
Demo::Generator.new.reset_and_clear_data!
families = [ "Demo Family 1" ]
Demo::Generator.new.reset_and_clear_data!(families)
end
task reset: :environment do
Demo::Generator.new.reset_data!
families = [ "Demo Family 1", "Demo Family 2", "Demo Family 3", "Demo Family 4", "Demo Family 5" ]
Demo::Generator.new.reset_data!(families)
end
end

View file

@ -67,23 +67,18 @@ class Account::EntryTest < ActiveSupport::TestCase
assert_equal 0, family.entries.search(params).size
end
test "can calculate total spending for a group of transactions" do
test "can calculate totals for a group of transactions" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
create_transaction(account: account, amount: 100)
create_transaction(account: account, amount: 100)
create_transaction(account: account, amount: -500) # income, will be ignored
create_transaction(account: account, amount: -500)
assert_equal Money.new(200), family.entries.expense_total("USD")
end
totals = family.entries.stats("USD")
test "can calculate total income for a group of transactions" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
create_transaction(account: account, amount: -100)
create_transaction(account: account, amount: -100)
create_transaction(account: account, amount: 500) # income, will be ignored
assert_equal Money.new(-200), family.entries.income_total("USD")
assert_equal 3, totals.count
assert_equal 500, totals.income_total
assert_equal 200, totals.expense_total
assert_equal "USD", totals.currency
end
end

View file

@ -69,11 +69,6 @@ class AccountsTest < ApplicationSystemTestCase
assert_account_created("OtherLiability")
end
test "can sync all accounts on accounts page" do
visit accounts_url
assert_button "Sync all"
end
private
def open_new_account_modal