1
0
Fork 0
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:
Zach Gollwitzer 2024-07-01 10:49:43 -04:00 committed by GitHub
parent 320954282a
commit c3314e62d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 2150 additions and 1576 deletions

View file

@ -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!

View file

@ -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
View 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

View 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

View file

@ -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] },

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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