1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-02 20:15:22 +02:00

Budgeting V1 (#1609)

* Budgeting V1

* Basic UI template

* Fully scaffolded budgeting v1

* Basic working budget

* Finalize donut chart for budgets

* Allow categorization of loan payments for budget

* Include loan payments in incomes_and_expenses scope

* Add budget allocations progress

* Empty states

* Clean up budget methods

* Category aggregation queries

* Handle overage scenarios in form

* Finalize budget donut chart controller

* Passing tests

* Fix allocation naming

* Add income category migration

* Native support for uncategorized budget category

* Formatting

* Fix subcategory sort order, padding

* Fix calculation for category rollups in budget
This commit is contained in:
Zach Gollwitzer 2025-01-16 14:36:37 -05:00 committed by GitHub
parent 413ec6cbed
commit 195ec85d96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 2044 additions and 140 deletions

View file

@ -18,7 +18,6 @@ class Account::DataEnricher
Rails.logger.info("Enriching #{candidates.count} transactions for account #{account.id}")
merchants = {}
categories = {}
candidates.each do |entry|
if entry.enriched_at.nil? || entry.entryable.merchant_id.nil? || entry.entryable.category_id.nil?
@ -37,17 +36,11 @@ class Account::DataEnricher
end
end
if info.category.present?
category = categories[info.category] ||= account.family.categories.find_or_create_by(name: info.category)
end
entryable_attributes = { id: entry.entryable_id }
entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil?
entryable_attributes[:category_id] = category.id if category.present? && entry.entryable.category_id.nil?
Account.transaction do
merchant.save! if merchant.present?
category.save! if category.present?
entry.update!(
enriched_at: Time.current,
enriched_name: info.name,

View file

@ -17,7 +17,7 @@ class Account::Entry < ApplicationRecord
scope :chronological, -> {
order(
date: :asc,
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
created_at: :asc
)
}
@ -25,18 +25,27 @@ class Account::Entry < ApplicationRecord
scope :reverse_chronological, -> {
order(
date: :desc,
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
created_at: :desc
)
}
# All entries that are not part of a pending/approved transfer (rejected transfers count as normal entries, so are included)
# All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses
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'")
joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id")
.joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id")
.joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id")
.where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')")
}
scope :incomes, -> {
incomes_and_expenses.where("account_entries.amount <= 0")
}
scope :expenses, -> {
incomes_and_expenses.where("account_entries.amount > 0")
}
scope :with_converted_amount, ->(currency) {
@ -137,18 +146,16 @@ class Account::Entry < ApplicationRecord
all.size
end
def income_total(currency = "USD")
total = account_transactions.includes(:entryable).incomes_and_expenses
.where("account_entries.amount <= 0")
def income_total(currency = "USD", start_date: nil, end_date: nil)
total = incomes.where(date: start_date..end_date)
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
Money.new(total, currency)
end
def expense_total(currency = "USD")
total = account_transactions.includes(:entryable).incomes_and_expenses
.where("account_entries.amount > 0")
def expense_total(currency = "USD", start_date: nil, end_date: nil)
total = expenses.where(date: start_date..end_date)
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum

181
app/models/budget.rb Normal file
View file

@ -0,0 +1,181 @@
class Budget < ApplicationRecord
include Monetizable
belongs_to :family
has_many :budget_categories, dependent: :destroy
validates :start_date, :end_date, presence: true
validates :start_date, :end_date, uniqueness: { scope: :family_id }
monetize :budgeted_spending, :expected_income, :allocated_spending,
:actual_spending, :available_to_spend, :available_to_allocate,
:estimated_spending, :estimated_income, :actual_income
class << self
def for_date(date)
find_by(start_date: date.beginning_of_month, end_date: date.end_of_month)
end
def find_or_bootstrap(family, date: Date.current)
Budget.transaction do
budget = Budget.find_or_create_by(
family: family,
start_date: date.beginning_of_month,
end_date: date.end_of_month,
currency: family.currency
)
budget.sync_budget_categories
budget
end
end
end
def sync_budget_categories
family.categories.expenses.each do |category|
budget_categories.find_or_create_by(
category: category,
) do |bc|
bc.budgeted_spending = 0
bc.currency = family.currency
end
end
end
def uncategorized_budget_category
budget_categories.uncategorized.tap do |bc|
bc.budgeted_spending = [ available_to_allocate, 0 ].max
bc.currency = family.currency
end
end
def entries
family.entries.incomes_and_expenses.where(date: start_date..end_date)
end
def name
start_date.strftime("%B %Y")
end
def initialized?
budgeted_spending.present?
end
def income_categories_with_totals
family.income_categories_with_totals(date: start_date)
end
def expense_categories_with_totals
family.expense_categories_with_totals(date: start_date)
end
def current?
start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month
end
def previous_budget
prev_month_end_date = end_date - 1.month
return nil if prev_month_end_date < family.oldest_entry_date
family.budgets.find_or_bootstrap(family, date: prev_month_end_date)
end
def next_budget
return nil if current?
next_start_date = start_date + 1.month
family.budgets.find_or_bootstrap(family, date: next_start_date)
end
def to_donut_segments_json
unused_segment_id = "unused"
# Continuous gray segment for empty budgets
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid?
segments = budget_categories.map do |bc|
{ color: bc.category.color, amount: bc.actual_spending, id: bc.id }
end
if available_to_spend.positive?
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
end
segments
end
# =============================================================================
# Actuals: How much user has spent on each budget category
# =============================================================================
def estimated_spending
family.budgeting_stats.avg_monthly_expenses&.abs
end
def actual_spending
budget_categories.reject(&:subcategory?).sum(&:actual_spending)
end
def available_to_spend
(budgeted_spending || 0) - actual_spending
end
def percent_of_budget_spent
return 0 unless budgeted_spending > 0
(actual_spending / budgeted_spending.to_f) * 100
end
def overage_percent
return 0 unless available_to_spend.negative?
available_to_spend.abs / actual_spending.to_f * 100
end
# =============================================================================
# Budget allocations: How much user has budgeted for all categories combined
# =============================================================================
def allocated_spending
budget_categories.sum(:budgeted_spending)
end
def allocated_percent
return 0 unless budgeted_spending > 0
(allocated_spending / budgeted_spending.to_f) * 100
end
def available_to_allocate
(budgeted_spending || 0) - allocated_spending
end
def allocations_valid?
initialized? && available_to_allocate.positive? && allocated_spending > 0
end
# =============================================================================
# Income: How much user earned relative to what they expected to earn
# =============================================================================
def estimated_income
family.budgeting_stats.avg_monthly_income&.abs
end
def actual_income
family.entries.incomes.where(date: start_date..end_date).sum(:amount).abs
end
def actual_income_percent
return 0 unless expected_income > 0
(actual_income / expected_income.to_f) * 100
end
def remaining_expected_income
expected_income - actual_income
end
def surplus_percent
return 0 unless remaining_expected_income.negative?
remaining_expected_income.abs / expected_income.to_f * 100
end
end

View file

@ -0,0 +1,82 @@
class BudgetCategory < ApplicationRecord
include Monetizable
belongs_to :budget
belongs_to :category
validates :budget_id, uniqueness: { scope: :category_id }
monetize :budgeted_spending, :actual_spending, :available_to_spend
class Group
attr_reader :budget_category, :budget_subcategories
delegate :category, to: :budget_category
delegate :name, :color, to: :category
def self.for(budget_categories)
top_level_categories = budget_categories.select { |budget_category| budget_category.category.parent_id.nil? }
top_level_categories.map do |top_level_category|
subcategories = budget_categories.select { |bc| bc.category.parent_id == top_level_category.category_id && top_level_category.category_id.present? }
new(top_level_category, subcategories.sort_by { |subcategory| subcategory.category.name })
end.sort_by { |group| group.category.name }
end
def initialize(budget_category, budget_subcategories = [])
@budget_category = budget_category
@budget_subcategories = budget_subcategories
end
end
class << self
def uncategorized
new(
id: Digest::UUID.uuid_v5(Digest::UUID::URL_NAMESPACE, "uncategorized"),
category: nil,
)
end
end
def initialized?
budget.initialized?
end
def category
super || budget.family.categories.uncategorized
end
def subcategory?
category.parent_id.present?
end
def actual_spending
category.month_total(date: budget.start_date)
end
def available_to_spend
(budgeted_spending || 0) - actual_spending
end
def percent_of_budget_spent
return 0 unless budgeted_spending > 0
(actual_spending / budgeted_spending) * 100
end
def to_donut_segments_json
unused_segment_id = "unused"
overage_segment_id = "overage"
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless actual_spending > 0
segments = [ { color: category.color, amount: actual_spending, id: id } ]
if available_to_spend.negative?
segments.push({ color: "#EF4444", amount: available_to_spend.abs, id: overage_segment_id })
else
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
end
segments
end
end

View file

@ -0,0 +1,29 @@
class BudgetingStats
attr_reader :family
def initialize(family)
@family = family
end
def avg_monthly_income
income_expense_totals_query(Account::Entry.incomes)
end
def avg_monthly_expenses
income_expense_totals_query(Account::Entry.expenses)
end
private
def income_expense_totals_query(type_scope)
monthly_totals = family.entries
.merge(type_scope)
.select("SUM(account_entries.amount) as total")
.group(Arel.sql("date_trunc('month', account_entries.date)"))
result = Family.select("AVG(mt.total)")
.from(monthly_totals, :mt)
.pick("AVG(mt.total)")
result
end
end

View file

@ -4,6 +4,7 @@ class Category < ApplicationRecord
belongs_to :family
has_many :budget_categories, dependent: :destroy
has_many :subcategories, class_name: "Category", foreign_key: :parent_id
belongs_to :parent, class_name: "Category", optional: true
@ -11,8 +12,11 @@ class Category < ApplicationRecord
validates :name, uniqueness: { scope: :family_id }
validate :category_level_limit
validate :nested_category_matches_parent_classification
scope :alphabetically, -> { order(:name) }
scope :incomes, -> { where(classification: "income") }
scope :expenses, -> { where(classification: "expense") }
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
@ -39,35 +43,43 @@ class Category < ApplicationRecord
end
class << self
def icon_codes
%w[bus circle-dollar-sign ambulance apple award baby battery lightbulb bed-single beer bluetooth book briefcase building credit-card camera utensils cooking-pot cookie dices drama dog drill drum dumbbell gamepad-2 graduation-cap house hand-helping ice-cream-cone phone piggy-bank pill pizza printer puzzle ribbon shopping-cart shield-plus ticket trees]
end
def bootstrap_defaults
default_categories.each do |name, color|
default_categories.each do |name, color, icon|
find_or_create_by!(name: name) do |category|
category.color = color
category.classification = "income" if name == "Income"
category.lucide_icon = icon
end
end
end
def uncategorized
new(
name: "Uncategorized",
color: UNCATEGORIZED_COLOR,
lucide_icon: "circle-dashed"
)
end
private
def default_categories
[
[ "Income", "#e99537" ],
[ "Loan Payments", "#6471eb" ],
[ "Bank Fees", "#db5a54" ],
[ "Entertainment", "#df4e92" ],
[ "Food & Drink", "#c44fe9" ],
[ "Groceries", "#eb5429" ],
[ "Dining Out", "#61c9ea" ],
[ "General Merchandise", "#805dee" ],
[ "Clothing & Accessories", "#6ad28a" ],
[ "Electronics", "#e99537" ],
[ "Healthcare", "#4da568" ],
[ "Insurance", "#6471eb" ],
[ "Utilities", "#db5a54" ],
[ "Transportation", "#df4e92" ],
[ "Gas & Fuel", "#c44fe9" ],
[ "Education", "#eb5429" ],
[ "Charitable Donations", "#61c9ea" ],
[ "Subscriptions", "#805dee" ]
[ "Income", "#e99537", "circle-dollar-sign" ],
[ "Housing", "#6471eb", "house" ],
[ "Entertainment", "#df4e92", "drama" ],
[ "Food & Drink", "#eb5429", "utensils" ],
[ "Shopping", "#e99537", "shopping-cart" ],
[ "Healthcare", "#4da568", "pill" ],
[ "Insurance", "#6471eb", "piggy-bank" ],
[ "Utilities", "#db5a54", "lightbulb" ],
[ "Transportation", "#df4e92", "bus" ],
[ "Education", "#eb5429", "book" ],
[ "Gifts & Donations", "#61c9ea", "hand-helping" ],
[ "Subscriptions", "#805dee", "credit-card" ]
]
end
end
@ -83,10 +95,28 @@ class Category < ApplicationRecord
parent.present?
end
def avg_monthly_total
family.category_stats.avg_monthly_total_for(self)
end
def median_monthly_total
family.category_stats.median_monthly_total_for(self)
end
def month_total(date: Date.current)
family.category_stats.month_total_for(self, date: date)
end
private
def category_level_limit
if subcategory? && parent.subcategory?
errors.add(:parent, "can't have more than 2 levels of subcategories")
end
end
def nested_category_matches_parent_classification
if subcategory? && parent.classification != classification
errors.add(:parent, "must have the same classification as its parent")
end
end
end

View file

@ -0,0 +1,179 @@
class CategoryStats
attr_reader :family
def initialize(family)
@family = family
end
def avg_monthly_total_for(category)
statistics_data[category.id]&.avg || 0
end
def median_monthly_total_for(category)
statistics_data[category.id]&.median || 0
end
def month_total_for(category, date: Date.current)
monthly_totals = totals_data[category.id]
category_total = monthly_totals&.find { |mt| mt.month == date.month && mt.year == date.year }
category_total&.amount || 0
end
def month_category_totals(date: Date.current)
by_classification = Hash.new { |h, k| h[k] = {} }
totals_data.each_with_object(by_classification) do |(category_id, totals), result|
totals.each do |t|
next unless t.month == date.month && t.year == date.year
result[t.classification][category_id] ||= { amount: 0, subcategory: t.subcategory? }
result[t.classification][category_id][:amount] += t.amount.abs
end
end
# Calculate percentages for each group
category_totals = []
[ "income", "expense" ].each do |classification|
totals = by_classification[classification]
# Only include non-subcategory amounts in the total for percentage calculations
total_amount = totals.sum do |_, data|
data[:subcategory] ? 0 : data[:amount]
end
next if total_amount.zero?
totals.each do |category_id, data|
percentage = (data[:amount].to_f / total_amount * 100).round(1)
category_totals << CategoryTotal.new(
category_id: category_id,
amount: data[:amount],
percentage: percentage,
classification: classification,
currency: family.currency,
subcategory?: data[:subcategory]
)
end
end
# Calculate totals based on non-subcategory amounts only
total_income = category_totals
.select { |ct| ct.classification == "income" && !ct.subcategory? }
.sum(&:amount)
total_expense = category_totals
.select { |ct| ct.classification == "expense" && !ct.subcategory? }
.sum(&:amount)
CategoryTotals.new(
total_income: total_income,
total_expense: total_expense,
category_totals: category_totals
)
end
private
Totals = Struct.new(:month, :year, :amount, :classification, :currency, :subcategory?, keyword_init: true)
Stats = Struct.new(:avg, :median, :currency, keyword_init: true)
CategoryTotals = Struct.new(:total_income, :total_expense, :category_totals, keyword_init: true)
CategoryTotal = Struct.new(:category_id, :amount, :percentage, :classification, :currency, :subcategory?, keyword_init: true)
def statistics_data
@statistics_data ||= begin
stats = totals_data.each_with_object({ nil => Stats.new(avg: 0, median: 0) }) do |(category_id, totals), hash|
next if totals.empty?
amounts = totals.map(&:amount)
hash[category_id] = Stats.new(
avg: (amounts.sum.to_f / amounts.size).round,
median: calculate_median(amounts),
currency: family.currency
)
end
end
end
def totals_data
@totals_data ||= begin
totals = monthly_totals_query.each_with_object({ nil => [] }) do |row, hash|
hash[row.category_id] ||= []
existing_total = hash[row.category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
if existing_total
existing_total.amount += row.total.to_i
else
hash[row.category_id] << Totals.new(
month: row.date.month,
year: row.date.year,
amount: row.total.to_i,
classification: row.classification,
currency: family.currency,
subcategory?: row.parent_category_id.present?
)
end
# If category is a parent, its total includes its own transactions + sum(child category transactions)
if row.parent_category_id
hash[row.parent_category_id] ||= []
existing_parent_total = hash[row.parent_category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
if existing_parent_total
existing_parent_total.amount += row.total.to_i
else
hash[row.parent_category_id] << Totals.new(
month: row.date.month,
year: row.date.year,
amount: row.total.to_i,
classification: row.classification,
currency: family.currency,
subcategory?: false
)
end
end
end
# Ensure we have a default empty array for nil category, which represents "Uncategorized"
totals[nil] ||= []
totals
end
end
def monthly_totals_query
income_expense_classification = Arel.sql("
CASE WHEN categories.id IS NULL THEN
CASE WHEN account_entries.amount < 0 THEN 'income' ELSE 'expense' END
ELSE categories.classification
END
")
family.entries
.incomes_and_expenses
.select(
"categories.id as category_id",
"categories.parent_id as parent_category_id",
income_expense_classification,
"date_trunc('month', account_entries.date) as date",
"SUM(account_entries.amount) as total"
)
.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
.group(Arel.sql("categories.id, categories.parent_id, #{income_expense_classification}, date_trunc('month', account_entries.date)"))
.order(Arel.sql("date_trunc('month', account_entries.date) DESC"))
end
def calculate_median(numbers)
return 0 if numbers.empty?
sorted = numbers.sort
mid = sorted.size / 2
if sorted.size.odd?
sorted[mid]
else
((sorted[mid-1] + sorted[mid]) / 2.0).round
end
end
end

View file

@ -87,18 +87,12 @@ class Demo::Generator
end
def create_categories!
categories = [ "Income", "Food & Drink", "Entertainment", "Travel",
"Personal Care", "General Services", "Auto & Transport",
"Rent & Utilities", "Home Improvement", "Shopping" ]
categories.each do |category|
family.categories.create!(name: category, color: COLORS.sample)
end
family.categories.bootstrap_defaults
food = family.categories.find_by(name: "Food & Drink")
family.categories.create!(name: "Restaurants", parent: food)
family.categories.create!(name: "Groceries", parent: food)
family.categories.create!(name: "Alcohol & Bars", parent: food)
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense")
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, classification: "expense")
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense")
end
def create_merchants!
@ -362,17 +356,17 @@ class Demo::Generator
"McDonald's" => "Food & Drink",
"Target" => "Shopping",
"Costco" => "Food & Drink",
"Home Depot" => "Home Improvement",
"Shell" => "Auto & Transport",
"Home Depot" => "Housing",
"Shell" => "Transportation",
"Whole Foods" => "Food & Drink",
"Walgreens" => "Personal Care",
"Walgreens" => "Healthcare",
"Nike" => "Shopping",
"Uber" => "Auto & Transport",
"Netflix" => "Entertainment",
"Spotify" => "Entertainment",
"Delta Airlines" => "Travel",
"Airbnb" => "Travel",
"Sephora" => "Personal Care"
"Uber" => "Transportation",
"Netflix" => "Subscriptions",
"Spotify" => "Subscriptions",
"Delta Airlines" => "Transportation",
"Airbnb" => "Housing",
"Sephora" => "Shopping"
}
categories.find { |c| c.name == mapping[merchant.name] }

View file

@ -17,6 +17,8 @@ class Family < ApplicationRecord
has_many :issues, through: :accounts
has_many :holdings, through: :accounts
has_many :plaid_items, dependent: :destroy
has_many :budgets, dependent: :destroy
has_many :budget_categories, through: :budgets
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS }
@ -56,6 +58,22 @@ class Family < ApplicationRecord
).link_token
end
def income_categories_with_totals(date: Date.current)
categories_with_stats(classification: "income", date: date)
end
def expense_categories_with_totals(date: Date.current)
categories_with_stats(classification: "expense", date: date)
end
def category_stats
CategoryStats.new(self)
end
def budgeting_stats
BudgetingStats.new(self)
end
def snapshot(period = Period.all)
query = accounts.active.joins(:balances)
.where("account_balances.currency = ?", self.currency)
@ -172,4 +190,41 @@ class Family < ApplicationRecord
def primary_user
users.order(:created_at).first
end
def oldest_entry_date
entries.order(:date).first&.date || Date.current
end
private
CategoriesWithTotals = Struct.new(:total_money, :category_totals, keyword_init: true)
CategoryWithStats = Struct.new(:category, :amount_money, :percentage, keyword_init: true)
def categories_with_stats(classification:, date: Date.current)
totals = category_stats.month_category_totals(date: date)
classified_totals = totals.category_totals.select { |t| t.classification == classification }
if classification == "income"
total = totals.total_income
categories_scope = categories.incomes
else
total = totals.total_expense
categories_scope = categories.expenses
end
categories_with_uncategorized = categories_scope + [ categories_scope.uncategorized ]
CategoriesWithTotals.new(
total_money: Money.new(total, currency),
category_totals: categories_with_uncategorized.map do |category|
ct = classified_totals.find { |ct| ct.category_id == category&.id }
CategoryWithStats.new(
category: category,
amount_money: Money.new(ct&.amount || 0, currency),
percentage: ct&.percentage || 0
)
end
)
end
end

View file

@ -42,34 +42,34 @@ class Transfer < ApplicationRecord
end
def auto_match_for_account(account)
matches = account.entries.account_transactions.joins("
JOIN account_entries ae2 ON
account_entries.amount = -ae2.amount AND
account_entries.currency = ae2.currency AND
account_entries.account_id <> ae2.account_id AND
ABS(account_entries.date - ae2.date) <= 4
").select(
"account_entries.id",
"account_entries.entryable_id AS e1_entryable_id",
"ae2.entryable_id AS e2_entryable_id",
"account_entries.amount AS e1_amount",
"ae2.amount AS e2_amount"
)
matches = Account::Entry.from("account_entries inflow_candidates")
.joins("
JOIN account_entries outflow_candidates ON (
inflow_candidates.amount < 0 AND
outflow_candidates.amount > 0 AND
inflow_candidates.amount = -outflow_candidates.amount AND
inflow_candidates.currency = outflow_candidates.currency AND
inflow_candidates.account_id <> outflow_candidates.account_id AND
inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4 AND
inflow_candidates.date >= outflow_candidates.date
)
").joins("
LEFT JOIN transfers existing_transfers ON (
(existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND
existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id) OR
(existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id) OR
(existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id)
)
")
.where(existing_transfers: { id: nil })
.where("inflow_candidates.account_id = ? AND outflow_candidates.account_id = ?", account.id, account.id)
.pluck(:inflow_transaction_id, :outflow_transaction_id)
Transfer.transaction do
matches.each do |match|
inflow = match.e1_amount.negative? ? match.e1_entryable_id : match.e2_entryable_id
outflow = match.e1_amount.negative? ? match.e2_entryable_id : match.e1_entryable_id
# Skip all rejected, or already matched transfers
next if Transfer.exists?(
inflow_transaction_id: inflow,
outflow_transaction_id: outflow
)
matches.each do |inflow_transaction_id, outflow_transaction_id|
Transfer.create!(
inflow_transaction_id: inflow,
outflow_transaction_id: outflow
inflow_transaction_id: inflow_transaction_id,
outflow_transaction_id: outflow_transaction_id,
)
end
end
@ -109,6 +109,10 @@ class Transfer < ApplicationRecord
to_account.liability?
end
def categorizable?
to_account.accountable_type == "Loan"
end
private
def transfer_has_different_accounts
return unless inflow_transaction.present? && outflow_transaction.present?