mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-05 13:35:21 +02:00
Transfer and Payment auto-matching, model and UI improvements (#1585)
* Transfer data model migration * Transfers and payment modeling and UI improvements * Fix CI * Transfer matching flow * Better UI for transfers * Auto transfer matching, approve, reject flow * Mark transfers created from form as confirmed * Account filtering * Excluded rejected transfers from calculations * Calculation tweaks with transfer exclusions * Clean up migration
This commit is contained in:
parent
46e129308f
commit
307a3687e8
78 changed files with 1161 additions and 682 deletions
|
@ -30,7 +30,15 @@ class Account::Entry < ApplicationRecord
|
|||
)
|
||||
}
|
||||
|
||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
||||
# All entries that are not part of a pending/approved transfer (rejected transfers count as normal entries, so are included)
|
||||
scope :incomes_and_expenses, -> {
|
||||
joins(
|
||||
'LEFT JOIN transfers AS inflow_transfers ON inflow_transfers.inflow_transaction_id = account_entries.entryable_id
|
||||
LEFT JOIN transfers AS outflow_transfers ON outflow_transfers.outflow_transaction_id = account_entries.entryable_id'
|
||||
)
|
||||
.where("(inflow_transfers.id IS NULL AND outflow_transfers.id IS NULL) OR inflow_transfers.status = 'rejected' OR outflow_transfers.status = 'rejected'")
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -59,6 +67,15 @@ class Account::Entry < ApplicationRecord
|
|||
enriched_name.presence || name
|
||||
end
|
||||
|
||||
def transfer_match_candidates
|
||||
account.family.entries
|
||||
.where.not(account_id: account_id)
|
||||
.where.not(id: id)
|
||||
.where(amount: -amount)
|
||||
.where(currency: currency)
|
||||
.where(date: (date - 4.days)..(date + 4.days))
|
||||
end
|
||||
|
||||
class << self
|
||||
def search(params)
|
||||
Account::EntrySearch.new(params).build_query(all)
|
||||
|
@ -98,13 +115,6 @@ class Account::Entry < ApplicationRecord
|
|||
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],
|
||||
|
@ -128,7 +138,7 @@ class Account::Entry < ApplicationRecord
|
|||
end
|
||||
|
||||
def income_total(currency = "USD")
|
||||
total = without_transfers.account_transactions.includes(:entryable)
|
||||
total = account_transactions.includes(:entryable).incomes_and_expenses
|
||||
.where("account_entries.amount <= 0")
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
|
@ -137,29 +147,12 @@ class Account::Entry < ApplicationRecord
|
|||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
total = without_transfers.account_transactions.includes(:entryable)
|
||||
total = account_transactions.includes(:entryable).incomes_and_expenses
|
||||
.where("account_entries.amount > 0")
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
|
||||
Money.new(total, currency)
|
||||
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
|
||||
end
|
||||
|
|
|
@ -6,8 +6,8 @@ class Account::EntrySearch
|
|||
attribute :amount, :string
|
||||
attribute :amount_operator, :string
|
||||
attribute :types, :string
|
||||
attribute :accounts, :string
|
||||
attribute :account_ids, :string
|
||||
attribute :accounts, array: true
|
||||
attribute :account_ids, array: true
|
||||
attribute :start_date, :string
|
||||
attribute :end_date, :string
|
||||
|
||||
|
@ -27,8 +27,6 @@ class Account::EntrySearch
|
|||
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
|
||||
|
||||
if types.present?
|
||||
query = query.where(marked_as_transfer: false) unless types.include?("transfer")
|
||||
|
||||
if types.include?("income") && !types.include?("expense")
|
||||
query = query.where("account_entries.amount < 0")
|
||||
elsif types.include?("expense") && !types.include?("income")
|
||||
|
|
|
@ -5,6 +5,8 @@ class Account::Syncer
|
|||
end
|
||||
|
||||
def run
|
||||
Transfer.auto_match_for_account(account)
|
||||
|
||||
holdings = sync_holdings
|
||||
balances = sync_balances(holdings)
|
||||
account.reload
|
||||
|
|
|
@ -4,6 +4,13 @@ class Account::TradeBuilder
|
|||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :type, :transfer_account_id
|
||||
|
||||
attr_reader :buildable
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
@buildable = set_buildable
|
||||
end
|
||||
|
||||
def save
|
||||
buildable.save
|
||||
end
|
||||
|
@ -17,7 +24,7 @@ class Account::TradeBuilder
|
|||
end
|
||||
|
||||
private
|
||||
def buildable
|
||||
def set_buildable
|
||||
case type
|
||||
when "buy", "sell"
|
||||
build_trade
|
||||
|
@ -55,9 +62,9 @@ class Account::TradeBuilder
|
|||
from_account = type == "withdrawal" ? account : transfer_account
|
||||
to_account = type == "withdrawal" ? transfer_account : account
|
||||
|
||||
Account::Transfer.build_from_accounts(
|
||||
from_account,
|
||||
to_account,
|
||||
Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: date,
|
||||
amount: signed_amount
|
||||
)
|
||||
|
@ -67,7 +74,6 @@ class Account::TradeBuilder
|
|||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
)
|
||||
end
|
||||
|
|
|
@ -6,6 +6,9 @@ class Account::Transaction < ApplicationRecord
|
|||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
|
||||
has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :restrict_with_exception
|
||||
has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :restrict_with_exception
|
||||
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
scope :active, -> { where(excluded: false) }
|
||||
|
@ -15,4 +18,12 @@ class Account::Transaction < ApplicationRecord
|
|||
Account::TransactionSearch.new(params).build_query(all)
|
||||
end
|
||||
end
|
||||
|
||||
def transfer
|
||||
transfer_as_inflow || transfer_as_outflow
|
||||
end
|
||||
|
||||
def transfer?
|
||||
transfer.present? && transfer.status != "rejected"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,6 +18,11 @@ class Account::TransactionSearch
|
|||
def build_query(scope)
|
||||
query = scope
|
||||
|
||||
if types.present? && types.exclude?("transfer")
|
||||
query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id")
|
||||
.where("transfers.id IS NULL")
|
||||
end
|
||||
|
||||
if categories.present?
|
||||
if categories.exclude?("Uncategorized")
|
||||
query = query
|
||||
|
|
|
@ -1,113 +0,0 @@
|
|||
class Account::Transfer < ApplicationRecord
|
||||
has_many :entries, dependent: :destroy
|
||||
|
||||
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 || Money.new(0)
|
||||
end
|
||||
|
||||
def from_name
|
||||
from_account&.name || I18n.t("account/transfer.from_fallback_name")
|
||||
end
|
||||
|
||||
def to_name
|
||||
to_account&.name || I18n.t("account/transfer.to_fallback_name")
|
||||
end
|
||||
|
||||
def name
|
||||
I18n.t("account/transfer.name", from_account: from_name, to_account: to_name)
|
||||
end
|
||||
|
||||
def from_account
|
||||
outflow_transaction&.account
|
||||
end
|
||||
|
||||
def to_account
|
||||
inflow_transaction&.account
|
||||
end
|
||||
|
||||
def inflow_transaction
|
||||
entries.find { |e| e.amount.negative? }
|
||||
end
|
||||
|
||||
def outflow_transaction
|
||||
entries.find { |e| e.amount.positive? }
|
||||
end
|
||||
|
||||
def update_entries!(params)
|
||||
transaction do
|
||||
entries.each do |entry|
|
||||
entry.update!(params)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
entries.each(&:sync_account_later)
|
||||
end
|
||||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:)
|
||||
outflow = from_account.entries.build \
|
||||
amount: amount.abs,
|
||||
currency: from_account.currency,
|
||||
date: date,
|
||||
name: "Transfer to #{to_account.name}",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
# 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)
|
||||
end
|
||||
|
||||
inflow = to_account.entries.build \
|
||||
amount: converted_amount.amount * -1,
|
||||
currency: converted_amount.currency.iso_code,
|
||||
date: date,
|
||||
name: "Transfer from #{from_account.name}",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
|
||||
new entries: [ outflow, inflow ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def single_currency_transfer?
|
||||
entries.map { |e| e.currency }.uniq.size == 1
|
||||
end
|
||||
|
||||
def transaction_count
|
||||
unless entries.size == 2
|
||||
errors.add :entries, :must_have_exactly_2_entries
|
||||
end
|
||||
end
|
||||
|
||||
def from_different_accounts
|
||||
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 entries.sum(&:amount).zero?
|
||||
errors.add :entries, :must_have_an_inflow_and_outflow_that_net_to_zero
|
||||
end
|
||||
end
|
||||
|
||||
def all_transactions_marked
|
||||
unless entries.all?(&:marked_as_transfer)
|
||||
errors.add :entries, :must_be_marked_as_transfer
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue