diff --git a/Gemfile b/Gemfile index 933985df57d..180d47f6271 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 61a15574406..e919b1f17d0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 79e30f25bbc..e372a8d6d27 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -21,7 +21,7 @@ def dashboard @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| diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 7478894b837..e61feaaad9b 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -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"] || {} diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index 7fb8a6a1868..b3268fdda3b 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -8,10 +8,10 @@ def transfer_entries(entries) 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? diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 9001c451950..4a222014a92 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -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 @@ def bulk_update!(bulk_update_params) 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 - - 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) + 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 + + 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 diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index c008e623644..c20bfa70db3 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -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..." + + destroy_everything! - def reset_and_clear_data! - reset_settings! - clear_data! - create_user! + puts "Data cleared" - puts "user reset" + 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.transaction do - reset_settings! - clear_data! - create_user! + def reset_data!(family_names) + puts "Clearing existing data..." + + destroy_everything! - puts "user reset" + puts "Data cleared" - create_tags! - create_categories! - create_merchants! + 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 "tags, categories, merchants created" + puts "Users reset" - create_credit_card_account! - create_checking_account! - create_savings_account! + load_securities! - create_investment_account! - create_house_and_mortgage! - create_car_and_loan! - create_other_accounts! + puts "Securities loaded" - create_transfer_transactions! + family_names.each do |family_name| + family = Family.find_by(name: family_name) - puts "accounts created" - puts "Demo data loaded successfully!" - end - end + ActiveRecord::Base.transaction do + create_tags!(family) + create_categories!(family) + create_merchants!(family) - private + puts "tags, categories, merchants created for #{family_name}" - attr_reader :family + create_credit_card_account!(family) + create_checking_account!(family) + create_savings_account!(family) - def reset_family! - family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id + create_investment_account!(family) + create_house_and_mortgage!(family) + create_car_and_loan!(family) + create_other_accounts!(family) - family = Family.find_by(id: family_id) - Transfer.destroy_all - family.destroy! if family + create_transfer_transactions!(family) + end - Family.create!(id: family_id, name: "Demo Family", stripe_subscription_status: "active").tap(&:reload) + puts "accounts created for #{family_name}" end - def clear_data! - Transfer.destroy_all + puts "Demo data loaded successfully!" + end + + private + 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 @@ def create_categories! 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 @@ def create_merchants! 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 @@ def create_credit_card_account! 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 @@ def create_savings_account! 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 @@ def load_securities! 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 @@ def create_investment_account! 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 @@ def create_house_and_mortgage! 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 @@ def create_car_and_loan! 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 @@ def create_transaction!(attributes = {}) 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 @@ def create_valuation!(account, date, amount) 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 @@ def category_for_merchant(merchant) "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 diff --git a/app/models/family.rb b/app/models/family.rb index 7ac41f25a62..329f8e3f934 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -44,7 +44,12 @@ def post_sync 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? diff --git a/app/models/user.rb b/app/models/user.rb index 92171040e2a..cbafdb1c401 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/account/trades/index.html.erb b/app/views/account/trades/index.html.erb index c33c1f21ea5..c538a87c216 100644 --- a/app/views/account/trades/index.html.erb +++ b/app/views/account/trades/index.html.erb @@ -32,7 +32,7 @@

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

<% else %>
- <%= entries_by_date(@entries) do |entries| %> + <%= entries_by_date(@entries) do |entries, _transfers| %> <%= render partial: "account/trades/trade", collection: entries, as: :entry %> <% end %>
diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index fcfdb29c590..3b679db0974 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -1,19 +1,18 @@ <%# locals: (entry:, selectable: true, balance_trend: nil) %> -<% transaction, account = entry.account_transaction, entry.account %>
">
"> <% 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 %>
<%= 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 @@ <% end %> - <% if entry.account_transaction.transfer? %> + <% if entry.entryable.transfer? %> <%= render "account/transactions/transfer_match", entry: entry %> <% end %>
- <% 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 %> diff --git a/app/views/account/transactions/index.html.erb b/app/views/account/transactions/index.html.erb index f30fcfd6ef8..7855d1f64f9 100644 --- a/app/views/account/transactions/index.html.erb +++ b/app/views/account/transactions/index.html.erb @@ -19,7 +19,7 @@

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

<% else %>
- <%= entries_by_date(@entries) do |entries| %> + <%= entries_by_date(@entries) do |entries, _transfers| %> <%= render entries %> <% end %>
diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 0b0657ed931..e47e84fcd24 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -77,7 +77,7 @@
<% 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 %> diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 42ac543d3e1..005c4f3607b 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -5,13 +5,13 @@