mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +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
|
@ -13,13 +13,6 @@ class Account::Syncer
|
|||
|
||||
def perform_post_sync
|
||||
account.family.auto_match_transfers!
|
||||
|
||||
# Warm IncomeStatement caches so subsequent requests are fast
|
||||
# TODO: this is a temporary solution to speed up pages. Long term we'll throw a materialized view / pre-computed table
|
||||
# in for family stats.
|
||||
income_statement = IncomeStatement.new(account.family)
|
||||
Rails.logger.info("Warming IncomeStatement caches")
|
||||
income_statement.warm_caches!
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -163,7 +163,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
|
|||
category: txn.category&.name,
|
||||
merchant: txn.merchant&.name,
|
||||
tags: txn.tags.map(&:name),
|
||||
is_transfer: txn.transfer.present?
|
||||
is_transfer: txn.transfer?
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -91,6 +91,7 @@ class Family < ApplicationRecord
|
|||
entries.order(:date).first&.date || Date.current
|
||||
end
|
||||
|
||||
# Used for invalidating family / balance sheet related aggregation queries
|
||||
def build_cache_key(key, invalidate_on_data_updates: false)
|
||||
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
|
||||
# By including it in the cache key, we can expire caches every time family account data changes.
|
||||
|
@ -103,6 +104,14 @@ class Family < ApplicationRecord
|
|||
].compact.join("_")
|
||||
end
|
||||
|
||||
# Used for invalidating entry related aggregation queries
|
||||
def entries_cache_version
|
||||
@entries_cache_version ||= begin
|
||||
ts = entries.maximum(:updated_at)
|
||||
ts.present? ? ts.to_i : 0
|
||||
end
|
||||
end
|
||||
|
||||
def self_hoster?
|
||||
Rails.application.config.app_mode.self_hosted?
|
||||
end
|
||||
|
|
|
@ -53,6 +53,9 @@ module Family::AutoTransferMatchable
|
|||
outflow_transaction_id: match.outflow_transaction_id,
|
||||
)
|
||||
|
||||
Transaction.find(match.inflow_transaction_id).update!(kind: "funds_movement")
|
||||
Transaction.find(match.outflow_transaction_id).update!(kind: Transfer.kind_for_account(Transaction.find(match.outflow_transaction_id).entry.account))
|
||||
|
||||
used_transaction_ids << match.inflow_transaction_id
|
||||
used_transaction_ids << match.outflow_transaction_id
|
||||
end
|
||||
|
|
|
@ -20,8 +20,7 @@ class IncomeStatement
|
|||
ScopeTotals.new(
|
||||
transactions_count: result.sum(&:transactions_count),
|
||||
income_money: Money.new(total_income, family.currency),
|
||||
expense_money: Money.new(total_expense, family.currency),
|
||||
missing_exchange_rates?: result.any?(&:missing_exchange_rates?)
|
||||
expense_money: Money.new(total_expense, family.currency)
|
||||
)
|
||||
end
|
||||
|
||||
|
@ -53,16 +52,9 @@ class IncomeStatement
|
|||
family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0
|
||||
end
|
||||
|
||||
def warm_caches!(interval: "month")
|
||||
totals
|
||||
family_stats(interval: interval)
|
||||
category_stats(interval: interval)
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?)
|
||||
PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals)
|
||||
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money)
|
||||
PeriodTotal = Data.define(:classification, :total, :currency, :category_totals)
|
||||
CategoryTotal = Data.define(:category, :total, :currency, :weight)
|
||||
|
||||
def categories
|
||||
|
@ -102,7 +94,6 @@ class IncomeStatement
|
|||
classification: classification,
|
||||
total: category_totals.reject { |ct| ct.category.subcategory? }.sum(&:total),
|
||||
currency: family.currency,
|
||||
missing_exchange_rates?: totals.any?(&:missing_exchange_rates?),
|
||||
category_totals: category_totals
|
||||
)
|
||||
end
|
||||
|
@ -110,14 +101,14 @@ class IncomeStatement
|
|||
def family_stats(interval: "month")
|
||||
@family_stats ||= {}
|
||||
@family_stats[interval] ||= Rails.cache.fetch([
|
||||
"income_statement", "family_stats", family.id, interval, entries_cache_version
|
||||
"income_statement", "family_stats", family.id, interval, family.entries_cache_version
|
||||
]) { FamilyStats.new(family, interval:).call }
|
||||
end
|
||||
|
||||
def category_stats(interval: "month")
|
||||
@category_stats ||= {}
|
||||
@category_stats[interval] ||= Rails.cache.fetch([
|
||||
"income_statement", "category_stats", family.id, interval, entries_cache_version
|
||||
"income_statement", "category_stats", family.id, interval, family.entries_cache_version
|
||||
]) { CategoryStats.new(family, interval:).call }
|
||||
end
|
||||
|
||||
|
@ -125,24 +116,11 @@ class IncomeStatement
|
|||
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
|
||||
|
||||
Rails.cache.fetch([
|
||||
"income_statement", "totals_query", family.id, sql_hash, entries_cache_version
|
||||
"income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
|
||||
]) { Totals.new(family, transactions_scope: transactions_scope).call }
|
||||
end
|
||||
|
||||
def monetizable_currency
|
||||
family.currency
|
||||
end
|
||||
|
||||
# Returns a monotonically increasing integer based on the most recent
|
||||
# update to any Entry that belongs to the family. Incorporated into cache
|
||||
# keys so they expire automatically on data changes.
|
||||
def entries_cache_version
|
||||
@entries_cache_version ||= begin
|
||||
ts = Entry.joins(:account)
|
||||
.where(accounts: { family_id: family.id })
|
||||
.maximum(:updated_at)
|
||||
|
||||
ts.present? ? ts.to_i : 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
module IncomeStatement::BaseQuery
|
||||
private
|
||||
def base_query_sql(family:, interval:, transactions_scope:)
|
||||
sql = <<~SQL
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
c.parent_id as parent_category_id,
|
||||
date_trunc(:interval, ae.date) as date,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
SUM(ae.amount * COALESCE(er.rate, 1)) as total,
|
||||
COUNT(ae.id) as transactions_count,
|
||||
BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates
|
||||
FROM (#{transactions_scope.to_sql}) at
|
||||
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
||||
LEFT JOIN categories c ON c.id = at.category_id
|
||||
LEFT JOIN (
|
||||
SELECT t.*, t.id as transfer_id, a.accountable_type
|
||||
FROM transfers t
|
||||
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
|
||||
AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
) transfer_info ON (
|
||||
transfer_info.inflow_transaction_id = at.id OR
|
||||
transfer_info.outflow_transaction_id = at.id
|
||||
)
|
||||
LEFT JOIN exchange_rates er ON (
|
||||
er.date = ae.date AND
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE (
|
||||
transfer_info.transfer_id IS NULL OR
|
||||
(ae.amount > 0 AND transfer_info.accountable_type = 'Loan')
|
||||
)
|
||||
GROUP BY 1, 2, 3, 4
|
||||
SQL
|
||||
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
sql,
|
||||
{ target_currency: family.currency, interval: interval }
|
||||
])
|
||||
end
|
||||
end
|
|
@ -1,40 +1,61 @@
|
|||
class IncomeStatement::CategoryStats
|
||||
include IncomeStatement::BaseQuery
|
||||
|
||||
def initialize(family, interval: "month")
|
||||
@family = family
|
||||
@interval = interval
|
||||
end
|
||||
|
||||
def call
|
||||
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
|
||||
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
|
||||
StatRow.new(
|
||||
category_id: row["category_id"],
|
||||
classification: row["classification"],
|
||||
median: row["median"],
|
||||
avg: row["avg"],
|
||||
missing_exchange_rates?: row["missing_exchange_rates"]
|
||||
avg: row["avg"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
StatRow = Data.define(:category_id, :classification, :median, :avg, :missing_exchange_rates?)
|
||||
StatRow = Data.define(:category_id, :classification, :median, :avg)
|
||||
|
||||
def sanitized_query_sql
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
query_sql,
|
||||
{
|
||||
target_currency: @family.currency,
|
||||
interval: @interval,
|
||||
family_id: @family.id
|
||||
}
|
||||
])
|
||||
end
|
||||
|
||||
def query_sql
|
||||
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
|
||||
|
||||
<<~SQL
|
||||
WITH base_totals AS (
|
||||
#{base_sql}
|
||||
WITH period_totals AS (
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
date_trunc(:interval, ae.date) as period,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
SUM(ae.amount * COALESCE(er.rate, 1)) as total
|
||||
FROM transactions t
|
||||
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
LEFT JOIN exchange_rates er ON (
|
||||
er.date = ae.date AND
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE a.family_id = :family_id
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
)
|
||||
SELECT
|
||||
category_id,
|
||||
classification,
|
||||
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
||||
ABS(AVG(total)) as avg,
|
||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
|
||||
FROM base_totals
|
||||
category_id,
|
||||
classification,
|
||||
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
||||
ABS(AVG(total)) as avg
|
||||
FROM period_totals
|
||||
GROUP BY category_id, classification;
|
||||
SQL
|
||||
end
|
||||
|
|
|
@ -1,46 +1,57 @@
|
|||
class IncomeStatement::FamilyStats
|
||||
include IncomeStatement::BaseQuery
|
||||
|
||||
def initialize(family, interval: "month")
|
||||
@family = family
|
||||
@interval = interval
|
||||
end
|
||||
|
||||
def call
|
||||
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
|
||||
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
|
||||
StatRow.new(
|
||||
classification: row["classification"],
|
||||
median: row["median"],
|
||||
avg: row["avg"],
|
||||
missing_exchange_rates?: row["missing_exchange_rates"]
|
||||
avg: row["avg"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
StatRow = Data.define(:classification, :median, :avg, :missing_exchange_rates?)
|
||||
StatRow = Data.define(:classification, :median, :avg)
|
||||
|
||||
def sanitized_query_sql
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
query_sql,
|
||||
{
|
||||
target_currency: @family.currency,
|
||||
interval: @interval,
|
||||
family_id: @family.id
|
||||
}
|
||||
])
|
||||
end
|
||||
|
||||
def query_sql
|
||||
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
|
||||
|
||||
<<~SQL
|
||||
WITH base_totals AS (
|
||||
#{base_sql}
|
||||
), aggregated_totals AS (
|
||||
WITH period_totals AS (
|
||||
SELECT
|
||||
date,
|
||||
classification,
|
||||
SUM(total) as total,
|
||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
|
||||
FROM base_totals
|
||||
GROUP BY date, classification
|
||||
date_trunc(:interval, ae.date) as period,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
SUM(ae.amount * COALESCE(er.rate, 1)) as total
|
||||
FROM transactions t
|
||||
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
LEFT JOIN exchange_rates er ON (
|
||||
er.date = ae.date AND
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE a.family_id = :family_id
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
)
|
||||
SELECT
|
||||
classification,
|
||||
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
||||
ABS(AVG(total)) as avg,
|
||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
|
||||
FROM aggregated_totals
|
||||
classification,
|
||||
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
|
||||
ABS(AVG(total)) as avg
|
||||
FROM period_totals
|
||||
GROUP BY classification;
|
||||
SQL
|
||||
end
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
class IncomeStatement::Totals
|
||||
include IncomeStatement::BaseQuery
|
||||
|
||||
def initialize(family, transactions_scope:)
|
||||
@family = family
|
||||
@transactions_scope = transactions_scope
|
||||
|
@ -13,31 +11,47 @@ class IncomeStatement::Totals
|
|||
category_id: row["category_id"],
|
||||
classification: row["classification"],
|
||||
total: row["total"],
|
||||
transactions_count: row["transactions_count"],
|
||||
missing_exchange_rates?: row["missing_exchange_rates"]
|
||||
transactions_count: row["transactions_count"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :missing_exchange_rates?)
|
||||
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count)
|
||||
|
||||
def query_sql
|
||||
base_sql = base_query_sql(family: @family, interval: "day", transactions_scope: @transactions_scope)
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
optimized_query_sql,
|
||||
sql_params
|
||||
])
|
||||
end
|
||||
|
||||
# OPTIMIZED: Direct SUM aggregation without unnecessary time bucketing
|
||||
# Eliminates CTE and intermediate date grouping for maximum performance
|
||||
def optimized_query_sql
|
||||
<<~SQL
|
||||
WITH base_totals AS (
|
||||
#{base_sql}
|
||||
)
|
||||
SELECT
|
||||
parent_category_id,
|
||||
category_id,
|
||||
classification,
|
||||
ABS(SUM(total)) as total,
|
||||
BOOL_OR(missing_exchange_rates) as missing_exchange_rates,
|
||||
SUM(transactions_count) as transactions_count
|
||||
FROM base_totals
|
||||
GROUP BY 1, 2, 3;
|
||||
c.id as category_id,
|
||||
c.parent_id as parent_category_id,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
||||
COUNT(ae.id) as transactions_count
|
||||
FROM (#{@transactions_scope.to_sql}) at
|
||||
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
||||
LEFT JOIN categories c ON c.id = at.category_id
|
||||
LEFT JOIN exchange_rates er ON (
|
||||
er.date = ae.date AND
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
|
||||
SQL
|
||||
end
|
||||
|
||||
def sql_params
|
||||
{
|
||||
target_currency: @family.currency
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
113
app/models/trade/create_form.rb
Normal file
113
app/models/trade/create_form.rb
Normal file
|
@ -0,0 +1,113 @@
|
|||
class Trade::CreateForm
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
|
||||
# Either creates a trade, transaction, or transfer based on type
|
||||
# Returns the model, regardless of success or failure
|
||||
def create
|
||||
case type
|
||||
when "buy", "sell"
|
||||
create_trade
|
||||
when "interest"
|
||||
create_interest_income
|
||||
when "deposit", "withdrawal"
|
||||
create_transfer
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
|
||||
def security
|
||||
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
||||
|
||||
Security::Resolver.new(
|
||||
ticker_symbol,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
).resolve
|
||||
end
|
||||
|
||||
def create_trade
|
||||
prefix = type == "sell" ? "Sell " : "Buy "
|
||||
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
||||
signed_qty = type == "sell" ? -qty.to_d : qty.to_d
|
||||
signed_amount = signed_qty * price.to_d
|
||||
|
||||
trade_entry = account.entries.new(
|
||||
name: trade_name,
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Trade.new(
|
||||
qty: signed_qty,
|
||||
price: price,
|
||||
currency: currency,
|
||||
security: security
|
||||
)
|
||||
)
|
||||
|
||||
if trade_entry.save
|
||||
trade_entry.lock_saved_attributes!
|
||||
account.sync_later
|
||||
end
|
||||
|
||||
trade_entry
|
||||
end
|
||||
|
||||
def create_interest_income
|
||||
signed_amount = amount.to_d * -1
|
||||
|
||||
entry = account.entries.build(
|
||||
name: "Interest payment",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
if entry.save
|
||||
entry.lock_saved_attributes!
|
||||
account.sync_later
|
||||
end
|
||||
|
||||
entry
|
||||
end
|
||||
|
||||
def create_transfer
|
||||
if transfer_account_id.present?
|
||||
from_account_id = type == "withdrawal" ? account.id : transfer_account_id
|
||||
to_account_id = type == "withdrawal" ? transfer_account_id : account.id
|
||||
|
||||
Transfer::Creator.new(
|
||||
family: account.family,
|
||||
source_account_id: from_account_id,
|
||||
destination_account_id: to_account_id,
|
||||
date: date,
|
||||
amount: amount
|
||||
).create
|
||||
else
|
||||
create_unlinked_transfer
|
||||
end
|
||||
end
|
||||
|
||||
# If user doesn't provide the reciprocal account, it's a regular transaction
|
||||
def create_unlinked_transfer
|
||||
signed_amount = type == "deposit" ? amount.to_d * -1 : amount.to_d
|
||||
|
||||
entry = account.entries.build(
|
||||
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
if entry.save
|
||||
entry.lock_saved_attributes!
|
||||
account.sync_later
|
||||
end
|
||||
|
||||
entry
|
||||
end
|
||||
end
|
|
@ -1,137 +0,0 @@
|
|||
class TradeBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
|
||||
attr_reader :buildable
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
@buildable = set_buildable
|
||||
end
|
||||
|
||||
def save
|
||||
buildable.save
|
||||
end
|
||||
|
||||
def lock_saved_attributes!
|
||||
if buildable.is_a?(Transfer)
|
||||
buildable.inflow_transaction.entry.lock_saved_attributes!
|
||||
buildable.outflow_transaction.entry.lock_saved_attributes!
|
||||
else
|
||||
buildable.lock_saved_attributes!
|
||||
end
|
||||
end
|
||||
|
||||
def entryable
|
||||
return nil if buildable.is_a?(Transfer)
|
||||
|
||||
buildable.entryable
|
||||
end
|
||||
|
||||
def errors
|
||||
buildable.errors
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
buildable.sync_account_later
|
||||
end
|
||||
|
||||
private
|
||||
def set_buildable
|
||||
case type
|
||||
when "buy", "sell"
|
||||
build_trade
|
||||
when "deposit", "withdrawal"
|
||||
build_transfer
|
||||
when "interest"
|
||||
build_interest
|
||||
else
|
||||
raise "Unknown trade type: #{type}"
|
||||
end
|
||||
end
|
||||
|
||||
def build_trade
|
||||
prefix = type == "sell" ? "Sell " : "Buy "
|
||||
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
||||
|
||||
account.entries.new(
|
||||
name: trade_name,
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Trade.new(
|
||||
qty: signed_qty,
|
||||
price: price,
|
||||
currency: currency,
|
||||
security: security
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def build_transfer
|
||||
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
|
||||
|
||||
if transfer_account
|
||||
from_account = type == "withdrawal" ? account : transfer_account
|
||||
to_account = type == "withdrawal" ? transfer_account : account
|
||||
|
||||
Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: date,
|
||||
amount: signed_amount
|
||||
)
|
||||
else
|
||||
account.entries.build(
|
||||
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def build_interest
|
||||
account.entries.build(
|
||||
name: "Interest payment",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
end
|
||||
|
||||
def signed_qty
|
||||
return nil unless type.in?([ "buy", "sell" ])
|
||||
|
||||
type == "sell" ? -qty.to_d : qty.to_d
|
||||
end
|
||||
|
||||
def signed_amount
|
||||
case type
|
||||
when "buy", "sell"
|
||||
signed_qty * price.to_d
|
||||
when "deposit", "withdrawal"
|
||||
type == "deposit" ? -amount.to_d : amount.to_d
|
||||
when "interest"
|
||||
amount.to_d * -1
|
||||
end
|
||||
end
|
||||
|
||||
def family
|
||||
account.family
|
||||
end
|
||||
|
||||
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
|
||||
def security
|
||||
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
||||
|
||||
Security::Resolver.new(
|
||||
ticker_symbol,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
).resolve
|
||||
end
|
||||
end
|
|
@ -9,10 +9,17 @@ class Transaction < ApplicationRecord
|
|||
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
Search.new(params).build_query(all)
|
||||
end
|
||||
enum :kind, {
|
||||
standard: "standard", # A regular transaction, included in budget analytics
|
||||
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
|
||||
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
|
||||
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
|
||||
one_time: "one_time" # A one-time expense/income, excluded from budget analytics
|
||||
}
|
||||
|
||||
# Overarching grouping method for all transfer-type transactions
|
||||
def transfer?
|
||||
funds_movement? || cc_payment? || loan_payment?
|
||||
end
|
||||
|
||||
def set_category!(category)
|
||||
|
|
|
@ -13,37 +13,88 @@ class Transaction::Search
|
|||
attribute :categories, array: true
|
||||
attribute :merchants, array: true
|
||||
attribute :tags, array: true
|
||||
attribute :active_accounts_only, :boolean, default: true
|
||||
attribute :excluded_transactions, :boolean, default: false
|
||||
|
||||
def build_query(scope)
|
||||
query = scope.joins(entry: :account)
|
||||
.joins(transfer_join)
|
||||
attr_reader :family
|
||||
|
||||
query = apply_category_filter(query, categories)
|
||||
query = apply_type_filter(query, types)
|
||||
query = apply_merchant_filter(query, merchants)
|
||||
query = apply_tag_filter(query, tags)
|
||||
query = EntrySearch.apply_search_filter(query, search)
|
||||
query = EntrySearch.apply_date_filters(query, start_date, end_date)
|
||||
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
|
||||
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
|
||||
def initialize(family, filters: {})
|
||||
@family = family
|
||||
super(filters)
|
||||
end
|
||||
|
||||
query
|
||||
def transactions_scope
|
||||
@transactions_scope ||= begin
|
||||
# This already joins entries + accounts. To avoid expensive double-joins, don't join them again (causes full table scan)
|
||||
query = family.transactions
|
||||
|
||||
query = apply_active_accounts_filter(query, active_accounts_only)
|
||||
query = apply_excluded_transactions_filter(query, excluded_transactions)
|
||||
query = apply_category_filter(query, categories)
|
||||
query = apply_type_filter(query, types)
|
||||
query = apply_merchant_filter(query, merchants)
|
||||
query = apply_tag_filter(query, tags)
|
||||
query = EntrySearch.apply_search_filter(query, search)
|
||||
query = EntrySearch.apply_date_filters(query, start_date, end_date)
|
||||
query = EntrySearch.apply_amount_filter(query, amount, amount_operator)
|
||||
query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)
|
||||
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
# Computes totals for the specific search
|
||||
def totals
|
||||
@totals ||= begin
|
||||
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
|
||||
result = transactions_scope
|
||||
.select(
|
||||
"COALESCE(SUM(CASE WHEN entries.amount >= 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount < 0 THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
|
||||
"COUNT(entries.id) as transactions_count"
|
||||
)
|
||||
.joins(
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
"LEFT JOIN exchange_rates er ON (er.date = entries.date AND er.from_currency = entries.currency AND er.to_currency = ?)",
|
||||
family.currency
|
||||
])
|
||||
)
|
||||
.take
|
||||
|
||||
Totals.new(
|
||||
count: result.transactions_count.to_i,
|
||||
income_money: Money.new(result.income_total.to_i, family.currency),
|
||||
expense_money: Money.new(result.expense_total.to_i, family.currency)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def cache_key_base
|
||||
[
|
||||
family.id,
|
||||
Digest::SHA256.hexdigest(attributes.sort.to_h.to_json), # cached by filters
|
||||
family.entries_cache_version
|
||||
].join("/")
|
||||
end
|
||||
|
||||
private
|
||||
def transfer_join
|
||||
<<~SQL
|
||||
LEFT JOIN (
|
||||
SELECT t.*, t.id as transfer_id, a.accountable_type
|
||||
FROM transfers t
|
||||
JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id
|
||||
AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
) transfer_info ON (
|
||||
transfer_info.inflow_transaction_id = transactions.id OR
|
||||
transfer_info.outflow_transaction_id = transactions.id
|
||||
)
|
||||
SQL
|
||||
Totals = Data.define(:count, :income_money, :expense_money)
|
||||
|
||||
def apply_active_accounts_filter(query, active_accounts_only_filter)
|
||||
if active_accounts_only_filter
|
||||
query.where(accounts: { is_active: true })
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
def apply_excluded_transactions_filter(query, excluded_transactions_filter)
|
||||
unless excluded_transactions_filter
|
||||
query.where(entries: { excluded: false })
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
def apply_category_filter(query, categories)
|
||||
|
@ -51,7 +102,7 @@ class Transaction::Search
|
|||
|
||||
query = query.left_joins(:category).where(
|
||||
"categories.name IN (?) OR (
|
||||
categories.id IS NULL AND (transfer_info.transfer_id IS NULL OR transfer_info.accountable_type = 'Loan')
|
||||
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
|
||||
)",
|
||||
categories
|
||||
)
|
||||
|
@ -67,7 +118,7 @@ class Transaction::Search
|
|||
return query unless types.present?
|
||||
return query if types.sort == [ "expense", "income", "transfer" ]
|
||||
|
||||
transfer_condition = "transfer_info.transfer_id IS NOT NULL"
|
||||
transfer_condition = "transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment')"
|
||||
expense_condition = "entries.amount >= 0"
|
||||
income_condition = "entries.amount <= 0"
|
||||
|
||||
|
|
|
@ -14,10 +14,6 @@ module Transaction::Transferable
|
|||
transfer_as_inflow || transfer_as_outflow
|
||||
end
|
||||
|
||||
def transfer?
|
||||
transfer.present?
|
||||
end
|
||||
|
||||
def transfer_match_candidates
|
||||
candidates_scope = if self.entry.amount.negative?
|
||||
family_matches_scope.where("inflow_candidates.entryable_id = ?", self.id)
|
||||
|
|
|
@ -13,34 +13,14 @@ class Transfer < ApplicationRecord
|
|||
validate :transfer_has_same_family
|
||||
|
||||
class << self
|
||||
def from_accounts(from_account:, to_account:, date:, amount:)
|
||||
# Attempt to convert the amount to the to_account's currency.
|
||||
# If the conversion fails, use the original amount.
|
||||
converted_amount = begin
|
||||
Money.new(amount.abs, from_account.currency).exchange_to(to_account.currency)
|
||||
rescue Money::ConversionError
|
||||
Money.new(amount.abs, from_account.currency)
|
||||
def kind_for_account(account)
|
||||
if account.loan?
|
||||
"loan_payment"
|
||||
elsif account.liability?
|
||||
"cc_payment"
|
||||
else
|
||||
"funds_movement"
|
||||
end
|
||||
|
||||
new(
|
||||
inflow_transaction: Transaction.new(
|
||||
entry: to_account.entries.build(
|
||||
amount: converted_amount.amount.abs * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: "Transfer from #{from_account.name}",
|
||||
)
|
||||
),
|
||||
outflow_transaction: Transaction.new(
|
||||
entry: from_account.entries.build(
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
)
|
||||
),
|
||||
status: "confirmed"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -51,19 +31,28 @@ class Transfer < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
# Once transfer is destroyed, we need to mark the denormalized kind fields on the transactions
|
||||
def destroy!
|
||||
Transfer.transaction do
|
||||
inflow_transaction.update!(kind: "standard")
|
||||
outflow_transaction.update!(kind: "standard")
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def confirm!
|
||||
update!(status: "confirmed")
|
||||
end
|
||||
|
||||
def date
|
||||
inflow_transaction.entry.date
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
inflow_transaction&.entry&.sync_account_later
|
||||
outflow_transaction&.entry&.sync_account_later
|
||||
end
|
||||
|
||||
def belongs_to_family?(family)
|
||||
family.transactions.include?(inflow_transaction)
|
||||
end
|
||||
|
||||
def to_account
|
||||
inflow_transaction&.entry&.account
|
||||
end
|
||||
|
@ -89,6 +78,24 @@ class Transfer < ApplicationRecord
|
|||
to_account&.liability?
|
||||
end
|
||||
|
||||
def loan_payment?
|
||||
outflow_transaction&.kind == "loan_payment"
|
||||
end
|
||||
|
||||
def liability_payment?
|
||||
outflow_transaction&.kind == "cc_payment"
|
||||
end
|
||||
|
||||
def regular_transfer?
|
||||
outflow_transaction&.kind == "funds_movement"
|
||||
end
|
||||
|
||||
def transfer_type
|
||||
return "loan_payment" if loan_payment?
|
||||
return "liability_payment" if liability_payment?
|
||||
"transfer"
|
||||
end
|
||||
|
||||
def categorizable?
|
||||
to_account&.accountable_type == "Loan"
|
||||
end
|
||||
|
|
85
app/models/transfer/creator.rb
Normal file
85
app/models/transfer/creator.rb
Normal file
|
@ -0,0 +1,85 @@
|
|||
class Transfer::Creator
|
||||
def initialize(family:, source_account_id:, destination_account_id:, date:, amount:)
|
||||
@family = family
|
||||
@source_account = family.accounts.find(source_account_id) # early throw if not found
|
||||
@destination_account = family.accounts.find(destination_account_id) # early throw if not found
|
||||
@date = date
|
||||
@amount = amount.to_d
|
||||
end
|
||||
|
||||
def create
|
||||
transfer = Transfer.new(
|
||||
inflow_transaction: inflow_transaction,
|
||||
outflow_transaction: outflow_transaction,
|
||||
status: "confirmed"
|
||||
)
|
||||
|
||||
if transfer.save
|
||||
source_account.sync_later
|
||||
destination_account.sync_later
|
||||
end
|
||||
|
||||
transfer
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :family, :source_account, :destination_account, :date, :amount
|
||||
|
||||
def outflow_transaction
|
||||
name = "#{name_prefix} to #{destination_account.name}"
|
||||
|
||||
Transaction.new(
|
||||
kind: outflow_transaction_kind,
|
||||
entry: source_account.entries.build(
|
||||
amount: amount.abs,
|
||||
currency: source_account.currency,
|
||||
date: date,
|
||||
name: name,
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def inflow_transaction
|
||||
name = "#{name_prefix} from #{source_account.name}"
|
||||
|
||||
Transaction.new(
|
||||
kind: "funds_movement",
|
||||
entry: destination_account.entries.build(
|
||||
amount: inflow_converted_money.amount.abs * -1,
|
||||
currency: destination_account.currency,
|
||||
date: date,
|
||||
name: name,
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# If destination account has different currency, its transaction should show up as converted
|
||||
# Future improvement: instead of a 1:1 conversion fallback, add a UI/UX flow for missing rates
|
||||
def inflow_converted_money
|
||||
Money.new(amount.abs, source_account.currency)
|
||||
.exchange_to(
|
||||
destination_account.currency,
|
||||
date: date,
|
||||
fallback_rate: 1.0
|
||||
)
|
||||
end
|
||||
|
||||
# The "expense" side of a transfer is treated different in analytics based on where it goes.
|
||||
def outflow_transaction_kind
|
||||
if destination_account.loan?
|
||||
"loan_payment"
|
||||
elsif destination_account.liability?
|
||||
"cc_payment"
|
||||
else
|
||||
"funds_movement"
|
||||
end
|
||||
end
|
||||
|
||||
def name_prefix
|
||||
if destination_account.liability?
|
||||
"Payment"
|
||||
else
|
||||
"Transfer"
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue