1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-05 13:35:21 +02:00

Add income and spending insight cards to dashboard (#668)

* Generate time series for rolling 30 day sum of income and spending

* Highlight accounts with most income and spending in the last 30 days

* Aggregate chips after 3 top accounts in insight card

* Refactor aggregation filter

I think this is easier to read and understand whats happening at a
glance

* Refactor and tidy

* Use family currency for insight cards

* Further reduce risk of sql injection

* Fix lint

* Refactor rolling total queries

* Add test for transaction snapshot
This commit is contained in:
Josh Brown 2024-04-24 13:34:50 +01:00 committed by GitHub
parent 1f6e83ee91
commit 461fa672ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 207 additions and 64 deletions

View file

@ -28,6 +28,49 @@ class Family < ApplicationRecord
}
end
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("transactions.date >= ?", period.date_range.begin)
.where("transactions.date <= ?", period.date_range.end)
.group("id")
.to_a
{
top_spenders: results.sort_by(&:spending).select { |a| a.spending > 0 }.reverse,
top_earners: results.sort_by(&:income).select { |a| a.income > 0 }.reverse
}
end
def snapshot_transactions
rolling_totals = Transaction.daily_rolling_totals(transactions, period: Period.last_30_days, currency: self.currency)
spending = []
income = []
rolling_totals.each do |r|
spending << {
date: r.date,
value: Money.new(r.rolling_spend, self.currency)
}
income << {
date: r.date,
value: Money.new(r.rolling_income, self.currency)
}
end
{
income_series: TimeSeries.new(income, favorable_direction: "up"),
spending_series: TimeSeries.new(spending, favorable_direction: "down")
}
end
def effective_start_date
accounts.active.joins(:balances).minimum("account_balances.date") || Date.current
end

View file

@ -14,6 +14,10 @@ class Period
@date_range = date_range
end
def extend_backward(duration)
Period.new(name: name + "_extended", date_range: (date_range.first - duration)..date_range.last)
end
BUILTIN = [
new(name: "all", date_range: nil..Date.current),
new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current),

View file

@ -11,6 +11,45 @@ class Transaction < ApplicationRecord
scope :inflows, -> { where("amount > 0") }
scope :outflows, -> { where("amount < 0") }
scope :active, -> { where(excluded: false) }
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(
"transactions.*",
"transactions.amount * COALESCE(er.rate, 1) AS converted_amount"
)
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON transactions.date = er.date AND transactions.currency = er.base_currency AND er.converted_currency = ?", currency ]))
.where("er.rate IS NOT NULL OR transactions.currency = ?", currency)
}
def self.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), :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 self.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 self.ransackable_attributes(auth_object = nil)
%w[name amount date]