1
0
Fork 0
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:
Zach Gollwitzer 2025-01-07 09:41:24 -05:00 committed by GitHub
parent 46e129308f
commit 307a3687e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 1161 additions and 682 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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