mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-24 15:49:39 +02:00
Account::Entry Delegated Type (namespace updates part 7) (#923)
* Initial entryable models * Update transfer and tests * Update transaction controllers and tests * Update sync process to use new entries model * Get dashboard working again * Update transfers, imports, and accounts to use Account::Entry * Update system tests * Consolidate transaction management into entries controller * Add permitted partial key helper * Move account transactions list to entries controller * Delegate transaction entries search * Move transfer relation to entry * Update bulk transaction management flows to use entries * Remove test code * Test fix attempt * Update demo data script * Consolidate remaining transaction partials to entries * Consolidate valuations controller to entries controller * Lint fix * Remove unused files, additional cleanup * Add back valuation creation * Make migrations fully reversible * Stale routes cleanup * Migrations reversible fix * Move types to entryable concern * Fix search when no entries found * Remove more unused code
This commit is contained in:
parent
320954282a
commit
c3314e62d1
105 changed files with 2150 additions and 1576 deletions
|
@ -8,14 +8,17 @@ class Account < ApplicationRecord
|
|||
|
||||
belongs_to :family
|
||||
belongs_to :institution, optional: true
|
||||
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
||||
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :valuations, dependent: :destroy
|
||||
has_many :transactions, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
|
||||
monetize :balance
|
||||
|
||||
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
|
||||
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
scope :assets, -> { where(classification: "asset") }
|
||||
|
@ -35,8 +38,7 @@ class Account < ApplicationRecord
|
|||
|
||||
# e.g. Wise, Revolut accounts that have transactions in multiple currencies
|
||||
def multi_currency?
|
||||
currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq
|
||||
currencies.count > 1
|
||||
entries.select(:currency).distinct.count > 1
|
||||
end
|
||||
|
||||
# e.g. Accounts denominated in currency other than family currency
|
||||
|
@ -44,16 +46,6 @@ class Account < ApplicationRecord
|
|||
currency != family.currency
|
||||
end
|
||||
|
||||
def self.by_provider
|
||||
# TODO: When 3rd party providers are supported, dynamically load all providers and their accounts
|
||||
[ { name: "Manual accounts", accounts: all.order(balance: :desc).group_by(&:accountable_type) } ]
|
||||
end
|
||||
|
||||
def self.some_syncing?
|
||||
exists?(status: "syncing")
|
||||
end
|
||||
|
||||
|
||||
def series(period: Period.all, currency: self.currency)
|
||||
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
|
||||
|
||||
|
@ -93,11 +85,19 @@ class Account < ApplicationRecord
|
|||
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
|
||||
|
||||
# Always build the initial valuation
|
||||
account.valuations.build(date: Date.current, value: attributes[:balance], currency: account.currency)
|
||||
account.entries.build \
|
||||
date: Date.current,
|
||||
amount: attributes[:balance],
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
|
||||
# Conditionally build the optional start valuation
|
||||
if start_date.present? && start_balance.present?
|
||||
account.valuations.build(date: start_date, value: start_balance, currency: account.currency)
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
account.save!
|
||||
|
|
|
@ -1,123 +1,115 @@
|
|||
class Account::Balance::Calculator
|
||||
attr_reader :daily_balances, :errors, :warnings
|
||||
attr_reader :errors, :warnings
|
||||
|
||||
def initialize(account, options = {})
|
||||
@daily_balances = []
|
||||
@errors = []
|
||||
@warnings = []
|
||||
@account = account
|
||||
@calc_start_date = [ options[:calc_start_date], @account.effective_start_date ].compact.max
|
||||
def initialize(account, options = {})
|
||||
@errors = []
|
||||
@warnings = []
|
||||
@account = account
|
||||
@calc_start_date = calculate_sync_start(options[:calc_start_date])
|
||||
end
|
||||
|
||||
def daily_balances
|
||||
@daily_balances ||= calculate_daily_balances
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :calc_start_date, :account
|
||||
|
||||
def calculate_sync_start(provided_start_date = nil)
|
||||
if account.balances.any?
|
||||
[ provided_start_date, account.effective_start_date ].compact.max
|
||||
else
|
||||
account.effective_start_date
|
||||
end
|
||||
end
|
||||
|
||||
def calculate
|
||||
prior_balance = implied_start_balance
|
||||
def calculate_daily_balances
|
||||
prior_balance = nil
|
||||
|
||||
calculated_balances = ((@calc_start_date + 1.day)..Date.current).map do |date|
|
||||
valuation = normalized_valuations.find { |v| v["date"] == date }
|
||||
calculated_balances = (calc_start_date..Date.current).map do |date|
|
||||
valuation_entry = find_valuation_entry(date)
|
||||
|
||||
if valuation
|
||||
current_balance = valuation["value"]
|
||||
if valuation_entry
|
||||
current_balance = valuation_entry.amount
|
||||
elsif prior_balance.nil?
|
||||
current_balance = implied_start_balance
|
||||
else
|
||||
txn_flows = transaction_flows(date)
|
||||
txn_entries = syncable_transaction_entries.select { |e| e.date == date }
|
||||
txn_flows = transaction_flows(txn_entries)
|
||||
current_balance = prior_balance - txn_flows
|
||||
end
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
{ date:, balance: current_balance, currency: @account.currency, updated_at: Time.current }
|
||||
{ date:, balance: current_balance, currency: account.currency, updated_at: Time.current }
|
||||
end
|
||||
|
||||
@daily_balances = [
|
||||
{ date: @calc_start_date, balance: implied_start_balance, currency: @account.currency, updated_at: Time.current },
|
||||
*calculated_balances
|
||||
]
|
||||
|
||||
if @account.foreign_currency?
|
||||
converted_balances = convert_balances_to_family_currency
|
||||
@daily_balances.concat(converted_balances)
|
||||
if account.foreign_currency?
|
||||
calculated_balances.concat(convert_balances_to_family_currency(calculated_balances))
|
||||
end
|
||||
|
||||
self
|
||||
calculated_balances
|
||||
end
|
||||
|
||||
private
|
||||
def convert_balances_to_family_currency
|
||||
rates = ExchangeRate.get_rates(
|
||||
@account.currency,
|
||||
@account.family.currency,
|
||||
@calc_start_date..Date.current
|
||||
).to_a
|
||||
def syncable_entries
|
||||
@entries ||= account.entries.where("date >= ?", calc_start_date).to_a
|
||||
end
|
||||
|
||||
# Abort conversion if some required rates are missing
|
||||
if rates.length != @daily_balances.length
|
||||
@errors << :sync_message_missing_rates
|
||||
return []
|
||||
end
|
||||
def syncable_transaction_entries
|
||||
@syncable_transaction_entries ||= syncable_entries.select { |e| e.account_transaction? }
|
||||
end
|
||||
|
||||
@daily_balances.map.with_index do |balance, index|
|
||||
converted_balance = balance[:balance] * rates[index].rate
|
||||
{ date: balance[:date], balance: converted_balance, currency: @account.family.currency, updated_at: Time.current }
|
||||
end
|
||||
def find_valuation_entry(date)
|
||||
syncable_entries.find { |entry| entry.date == date && entry.account_valuation? }
|
||||
end
|
||||
|
||||
def transaction_flows(transaction_entries)
|
||||
converted_entries = transaction_entries.map { |entry| convert_entry_to_account_currency(entry) }.compact
|
||||
flows = converted_entries.sum(&:amount)
|
||||
flows *= -1 if account.liability?
|
||||
flows
|
||||
end
|
||||
|
||||
def convert_balances_to_family_currency(balances)
|
||||
rates = ExchangeRate.get_rates(
|
||||
account.currency,
|
||||
account.family.currency,
|
||||
calc_start_date..Date.current
|
||||
).to_a
|
||||
|
||||
# Abort conversion if some required rates are missing
|
||||
if rates.length != balances.length
|
||||
@errors << :sync_message_missing_rates
|
||||
return []
|
||||
end
|
||||
|
||||
# For calculation, all transactions and valuations need to be normalized to the same currency (the account's primary currency)
|
||||
def normalize_entries_to_account_currency(entries, value_key)
|
||||
grouped_entries = entries.group_by(&:currency)
|
||||
normalized_entries = []
|
||||
balances.map.with_index do |balance, index|
|
||||
converted_balance = balance[:balance] * rates[index].rate
|
||||
{ date: balance[:date], balance: converted_balance, currency: account.family.currency, updated_at: Time.current }
|
||||
end
|
||||
end
|
||||
|
||||
grouped_entries.each do |currency, entries|
|
||||
if currency != @account.currency
|
||||
dates = entries.map(&:date).uniq
|
||||
rates = ExchangeRate.get_rates(currency, @account.currency, dates).to_a
|
||||
if rates.length != dates.length
|
||||
@errors << :sync_message_missing_rates
|
||||
else
|
||||
entries.each do |entry|
|
||||
## There can be several entries on the same date so we cannot rely on indeces
|
||||
rate = rates.find { |rate| rate.date == entry.date }
|
||||
value = entry.send(value_key)
|
||||
value *= rate.rate
|
||||
normalized_entries << entry.attributes.merge(value_key.to_s => value, "currency" => currency)
|
||||
end
|
||||
end
|
||||
else
|
||||
normalized_entries.concat(entries)
|
||||
end
|
||||
end
|
||||
# Multi-currency accounts have transactions in many currencies
|
||||
def convert_entry_to_account_currency(entry)
|
||||
return entry if entry.currency == account.currency
|
||||
|
||||
normalized_entries
|
||||
converted_entry = entry.dup
|
||||
|
||||
rate = ExchangeRate.find_rate(from: entry.currency, to: account.currency, date: entry.date)
|
||||
|
||||
unless rate
|
||||
@errors << :sync_message_missing_rates
|
||||
return nil
|
||||
end
|
||||
|
||||
def normalized_valuations
|
||||
@normalized_valuations ||= normalize_entries_to_account_currency(@account.valuations.where("date >= ?", @calc_start_date).order(:date).select(:date, :value, :currency), :value)
|
||||
end
|
||||
converted_entry.currency = account.currency
|
||||
converted_entry.amount = entry.amount * rate.rate
|
||||
converted_entry
|
||||
end
|
||||
|
||||
def normalized_transactions
|
||||
@normalized_transactions ||= normalize_entries_to_account_currency(@account.transactions.where("date >= ?", @calc_start_date).order(:date).select(:date, :amount, :currency), :amount)
|
||||
end
|
||||
|
||||
def transaction_flows(date)
|
||||
flows = normalized_transactions.select { |t| t["date"] == date }.sum { |t| t["amount"] }
|
||||
flows *= -1 if @account.classification == "liability"
|
||||
flows
|
||||
end
|
||||
|
||||
def implied_start_balance
|
||||
if @calc_start_date > @account.effective_start_date
|
||||
return @account.balance_on(@calc_start_date)
|
||||
end
|
||||
|
||||
oldest_valuation_date = normalized_valuations.first&.date
|
||||
oldest_transaction_date = normalized_transactions.first&.date
|
||||
oldest_entry_date = [ oldest_valuation_date, oldest_transaction_date ].compact.min
|
||||
|
||||
if oldest_entry_date.present? && oldest_entry_date == oldest_valuation_date
|
||||
oldest_valuation = normalized_valuations.find { |v| v["date"] == oldest_valuation_date }
|
||||
oldest_valuation["value"].to_d
|
||||
else
|
||||
net_transaction_flows = normalized_transactions.sum { |t| t["amount"].to_d }
|
||||
net_transaction_flows *= -1 if @account.classification == "liability"
|
||||
@account.balance.to_d + net_transaction_flows
|
||||
end
|
||||
end
|
||||
def implied_start_balance
|
||||
transaction_entries = syncable_transaction_entries.select { |e| e.date > calc_start_date }
|
||||
account.balance.to_d + transaction_flows(transaction_entries)
|
||||
end
|
||||
end
|
||||
|
|
196
app/models/account/entry.rb
Normal file
196
app/models/account/entry.rb
Normal file
|
@ -0,0 +1,196 @@
|
|||
class Account::Entry < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :transfer, optional: true
|
||||
|
||||
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
|
||||
accepts_nested_attributes_for :entryable
|
||||
|
||||
validates :date, :amount, :currency, presence: true
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||
|
||||
scope :chronological, -> { order(:date, :created_at) }
|
||||
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
||||
scope :with_converted_amount, ->(currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
# If no rate is available, exclude the transaction from the results
|
||||
select(
|
||||
"account_entries.*",
|
||||
"account_entries.amount * COALESCE(er.rate, 1) AS converted_amount"
|
||||
)
|
||||
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.base_currency AND er.converted_currency = ?", currency ]))
|
||||
.where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
|
||||
}
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_entry&.date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
account.sync_later(sync_start_date)
|
||||
end
|
||||
|
||||
def inflow?
|
||||
amount <= 0 && account_transaction?
|
||||
end
|
||||
|
||||
def outflow?
|
||||
amount > 0 && account_transaction?
|
||||
end
|
||||
|
||||
def first_of_type?
|
||||
first_entry = account
|
||||
.entries
|
||||
.where("entryable_type = ?", entryable_type)
|
||||
.order(:date)
|
||||
.first
|
||||
|
||||
first_entry&.id == id
|
||||
end
|
||||
|
||||
def entryable_name_short
|
||||
entryable_type.demodulize.underscore
|
||||
end
|
||||
|
||||
def trend
|
||||
@trend ||= create_trend
|
||||
end
|
||||
|
||||
class << self
|
||||
def daily_totals(entries, currency, period: Period.last_30_days)
|
||||
# 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(entries.with_converted_amount(currency), :e)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON e.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
end
|
||||
|
||||
def daily_rolling_totals(entries, currency, period: Period.last_30_days)
|
||||
# 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(entries, currency, period: period_with_rolling))
|
||||
.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 mark_transfers!
|
||||
update_all marked_as_transfer: true
|
||||
|
||||
# Attempt to "auto match" and save a transfer if 2 transactions selected
|
||||
Account::Transfer.new(entries: all).save if all.count == 2
|
||||
end
|
||||
|
||||
def bulk_update!(bulk_update_params)
|
||||
bulk_attributes = {
|
||||
date: bulk_update_params[:date],
|
||||
entryable_attributes: {
|
||||
notes: bulk_update_params[:notes],
|
||||
category_id: bulk_update_params[:category_id],
|
||||
merchant_id: bulk_update_params[:merchant_id]
|
||||
}.compact_blank
|
||||
}.compact_blank
|
||||
|
||||
return 0 if bulk_attributes.blank?
|
||||
|
||||
transaction do
|
||||
all.each do |entry|
|
||||
bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present?
|
||||
entry.update! bulk_attributes
|
||||
end
|
||||
end
|
||||
|
||||
all.size
|
||||
end
|
||||
|
||||
def income_total(currency = "USD")
|
||||
account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount <= 0")
|
||||
.where("account_entries.currency = ?", currency)
|
||||
.reject { |e| e.marked_as_transfer? }
|
||||
.sum(&:amount_money)
|
||||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount > 0")
|
||||
.where("account_entries.currency = ?", currency)
|
||||
.reject { |e| e.marked_as_transfer? }
|
||||
.sum(&:amount_money)
|
||||
end
|
||||
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.where("account_entries.name ILIKE ?", "%#{params[:search]}%") if params[:search].present?
|
||||
query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present?
|
||||
query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present?
|
||||
|
||||
if params[:accounts].present? || params[:account_ids].present?
|
||||
query = query.joins(:account)
|
||||
end
|
||||
|
||||
query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present?
|
||||
query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present?
|
||||
|
||||
# Search attributes on each entryable to further refine results
|
||||
entryable_ids = entryable_search(params)
|
||||
query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil?
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def entryable_search(params)
|
||||
entryable_ids = []
|
||||
entryable_search_performed = false
|
||||
|
||||
Account::Entryable::TYPES.map(&:constantize).each do |entryable|
|
||||
next unless entryable.requires_search?(params)
|
||||
|
||||
entryable_search_performed = true
|
||||
entryable_ids += entryable.search(params).pluck(:id)
|
||||
end
|
||||
|
||||
return nil unless entryable_search_performed
|
||||
|
||||
entryable_ids
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_entry
|
||||
@previous_entry ||= account
|
||||
.entries
|
||||
.where("date < ?", date)
|
||||
.where("entryable_type = ?", entryable_type)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def create_trend
|
||||
TimeSeries::Trend.new \
|
||||
current: amount_money,
|
||||
previous: previous_entry&.amount_money,
|
||||
favorable_direction: account.favorable_direction
|
||||
end
|
||||
end
|
13
app/models/account/entryable.rb
Normal file
13
app/models/account/entryable.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
module Account::Entryable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
TYPES = %w[ Account::Valuation Account::Transaction ]
|
||||
|
||||
def self.from_type(entryable_type)
|
||||
entryable_type.presence_in(TYPES).constantize
|
||||
end
|
||||
|
||||
included do
|
||||
has_one :entry, as: :entryable, touch: true
|
||||
end
|
||||
end
|
|
@ -8,17 +8,22 @@ module Account::Syncable
|
|||
def sync(start_date = nil)
|
||||
update!(status: "syncing")
|
||||
|
||||
sync_exchange_rates
|
||||
if multi_currency? || foreign_currency?
|
||||
sync_exchange_rates
|
||||
end
|
||||
|
||||
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: start_date })
|
||||
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
|
||||
calculator.calculate
|
||||
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
||||
self.balances.where("date < ?", effective_start_date).delete_all
|
||||
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
|
||||
|
||||
update!(status: "ok", last_sync_date: Date.today, balance: new_balance, sync_errors: calculator.errors, sync_warnings: calculator.warnings)
|
||||
update! \
|
||||
status: "ok",
|
||||
last_sync_date: Date.current,
|
||||
balance: new_balance,
|
||||
sync_errors: calculator.errors,
|
||||
sync_warnings: calculator.warnings
|
||||
rescue => e
|
||||
update!(status: "error", sync_errors: [ :sync_message_unknown_error ])
|
||||
logger.error("Failed to sync account #{id}: #{e.message}")
|
||||
|
@ -37,10 +42,7 @@ module Account::Syncable
|
|||
|
||||
# The earliest date we can calculate a balance for
|
||||
def effective_start_date
|
||||
first_valuation_date = self.valuations.order(:date).pluck(:date).first
|
||||
first_transaction_date = self.transactions.order(:date).pluck(:date).first
|
||||
|
||||
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
|
||||
@effective_start_date ||= entries.order(:date).first.try(:date) || Date.current
|
||||
end
|
||||
|
||||
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
|
||||
|
@ -48,7 +50,7 @@ module Account::Syncable
|
|||
rate_candidates = []
|
||||
|
||||
if multi_currency?
|
||||
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency = self.entries.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency.each do |currency, date|
|
||||
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
|
||||
end
|
||||
|
@ -60,6 +62,8 @@ module Account::Syncable
|
|||
end
|
||||
end
|
||||
|
||||
return if rate_candidates.blank?
|
||||
|
||||
existing_rates = ExchangeRate.where(
|
||||
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
|
|
|
@ -1,118 +1,32 @@
|
|||
class Account::Transaction < ApplicationRecord
|
||||
include Monetizable
|
||||
include Account::Entryable
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :transfer, optional: true, class_name: "Account::Transfer"
|
||||
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
|
||||
|
||||
scope :ordered, -> { order(date: :desc) }
|
||||
scope :active, -> { where(excluded: false) }
|
||||
scope :inflows, -> { where("amount <= 0") }
|
||||
scope :outflows, -> { where("amount > 0") }
|
||||
scope :by_name, ->(name) { where("account_transactions.name ILIKE ?", "%#{name}%") }
|
||||
scope :with_categories, ->(categories) { joins(:category).where(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(merchants: { name: merchants }) }
|
||||
scope :on_or_after_date, ->(date) { where("account_transactions.date >= ?", date) }
|
||||
scope :on_or_before_date, ->(date) { where("account_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
|
||||
select(
|
||||
"account_transactions.*",
|
||||
"account_transactions.amount * COALESCE(er.rate, 1) AS converted_amount"
|
||||
)
|
||||
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_transactions.date = er.date AND account_transactions.currency = er.base_currency AND er.converted_currency = ?", currency ]))
|
||||
.where("er.rate IS NOT NULL OR account_transactions.currency = ?", currency)
|
||||
}
|
||||
|
||||
def inflow?
|
||||
amount <= 0
|
||||
end
|
||||
|
||||
def outflow?
|
||||
amount > 0
|
||||
end
|
||||
|
||||
def transfer?
|
||||
marked_as_transfer
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_transaction_date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
account.sync_later(sync_start_date)
|
||||
end
|
||||
|
||||
class << self
|
||||
def income_total(currency = "USD")
|
||||
inflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money)
|
||||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
outflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money)
|
||||
end
|
||||
|
||||
def mark_transfers!
|
||||
update_all marked_as_transfer: true
|
||||
|
||||
# Attempt to "auto match" and save a transfer if 2 transactions selected
|
||||
Account::Transfer.new(transactions: all).save if all.count == 2
|
||||
end
|
||||
|
||||
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).where(marked_as_transfer: false), :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.includes(:transfer)
|
||||
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 = all
|
||||
query = query.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id").where(categories: { name: params[:categories] }) if params[:categories].present?
|
||||
query = query.joins("LEFT JOIN merchants ON merchants.id = account_transactions.merchant_id").where(merchants: { name: params[:merchants] }) if params[:merchants].present?
|
||||
query
|
||||
end
|
||||
|
||||
def requires_search?(params)
|
||||
searchable_keys.any? { |key| params.key?(key) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def searchable_keys
|
||||
%i[ categories merchants ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -1,21 +1,42 @@
|
|||
class Account::Transfer < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify
|
||||
has_many :entries, dependent: :nullify
|
||||
|
||||
validate :net_zero_flows, if: :single_currency_transfer?
|
||||
validate :transaction_count, :from_different_accounts, :all_transactions_marked
|
||||
|
||||
def date
|
||||
outflow_transaction&.date
|
||||
end
|
||||
|
||||
def amount_money
|
||||
entries.first&.amount_money&.abs
|
||||
end
|
||||
|
||||
def from_name
|
||||
outflow_transaction&.account&.name
|
||||
end
|
||||
|
||||
def to_name
|
||||
inflow_transaction&.account&.name
|
||||
end
|
||||
|
||||
def name
|
||||
return nil unless from_name && to_name
|
||||
I18n.t("account.transfer.name", from_account: from_name, to_account: to_name)
|
||||
end
|
||||
|
||||
def inflow_transaction
|
||||
transactions.find { |t| t.inflow? }
|
||||
entries.find { |e| e.inflow? }
|
||||
end
|
||||
|
||||
def outflow_transaction
|
||||
transactions.find { |t| t.outflow? }
|
||||
entries.find { |e| e.outflow? }
|
||||
end
|
||||
|
||||
def destroy_and_remove_marks!
|
||||
transaction do
|
||||
transactions.each do |t|
|
||||
t.update! marked_as_transfer: false
|
||||
entries.each do |e|
|
||||
e.update! marked_as_transfer: false
|
||||
end
|
||||
|
||||
destroy!
|
||||
|
@ -24,39 +45,52 @@ class Account::Transfer < ApplicationRecord
|
|||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
|
||||
outflow = from_account.transactions.build(amount: amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
|
||||
inflow = to_account.transactions.build(amount: -amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
|
||||
outflow = from_account.entries.build \
|
||||
amount: amount.abs,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: name,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
new transactions: [ outflow, inflow ]
|
||||
inflow = to_account.entries.build \
|
||||
amount: amount.abs * -1,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: name,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
new entries: [ outflow, inflow ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def single_currency_transfer?
|
||||
transactions.map(&:currency).uniq.size == 1
|
||||
entries.map { |e| e.currency }.uniq.size == 1
|
||||
end
|
||||
|
||||
def transaction_count
|
||||
unless transactions.size == 2
|
||||
errors.add :transactions, "must have exactly 2 transactions"
|
||||
unless entries.size == 2
|
||||
errors.add :entries, "must have exactly 2 entries"
|
||||
end
|
||||
end
|
||||
|
||||
def from_different_accounts
|
||||
accounts = transactions.map(&:account_id).uniq
|
||||
errors.add :transactions, "must be from different accounts" if accounts.size < transactions.size
|
||||
accounts = entries.map { |e| e.account_id }.uniq
|
||||
errors.add :entries, "must be from different accounts" if accounts.size < entries.size
|
||||
end
|
||||
|
||||
def net_zero_flows
|
||||
unless transactions.sum(&:amount).zero?
|
||||
unless entries.sum(&:amount).zero?
|
||||
errors.add :transactions, "must have an inflow and outflow that net to zero"
|
||||
end
|
||||
end
|
||||
|
||||
def all_transactions_marked
|
||||
unless transactions.all?(&:marked_as_transfer)
|
||||
errors.add :transactions, "must be marked as transfer"
|
||||
unless entries.all?(&:marked_as_transfer)
|
||||
errors.add :entries, "must be marked as transfer"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,48 +1,13 @@
|
|||
class Account::Valuation < ApplicationRecord
|
||||
include Monetizable
|
||||
include Account::Entryable
|
||||
|
||||
monetize :value
|
||||
|
||||
belongs_to :account
|
||||
|
||||
validates :account, :date, :value, presence: true
|
||||
validates :date, uniqueness: { scope: :account_id }
|
||||
|
||||
scope :chronological, -> { order(:date) }
|
||||
scope :reverse_chronological, -> { order(date: :desc) }
|
||||
|
||||
def trend
|
||||
@trend ||= create_trend
|
||||
end
|
||||
|
||||
def oldest?
|
||||
account.valuations.chronological.limit(1).pluck(:date).first == self.date
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_valuation&.date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
class << self
|
||||
def search(_params)
|
||||
all
|
||||
end
|
||||
|
||||
account.sync_later(sync_start_date)
|
||||
def requires_search?(_params)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_valuation
|
||||
@previous_valuation ||= self.account
|
||||
.valuations
|
||||
.where("date < ?", date)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def create_trend
|
||||
TimeSeries::Trend.new \
|
||||
current: self.value,
|
||||
previous: previous_valuation&.value,
|
||||
favorable_direction: account.favorable_direction
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,7 +6,7 @@ module Monetizable
|
|||
fields.each do |field|
|
||||
define_method("#{field}_money") do
|
||||
value = self.send(field)
|
||||
value.nil? ? nil : Money.new(value, currency)
|
||||
value.nil? ? nil : Money.new(value, currency || Money.default_currency)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,7 +3,8 @@ class Family < ApplicationRecord
|
|||
has_many :tags, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :institutions, dependent: :destroy
|
||||
has_many :transactions, through: :accounts, class_name: "Account::Transaction"
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :entries, through: :accounts
|
||||
has_many :imports, through: :accounts
|
||||
has_many :categories, dependent: :destroy
|
||||
has_many :merchants, dependent: :destroy
|
||||
|
@ -34,17 +35,18 @@ class Family < ApplicationRecord
|
|||
|
||||
def snapshot_account_transactions
|
||||
period = Period.last_30_days
|
||||
results = accounts.active.joins(:transactions)
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income"
|
||||
)
|
||||
.where("account_transactions.date >= ?", period.date_range.begin)
|
||||
.where("account_transactions.date <= ?", period.date_range.end)
|
||||
.where("account_transactions.marked_as_transfer = ?", false)
|
||||
.group("id")
|
||||
.to_a
|
||||
results = accounts.active.joins(:entries)
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-account_entries.amount) FILTER (WHERE account_entries.amount < 0), 0) AS income"
|
||||
)
|
||||
.where("account_entries.date >= ?", period.date_range.begin)
|
||||
.where("account_entries.date <= ?", period.date_range.end)
|
||||
.where("account_entries.marked_as_transfer = ?", false)
|
||||
.where("account_entries.entryable_type = ?", "Account::Transaction")
|
||||
.group("id")
|
||||
.to_a
|
||||
|
||||
results.each do |r|
|
||||
r.define_singleton_method(:savings_rate) do
|
||||
|
@ -60,7 +62,8 @@ class Family < ApplicationRecord
|
|||
end
|
||||
|
||||
def snapshot_transactions
|
||||
rolling_totals = Account::Transaction.daily_rolling_totals(transactions, period: Period.last_30_days, currency: self.currency)
|
||||
candidate_entries = entries.account_transactions.without_transfers
|
||||
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
|
||||
|
||||
spending = []
|
||||
income = []
|
||||
|
@ -89,10 +92,6 @@ class Family < ApplicationRecord
|
|||
}
|
||||
end
|
||||
|
||||
def effective_start_date
|
||||
accounts.active.joins(:balances).minimum("account_balances.date") || Date.current
|
||||
end
|
||||
|
||||
def net_worth
|
||||
assets - liabilities
|
||||
end
|
||||
|
|
|
@ -111,7 +111,7 @@ class Import < ApplicationRecord
|
|||
end
|
||||
|
||||
def generate_transactions
|
||||
transactions = []
|
||||
transaction_entries = []
|
||||
category_cache = {}
|
||||
tag_cache = {}
|
||||
|
||||
|
@ -126,18 +126,17 @@ class Import < ApplicationRecord
|
|||
|
||||
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
|
||||
|
||||
txn = account.transactions.build \
|
||||
entry = account.entries.build \
|
||||
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
||||
date: Date.iso8601(row["date"]),
|
||||
category: category,
|
||||
tags: tags,
|
||||
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
|
||||
currency: account.currency
|
||||
currency: account.currency,
|
||||
amount: BigDecimal(row["amount"]) * -1,
|
||||
entryable: Account::Transaction.new(category: category, tags: tags)
|
||||
|
||||
transactions << txn
|
||||
transaction_entries << entry
|
||||
end
|
||||
|
||||
transactions
|
||||
transaction_entries
|
||||
end
|
||||
|
||||
def create_expected_fields
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue