mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-04 21:15:19 +02:00
Transactions cleanup (#817)
An overhaul and cleanup of the transactions feature including: - Simplification of transactions search and filtering - Consolidation of account sync logic after transaction change - Split sidebar modal and modal into "drawer" and "modal" concepts - Refactor of transaction partials and folder organization - Cleanup turbo frames and streams for transaction updates, including new Transactions::RowsController for inline updates - Refactored and added several integration and systems tests
This commit is contained in:
parent
ee162bbef7
commit
4ebc08e5a4
61 changed files with 789 additions and 683 deletions
|
@ -22,10 +22,6 @@ class Account < ApplicationRecord
|
|||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name id]
|
||||
end
|
||||
|
||||
def balance_on(date)
|
||||
balances.where("date <= ?", date).order(date: :desc).first&.balance
|
||||
end
|
||||
|
|
|
@ -1,20 +1,28 @@
|
|||
class Transaction < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
|
||||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
validates :name, :date, :amount, :account, presence: true
|
||||
|
||||
monetize :amount
|
||||
|
||||
scope :ordered, -> { order(date: :desc) }
|
||||
scope :active, -> { where(excluded: false) }
|
||||
scope :inflows, -> { where("amount <= 0") }
|
||||
scope :outflows, -> { where("amount > 0") }
|
||||
scope :active, -> { where(excluded: false) }
|
||||
scope :by_name, ->(name) { where("transactions.name ILIKE ?", "%#{name}%") }
|
||||
scope :with_categories, ->(categories) { joins(:category).where(transaction_categories: { name: categories }) }
|
||||
scope :with_accounts, ->(accounts) { joins(:account).where(accounts: { name: accounts }) }
|
||||
scope :with_account_ids, ->(account_ids) { joins(:account).where(accounts: { id: account_ids }) }
|
||||
scope :with_merchants, ->(merchants) { joins(:merchant).where(transaction_merchants: { name: merchants }) }
|
||||
scope :on_or_after_date, ->(date) { where("transactions.date >= ?", date) }
|
||||
scope :on_or_before_date, ->(date) { where("transactions.date <= ?", date) }
|
||||
scope :with_converted_amount, ->(currency = Current.family.currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
# If no rate is available, exclude the transaction from the results
|
||||
|
@ -26,92 +34,74 @@ class Transaction < ApplicationRecord
|
|||
.where("er.rate IS NOT NULL OR transactions.currency = ?", currency)
|
||||
}
|
||||
|
||||
def self.daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
"gs.date",
|
||||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||
)
|
||||
.from(transactions.with_converted_amount(currency), :t)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
def inflow?
|
||||
amount <= 0
|
||||
end
|
||||
|
||||
def self.daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Extend the period to include the rolling window
|
||||
period_with_rolling = period.extend_backward(period.date_range.count.days)
|
||||
|
||||
# Aggregate the rolling sum of spending and income based on daily totals
|
||||
rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency))
|
||||
.select(
|
||||
"*",
|
||||
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
|
||||
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
|
||||
)
|
||||
.order("date")
|
||||
|
||||
# Trim the results to the original period
|
||||
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
|
||||
def outflow?
|
||||
amount > 0
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name amount date]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[category merchant account]
|
||||
end
|
||||
|
||||
def self.build_filter_list(params, family)
|
||||
filters = []
|
||||
valid_params = {}
|
||||
|
||||
date_filters = { gteq: nil, lteq: nil }
|
||||
|
||||
if params
|
||||
params.each do |key, value|
|
||||
next if value.blank?
|
||||
|
||||
case key
|
||||
when "account_id_in"
|
||||
valid_accounts = value.select do |account_id|
|
||||
account = family.accounts.find_by(id: account_id)
|
||||
filters << { type: "account", value: account, original: { key: key, value: account_id } } if account.present?
|
||||
account.present?
|
||||
end
|
||||
valid_params[key] = valid_accounts unless valid_accounts.empty?
|
||||
when "category_id_in"
|
||||
valid_categories = value.select do |category_id|
|
||||
category = family.transaction_categories.find_by(id: category_id)
|
||||
filters << { type: "category", value: category, original: { key: key, value: category_id } } if category.present?
|
||||
category.present?
|
||||
end
|
||||
valid_params[key] = valid_categories unless valid_categories.empty?
|
||||
when "merchant_id_in"
|
||||
valid_merchants = value.select do |merchant_id|
|
||||
merchant = family.transaction_merchants.find_by(id: merchant_id)
|
||||
filters << { type: "merchant", value: merchant, original: { key: key, value: merchant_id } } if merchant.present?
|
||||
merchant.present?
|
||||
end
|
||||
valid_params[key] = valid_merchants unless valid_merchants.empty?
|
||||
when "category_name_or_merchant_name_or_account_name_or_name_cont"
|
||||
filters << { type: "search", value: value, original: { key: key, value: nil } }
|
||||
valid_params[key] = value
|
||||
when "date_gteq"
|
||||
date_filters[:gteq] = value
|
||||
valid_params[key] = value
|
||||
when "date_lteq"
|
||||
date_filters[:lteq] = value
|
||||
valid_params[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
unless date_filters.values.compact.empty?
|
||||
filters << { type: "date_range", value: date_filters, original: { key: "date_range", value: nil } }
|
||||
end
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_transaction_date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
[ filters, valid_params ]
|
||||
account.sync_later(sync_start_date)
|
||||
end
|
||||
|
||||
class << self
|
||||
def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
"gs.date",
|
||||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||
)
|
||||
.from(transactions.with_converted_amount(currency), :t)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
end
|
||||
|
||||
def daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Extend the period to include the rolling window
|
||||
period_with_rolling = period.extend_backward(period.date_range.count.days)
|
||||
|
||||
# Aggregate the rolling sum of spending and income based on daily totals
|
||||
rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency))
|
||||
.select(
|
||||
"*",
|
||||
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
|
||||
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
|
||||
)
|
||||
.order("date")
|
||||
|
||||
# Trim the results to the original period
|
||||
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
|
||||
end
|
||||
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.by_name(params[:search]) if params[:search].present?
|
||||
query = query.with_categories(params[:categories]) if params[:categories].present?
|
||||
query = query.with_accounts(params[:accounts]) if params[:accounts].present?
|
||||
query = query.with_account_ids(params[:account_ids]) if params[:account_ids].present?
|
||||
query = query.with_merchants(params[:merchants]) if params[:merchants].present?
|
||||
query = query.on_or_after_date(params[:start_date]) if params[:start_date].present?
|
||||
query = query.on_or_before_date(params[:end_date]) if params[:end_date].present?
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_transaction_date
|
||||
self.account
|
||||
.transactions
|
||||
.where("date < ?", date)
|
||||
.order(date: :desc)
|
||||
.first&.date
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,14 +23,6 @@ class Transaction::Category < ApplicationRecord
|
|||
{ internal_category: "home_improvement", color: COLORS[7] }
|
||||
]
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name id]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[]
|
||||
end
|
||||
|
||||
def self.create_default_categories(family)
|
||||
if family.transaction_categories.size > 0
|
||||
raise ArgumentError, "Family already has some categories"
|
||||
|
|
|
@ -7,12 +7,4 @@ class Transaction::Merchant < ApplicationRecord
|
|||
scope :alphabetically, -> { order(:name) }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name id]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[]
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue