mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-05 05:25:24 +02:00
perf(transactions): add kind
to Transaction
model and remove expensive Transfer joins in aggregations (#2388)
* add kind to transaction model * Basic transfer creator * Fix method naming conflict * Creator form pattern * Remove stale methods * Tweak migration * Remove BaseQuery, write entire query in each class for clarity * Query optimizations * Remove unused exchange rate query lines * Remove temporary cache-warming strategy * Fix test * Update transaction search * Decouple transactions endpoint from IncomeStatement * Clean up transactions controller * Update cursor rules * Cleanup comments, logic in search * Fix totals logic on transactions view * Fix pagination * Optimize search totals query * Default to last 30 days on transactions page if no filters * Decouple transactions list from transfer details * Revert transfer route * Migration reset * Bundle update * Fix matching logic, tests * Remove unused code
This commit is contained in:
parent
7aca5a2277
commit
1aae00f586
49 changed files with 1749 additions and 705 deletions
|
@ -1,17 +1,27 @@
|
|||
class TradesController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
# Defaults to a buy trade
|
||||
def new
|
||||
@account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
@model = Current.family.entries.new(
|
||||
account: @account,
|
||||
currency: @account ? @account.currency : Current.family.currency,
|
||||
entryable: Trade.new
|
||||
)
|
||||
end
|
||||
|
||||
# Can create a trade, transaction (e.g. "fees"), or transfer (e.g. "withdrawal")
|
||||
def create
|
||||
@entry = build_entry
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
|
||||
|
||||
if @model.persisted?
|
||||
flash[:notice] = t("entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) }
|
||||
format.html { redirect_back_or_to account_path(@account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to account_path(@account) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
|
@ -41,11 +51,6 @@ class TradesController < ApplicationController
|
|||
end
|
||||
|
||||
private
|
||||
def build_entry
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
TradeBuilder.new(create_entry_params.merge(account: account))
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
|
@ -53,8 +58,8 @@ class TradesController < ApplicationController
|
|||
)
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
params.require(:entry).permit(
|
||||
def create_params
|
||||
params.require(:model).permit(
|
||||
:date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
)
|
||||
end
|
||||
|
|
|
@ -3,8 +3,6 @@ class TransactionsController < ApplicationController
|
|||
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
require "digest/md5"
|
||||
|
||||
def new
|
||||
super
|
||||
@income_categories = Current.family.categories.incomes.alphabetically
|
||||
|
@ -13,95 +11,22 @@ class TransactionsController < ApplicationController
|
|||
|
||||
def index
|
||||
@q = search_params
|
||||
transactions_query = Current.family.transactions.active.search(@q)
|
||||
@search = Transaction::Search.new(Current.family, filters: @q)
|
||||
|
||||
set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
|
||||
base_scope = @search.transactions_scope
|
||||
.reverse_chronological
|
||||
.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
:transfer_as_inflow, :transfer_as_outflow
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cache the expensive includes & pagination block so the DB work only
|
||||
# runs when either the query params change *or* any entry has been
|
||||
# updated for the current family.
|
||||
# ------------------------------------------------------------------
|
||||
@pagy, @transactions = pagy(base_scope, limit: per_page, params: ->(p) { p.except(:focused_record_id) })
|
||||
|
||||
latest_update_ts = Current.family.entries.maximum(:updated_at)&.utc&.to_i || 0
|
||||
|
||||
items_per_page = (params[:per_page].presence || default_params[:per_page]).to_i
|
||||
items_per_page = 1 if items_per_page <= 0
|
||||
|
||||
current_page = (params[:page].presence || default_params[:page]).to_i
|
||||
current_page = 1 if current_page <= 0
|
||||
|
||||
# Build a compact cache digest: sanitized filters + page info + a
|
||||
# token that changes on updates *or* deletions.
|
||||
entries_changed_token = [ latest_update_ts, Current.family.entries.count ].join(":")
|
||||
|
||||
digest_source = {
|
||||
q: @q, # processed & sanitised search params
|
||||
page: current_page, # requested page number
|
||||
per: items_per_page, # page size
|
||||
tok: entries_changed_token
|
||||
}.to_json
|
||||
|
||||
cache_key = Current.family.build_cache_key(
|
||||
"transactions_idx_#{Digest::MD5.hexdigest(digest_source)}"
|
||||
)
|
||||
|
||||
cache_data = Rails.cache.fetch(cache_key, expires_in: 30.minutes) do
|
||||
current_page_i = current_page
|
||||
|
||||
# Initial query
|
||||
offset = (current_page_i - 1) * items_per_page
|
||||
ids = transactions_query
|
||||
.reverse_chronological
|
||||
.limit(items_per_page)
|
||||
.offset(offset)
|
||||
.pluck(:id)
|
||||
|
||||
total_count = transactions_query.count
|
||||
|
||||
if ids.empty? && total_count.positive? && current_page_i > 1
|
||||
current_page_i = (total_count.to_f / items_per_page).ceil
|
||||
offset = (current_page_i - 1) * items_per_page
|
||||
|
||||
ids = transactions_query
|
||||
.reverse_chronological
|
||||
.limit(items_per_page)
|
||||
.offset(offset)
|
||||
.pluck(:id)
|
||||
end
|
||||
|
||||
{ ids: ids, total_count: total_count, current_page: current_page_i }
|
||||
# No performance penalty by default. Only runs queries if the record is set.
|
||||
if params[:focused_record_id].present?
|
||||
set_focused_record(base_scope, params[:focused_record_id], default_per_page: per_page)
|
||||
end
|
||||
|
||||
ids = cache_data[:ids]
|
||||
total_count = cache_data[:total_count]
|
||||
current_page = cache_data[:current_page]
|
||||
|
||||
# Build Pagy object (this part is cheap – done *after* potential
|
||||
# page fallback so the pagination UI reflects the adjusted page
|
||||
# number).
|
||||
@pagy = Pagy.new(
|
||||
count: total_count,
|
||||
page: current_page,
|
||||
items: items_per_page,
|
||||
params: ->(p) { p.except(:focused_record_id) }
|
||||
)
|
||||
|
||||
# Fetch the transactions in the cached order
|
||||
@transactions = Current.family.transactions
|
||||
.active
|
||||
.where(id: ids)
|
||||
.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
)
|
||||
|
||||
# Preserve the order defined by `ids`
|
||||
@transactions = ids.map { |id| @transactions.detect { |t| t.id == id } }.compact
|
||||
|
||||
@totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
|
||||
end
|
||||
|
||||
def clear_filter
|
||||
|
@ -124,6 +49,10 @@ class TransactionsController < ApplicationController
|
|||
end
|
||||
|
||||
updated_params["q"] = q_params.presence
|
||||
|
||||
# Add flag to indicate filters were explicitly cleared
|
||||
updated_params["filter_cleared"] = "1" if updated_params["q"].blank?
|
||||
|
||||
Current.session.update!(prev_transaction_page_params: updated_params)
|
||||
|
||||
redirect_to transactions_path(updated_params)
|
||||
|
@ -185,6 +114,10 @@ class TransactionsController < ApplicationController
|
|||
end
|
||||
|
||||
private
|
||||
def per_page
|
||||
params[:per_page].to_i.positive? ? params[:per_page].to_i : 50
|
||||
end
|
||||
|
||||
def needs_rule_notification?(transaction)
|
||||
return false if Current.user.rule_prompts_disabled
|
||||
|
||||
|
@ -217,7 +150,8 @@ class TransactionsController < ApplicationController
|
|||
cleaned_params = params.fetch(:q, {})
|
||||
.permit(
|
||||
:start_date, :end_date, :search, :amount,
|
||||
:amount_operator, accounts: [], account_ids: [],
|
||||
:amount_operator, :active_accounts_only, :excluded_transactions,
|
||||
accounts: [], account_ids: [],
|
||||
categories: [], merchants: [], types: [], tags: []
|
||||
)
|
||||
.to_h
|
||||
|
@ -225,35 +159,9 @@ class TransactionsController < ApplicationController
|
|||
|
||||
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Performance optimisation
|
||||
# -------------------------------------------------------------------
|
||||
# When a user lands on the Transactions page without an explicit date
|
||||
# filter, the previous behaviour queried *all* historical transactions
|
||||
# for the family. For large datasets this results in very expensive
|
||||
# SQL (as shown in Skylight) – particularly the aggregation queries
|
||||
# used for @totals. To keep the UI responsive while still showing a
|
||||
# sensible period of activity, we fall back to the user's preferred
|
||||
# default period (stored on User#default_period, defaulting to
|
||||
# "last_30_days") when **no** date filters have been supplied.
|
||||
#
|
||||
# This effectively changes the default view from "all-time" to a
|
||||
# rolling window, dramatically reducing the rows scanned / grouped in
|
||||
# Postgres without impacting the UX (the user can always clear the
|
||||
# filter).
|
||||
# -------------------------------------------------------------------
|
||||
if cleaned_params[:start_date].blank? && cleaned_params[:end_date].blank?
|
||||
period_key = Current.user&.default_period.presence || "last_30_days"
|
||||
|
||||
begin
|
||||
period = Period.from_key(period_key)
|
||||
cleaned_params[:start_date] = period.start_date
|
||||
cleaned_params[:end_date] = period.end_date
|
||||
rescue Period::InvalidKeyError
|
||||
# Fallback – should never happen but keeps things safe.
|
||||
cleaned_params[:start_date] = 30.days.ago.to_date
|
||||
cleaned_params[:end_date] = Date.current
|
||||
end
|
||||
# Only add default start_date if params are blank AND filters weren't explicitly cleared
|
||||
if cleaned_params.blank? && params[:filter_cleared].blank?
|
||||
cleaned_params[:start_date] = 30.days.ago.to_date
|
||||
end
|
||||
|
||||
cleaned_params
|
||||
|
@ -263,9 +171,9 @@ class TransactionsController < ApplicationController
|
|||
if should_restore_params?
|
||||
params_to_restore = {}
|
||||
|
||||
params_to_restore[:q] = stored_params["q"].presence || default_params[:q]
|
||||
params_to_restore[:page] = stored_params["page"].presence || default_params[:page]
|
||||
params_to_restore[:per_page] = stored_params["per_page"].presence || default_params[:per_page]
|
||||
params_to_restore[:q] = stored_params["q"].presence || {}
|
||||
params_to_restore[:page] = stored_params["page"].presence || 1
|
||||
params_to_restore[:per_page] = stored_params["per_page"].presence || 50
|
||||
|
||||
redirect_to transactions_path(params_to_restore)
|
||||
else
|
||||
|
@ -286,12 +194,4 @@ class TransactionsController < ApplicationController
|
|||
def stored_params
|
||||
Current.session.prev_transaction_page_params
|
||||
end
|
||||
|
||||
def default_params
|
||||
{
|
||||
q: {},
|
||||
page: 1,
|
||||
per_page: 50
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,7 +8,12 @@ class TransferMatchesController < ApplicationController
|
|||
|
||||
def create
|
||||
@transfer = build_transfer
|
||||
@transfer.save!
|
||||
Transfer.transaction do
|
||||
@transfer.save!
|
||||
@transfer.outflow_transaction.update!(kind: Transfer.kind_for_account(@transfer.outflow_transaction.entry.account))
|
||||
@transfer.inflow_transaction.update!(kind: "funds_movement")
|
||||
end
|
||||
|
||||
@transfer.sync_account_later
|
||||
|
||||
redirect_back_or_to transactions_path, notice: "Transfer created"
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
class TransfersController < ApplicationController
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
include StreamExtensions
|
||||
|
||||
before_action :set_transfer, only: %i[show destroy update]
|
||||
|
||||
def new
|
||||
@transfer = Transfer.new
|
||||
|
@ -10,25 +12,19 @@ class TransfersController < ApplicationController
|
|||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
@transfer = Transfer::Creator.new(
|
||||
family: Current.family,
|
||||
source_account_id: transfer_params[:from_account_id],
|
||||
destination_account_id: transfer_params[:to_account_id],
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d
|
||||
)
|
||||
|
||||
if @transfer.save
|
||||
@transfer.sync_account_later
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
).create
|
||||
|
||||
if @transfer.persisted?
|
||||
success_message = "Transfer created"
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_path }
|
||||
redirect_target_url = request.referer || transactions_path
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
format.html { redirect_back_or_to transactions_path, notice: success_message }
|
||||
format.turbo_stream { stream_redirect_back_or_to transactions_path, notice: success_message }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
|
@ -54,9 +50,11 @@ class TransfersController < ApplicationController
|
|||
|
||||
private
|
||||
def set_transfer
|
||||
@transfer = Transfer.find(params[:id])
|
||||
|
||||
raise ActiveRecord::RecordNotFound unless @transfer.belongs_to_family?(Current.family)
|
||||
# Finds the transfer and ensures the family owns it
|
||||
@transfer = Transfer
|
||||
.where(id: params[:id])
|
||||
.where(inflow_transaction_id: Current.family.transactions.select(:id))
|
||||
.first
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue