mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +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:
parent
1f6e83ee91
commit
461fa672ff
8 changed files with 207 additions and 64 deletions
|
@ -6,6 +6,15 @@ class PagesController < ApplicationController
|
|||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@liability_series = snapshot[:liability_series]
|
||||
|
||||
snapshot_transactions = Current.family.snapshot_transactions
|
||||
@income_series = snapshot_transactions[:income_series]
|
||||
@spending_series = snapshot_transactions[:spending_series]
|
||||
|
||||
snapshot_account_transactions = Current.family.snapshot_account_transactions
|
||||
@top_spenders = snapshot_account_transactions[:top_spenders]
|
||||
@top_earners = snapshot_account_transactions[:top_earners]
|
||||
|
||||
@account_groups = Current.family.accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
@transactions = Current.family.transactions.limit(5).order(date: :desc)
|
||||
|
||||
|
@ -13,8 +22,6 @@ class PagesController < ApplicationController
|
|||
placeholder_series_data = 10.times.map do |i|
|
||||
{ date: Date.current - i.days, value: Money.new(0) }
|
||||
end
|
||||
@income_series = TimeSeries.new(placeholder_series_data)
|
||||
@spending_series = TimeSeries.new(placeholder_series_data)
|
||||
@savings_rate_series = TimeSeries.new(10.times.map { |i| { date: Date.current - i.days, value: 0 } })
|
||||
@investing_series = TimeSeries.new(placeholder_series_data)
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -33,12 +33,13 @@
|
|||
</section>
|
||||
<section class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
|
||||
<div class="flex gap-4 h-full">
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<div class="flex gap-4">
|
||||
<div class="grow">
|
||||
<%= render partial: "shared/value_heading", locals: {
|
||||
label: t(".income"),
|
||||
period: @period,
|
||||
value: @income_series.last.value,
|
||||
period: Period.last_30_days,
|
||||
value: @income_series.last&.value,
|
||||
trend: @income_series.trend
|
||||
} %>
|
||||
</div>
|
||||
|
@ -47,16 +48,32 @@
|
|||
class="h-full w-2/5"
|
||||
data-controller="time-series-chart"
|
||||
data-time-series-chart-data-value="<%= @income_series.to_json %>"
|
||||
data-time-series-chart-use-labels-value="false"></div>
|
||||
data-time-series-chart-use-labels-value="false"
|
||||
data-time-series-chart-use-tooltip-value="false"></div>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<% @top_earners.first(3).each do |account| %>
|
||||
<%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25" do %>
|
||||
<div class="flex items-center justify-center text-xs w-5 h-5 rounded-full <%= accountable_text_class(account.accountable_type) %> <%= accountable_bg_transparent_class(account.accountable_type) %>">
|
||||
<%= account.name[0].upcase %>
|
||||
</div>
|
||||
<span>+<%= Money.new(account.income, account.currency) %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @top_earners.count > 3 %>
|
||||
<div class="bg-gray-25 rounded-full flex h-full aspect-1 items-center justify-center text-xs font-medium text-gray-500">+<%= @top_earners.count - 3 %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
|
||||
<div class="flex gap-4 h-full">
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<div class="flex gap-4">
|
||||
<div class="grow">
|
||||
<%= render partial: "shared/value_heading", locals: {
|
||||
label: t(".spending"),
|
||||
period: @period,
|
||||
value: @spending_series.last.value,
|
||||
period: Period.last_30_days,
|
||||
value: @spending_series.last&.value,
|
||||
trend: @spending_series.trend
|
||||
} %>
|
||||
</div>
|
||||
|
@ -65,7 +82,22 @@
|
|||
class="h-full w-2/5"
|
||||
data-controller="time-series-chart"
|
||||
data-time-series-chart-data-value="<%= @spending_series.to_json %>"
|
||||
data-time-series-chart-use-labels-value="false"></div>
|
||||
data-time-series-chart-use-labels-value="false"
|
||||
data-time-series-chart-use-tooltip-value="false"></div>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<% @top_spenders.first(3).each do |account| %>
|
||||
<%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25" do %>
|
||||
<div class="flex items-center justify-center text-xs w-5 h-5 rounded-full <%= accountable_text_class(account.accountable_type) %> <%= accountable_bg_transparent_class(account.accountable_type) %>">
|
||||
<%= account.name[0].upcase %>
|
||||
</div>
|
||||
-<%= Money.new(account.spending, account.currency) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @top_spenders.count > 3 %>
|
||||
<div class="bg-gray-25 rounded-full flex h-full aspect-1 items-center justify-center text-xs font-medium text-gray-500">+<%= @top_spenders.count - 3 %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
|
||||
|
|
|
@ -7,14 +7,14 @@ en:
|
|||
debts: Debts
|
||||
categories: Categories
|
||||
greeting: Welcome back, %{name}
|
||||
income: Income (coming soon...)
|
||||
income: Income
|
||||
investing: Investing (coming soon...)
|
||||
net_worth: Net Worth
|
||||
new: New account
|
||||
no_transactions: You have no recent transactions
|
||||
recurring: Recurring
|
||||
savings_rate: Savings Rate (coming soon...)
|
||||
spending: Spending (coming soon...)
|
||||
spending: Spending
|
||||
subtitle: Here's what's happening today
|
||||
transactions: Transactions
|
||||
view_all: View all
|
||||
|
|
64
test/fixtures/family/expected_snapshots.csv
vendored
64
test/fixtures/family/expected_snapshots.csv
vendored
|
@ -1,32 +1,32 @@
|
|||
date_offset,net_worth,assets,liabilities,depositories,investments,loans,credits,properties,vehicles,other_assets,other_liabilities
|
||||
-30,48278.57,49318.57,1040.00,48918.57,0.00,0.00,1040.00,0.00,0.00,400.00,0.00
|
||||
-29,49298.96,50238.96,940.00,49838.96,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-28,49311.01,50251.01,940.00,49851.01,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-27,49248.35,50188.35,940.00,49788.35,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-26,49200.15,50140.15,940.00,49740.15,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-25,48447.74,49387.74,940.00,48987.74,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-24,48428.46,49368.46,940.00,48968.46,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-23,48388.70,49328.70,940.00,48928.70,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-22,49502.26,50442.26,940.00,50042.26,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-21,49509.49,50449.49,940.00,50049.49,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-20,49520.33,50460.33,940.00,50060.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-19,49265.33,50205.33,940.00,49805.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-18,47267.72,48207.72,940.00,47807.72,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-17,47260.55,48200.55,940.00,47800.55,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-16,47249.80,48189.80,940.00,47789.80,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-15,47175.14,48135.14,960.00,47735.14,0.00,0.00,960.00,0.00,0.00,400.00,0.00
|
||||
-14,47172.75,48132.75,960.00,47732.75,0.00,0.00,960.00,0.00,0.00,400.00,0.00
|
||||
-13,47166.78,48126.78,960.00,47726.78,0.00,0.00,960.00,0.00,0.00,400.00,0.00
|
||||
-12,47854.20,48844.20,990.00,48144.20,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-11,47830.30,48820.30,990.00,48120.30,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-10,47906.78,48896.78,990.00,48196.78,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-9,48022.64,49012.64,990.00,48312.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-8,48016.64,49006.64,990.00,48306.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-7,48011.84,49001.84,990.00,48301.84,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-6,47989.04,48979.04,990.00,48279.04,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-5,48154.64,49154.64,1000.00,48454.64,0.00,0.00,1000.00,0.00,0.00,700.00,0.00
|
||||
-4,47195.60,48195.60,1000.00,47645.60,0.00,0.00,1000.00,0.00,0.00,550.00,0.00
|
||||
-3,48096.40,49096.40,1000.00,48546.40,0.00,0.00,1000.00,0.00,0.00,550.00,0.00
|
||||
-2,48032.80,49032.80,1000.00,48482.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00
|
||||
-1,48064.00,49064.00,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00
|
||||
0,47550.80,48550.80,1000.00,48000.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00
|
||||
date_offset,net_worth,assets,liabilities,depositories,investments,loans,credits,properties,vehicles,other_assets,other_liabilities,spending,income,rolling_spend,rolling_income
|
||||
-30,48278.57,49318.57,1040.00,48918.57,0.00,0.00,1040.00,0.00,0.00,400.00,0.00,0,0,0,0
|
||||
-29,49298.96,50238.96,940.00,49838.96,0.00,0.00,940.00,0.00,0.00,400.00,0.00,15,1018.8,15,1018.8
|
||||
-28,49311.01,50251.01,940.00,49851.01,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
||||
-27,49248.35,50188.35,940.00,49788.35,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
||||
-26,49200.15,50140.15,940.00,49740.15,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
||||
-25,48447.74,49387.74,940.00,48987.74,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
||||
-24,48428.46,49368.46,940.00,48968.46,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
||||
-23,48388.70,49328.70,940.00,48928.70,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
||||
-22,49502.26,50442.26,940.00,50042.26,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,1075,15,2093.8
|
||||
-21,49509.49,50449.49,940.00,50049.49,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,2093.8
|
||||
-20,49520.33,50460.33,940.00,50060.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,2093.8
|
||||
-19,49265.33,50205.33,940.00,49805.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00,216.44,0,231.44,2093.8
|
||||
-18,47267.72,48207.72,940.00,47807.72,0.00,0.00,940.00,0.00,0.00,400.00,0.00,2000,0,2231.44,2093.8
|
||||
-17,47260.55,48200.55,940.00,47800.55,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,2231.44,2093.8
|
||||
-16,47249.80,48189.80,940.00,47789.80,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,2231.44,2093.8
|
||||
-15,47175.14,48135.14,960.00,47735.14,0.00,0.00,960.00,0.00,0.00,400.00,0.00,40,0,2271.44,2093.8
|
||||
-14,47172.75,48132.75,960.00,47732.75,0.00,0.00,960.00,0.00,0.00,400.00,0.00,0,0,2271.44,2093.8
|
||||
-13,47166.78,48126.78,960.00,47726.78,0.00,0.00,960.00,0.00,0.00,400.00,0.00,0,0,2271.44,2093.8
|
||||
-12,47854.20,48844.20,990.00,48144.20,0.00,0.00,990.00,0.00,0.00,700.00,0.00,60,50,2331.44,2143.8
|
||||
-11,47830.30,48820.30,990.00,48120.30,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2143.8
|
||||
-10,47906.78,48896.78,990.00,48196.78,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2143.8
|
||||
-9,48022.64,49012.64,990.00,48312.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,103.915,2331.44,2247.715
|
||||
-8,48016.64,49006.64,990.00,48306.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715
|
||||
-7,48011.84,49001.84,990.00,48301.84,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715
|
||||
-6,47989.04,48979.04,990.00,48279.04,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715
|
||||
-5,48154.64,49154.64,1000.00,48454.64,0.00,0.00,1000.00,0.00,0.00,700.00,0.00,20,200,2351.44,2447.715
|
||||
-4,47195.60,48195.60,1000.00,47645.60,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,863.04,0,3214.48,2447.715
|
||||
-3,48096.40,49096.40,1000.00,48546.40,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715
|
||||
-2,48032.80,49032.80,1000.00,48482.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715
|
||||
-1,48064.00,49064.00,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715
|
||||
0,47550.80,48550.80,1000.00,48000.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715
|
|
|
@ -16,7 +16,9 @@ class FamilyTest < ActiveSupport::TestCase
|
|||
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
|
||||
"net_worth" => row["net_worth"],
|
||||
"assets" => row["assets"],
|
||||
"liabilities" => row["liabilities"]
|
||||
"liabilities" => row["liabilities"],
|
||||
"rolling_spend" => row["rolling_spend"],
|
||||
"rolling_income" => row["rolling_income"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@ -83,6 +85,22 @@ class FamilyTest < ActiveSupport::TestCase
|
|||
end
|
||||
end
|
||||
|
||||
test "should calculate transaction snapshot correctly" do
|
||||
spending_series = @family.snapshot_transactions[:spending_series]
|
||||
income_series = @family.snapshot_transactions[:income_series]
|
||||
|
||||
assert_equal @expected_snapshots.count, spending_series.values.count
|
||||
assert_equal @expected_snapshots.count, income_series.values.count
|
||||
|
||||
@expected_snapshots.each_with_index do |row, index|
|
||||
expected_spending = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_spend"].to_d))
|
||||
expected_income = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_income"].to_d))
|
||||
|
||||
assert_in_delta expected_spending.value.amount, Money.new(spending_series.values[index].value).amount, 0.01
|
||||
assert_in_delta expected_income.value.amount, Money.new(income_series.values[index].value).amount, 0.01
|
||||
end
|
||||
end
|
||||
|
||||
test "should exclude disabled accounts from calculations" do
|
||||
assets_before = @family.assets
|
||||
liabilities_before = @family.liabilities
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue