Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ config :spendable,

config :spendable, Oban,
repo: Spendable.Repo,
queues: [banks: 5]
queues: [banks: 5, budgets: 1],
plugins: [
# Daily rather than monthly: funding a month is idempotent, so a missed run heals itself the
# next day instead of leaving the month unfunded until someone notices.
{Oban.Plugins.Cron, crontab: [{"0 4 * * *", Spendable.Budgets.Jobs.FundBudgets}]}
]

config :spendable, Spendable.Repo,
migration_primary_key: [type: :text],
Expand Down
3 changes: 3 additions & 0 deletions lib/spendable/budgets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ defmodule Spendable.Budgets do
defdelegate update_budget(scope, budget, attrs), to: Actions.UpdateBudget
defdelegate archive_budget(scope, budget), to: Actions.ArchiveBudget
defdelegate find_or_create_spendable_budget(scope), to: Actions.FindOrCreateSpendableBudget
defdelegate fund_budgets(scope, month), to: Actions.FundBudgets
defdelegate calculate_spendable(scope), to: Actions.CalculateSpendable
defdelegate calculate_funded(scope, budgets, month), to: Actions.CalculateFunded
defdelegate calculate_received(scope, budgets, month), to: Actions.CalculateReceived
defdelegate calculate_spent(scope, budgets, month), to: Actions.CalculateSpent
defdelegate calculate_spent_by_month(scope), to: Actions.CalculateSpentByMonth
defdelegate calculate_month_summary(scope, month, opts \\ []), to: Actions.CalculateMonthSummary
Expand Down
46 changes: 42 additions & 4 deletions lib/spendable/budgets/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ A named envelope a user assigns money to.
_Avoid_: Category, bucket, envelope

**Budget Type**:
Whether a **Budget** reserves money, saves toward an amount, or only records spending.
Whether a **Budget** reserves money, saves toward an amount, records spending, or records money
arriving.
_Avoid_: Kind, mode

**Envelope**:
Expand All @@ -24,14 +25,38 @@ A **Budget** saving toward a target amount.
A **Budget** that records spending without reserving anything against it.
_Avoid_: Track-only, spending-only

**Income**:
A **Budget** that records money arriving.
_Avoid_: Revenue, deposit, inflow, earnings

**Received**:
What an **Income** budget took in over a month.
_Avoid_: Earned, credited, incoming

**Balance**:
What a **Budget** currently holds. Never stored - see **Relationships** for what it comes from.
What a **Budget** currently holds.
_Avoid_: Total, amount

**Budgeted Amount**:
What the user intends a **Budget** to hold, against which its **Balance** is read.
The figure a **Budget** that holds nothing is measured against for a month.
_Avoid_: Target, limit, cap

**Funding Amount**:
What a **Budget** puts into itself each month.
_Avoid_: Contribution, auto-fill, budgeted amount

**Funding**:
Money put into a **Budget** for one month.
_Avoid_: Deposit, top-up, assignment

**Rollover**:
Whether a **Budget**'s **Balance** carries into the next month.
_Avoid_: Carry over, reset, accumulate

**Overspent**:
An **Envelope** whose **Balance** has gone below zero.
_Avoid_: Over budget, in the red, negative

**Adjustment**:
The correction that makes a **Budget**'s **Balance** the figure the user asked for.
_Avoid_: Offset, manual entry
Expand Down Expand Up @@ -62,10 +87,14 @@ _Avoid_: Row, item, split allocation

- A **Budget** has many **Allocations**
- An **Allocation** belongs to exactly one **Budget** and one **Transaction**
- A **Budget**'s **Balance** is the sum of its **Allocations** plus its **Adjustment**, unless a **Bank Account** is assigned to it, in which case the **Balance** is that account's
- A **Budget** has many **Fundings**, at most one per month
- A **Budget**'s **Balance** is the sum of its **Fundings** and its **Allocations** plus its **Adjustment**, unless a **Bank Account** is assigned to it, in which case the **Balance** is that account's
- An **Envelope** has a **Funding Amount** and no **Budgeted Amount**; **Tracking** and **Income** have the reverse; a **Goal** has both
- Only an **Envelope** has a **Rollover** the user can turn off
- A **Split** has many **Lines**; a **Line** names one **Budget**
- A **User** has at most one **Spendable** budget, created the first time one is needed
- Spending is read per month and is derived from **Allocations**, so it belongs to a month rather than to a **Budget**
- **Received** belongs to an **Income** budget; spending belongs to every other **Budget Type**

## Example dialogue

Expand All @@ -75,6 +104,15 @@ _Avoid_: Row, item, split allocation
> **Dev:** "So what does editing a **Budget**'s **Balance** actually write?"
> **Domain expert:** "The **Adjustment**. You're telling it what the balance ought to be, and the adjustment is the difference."

> **Dev:** "Groceries is 50 **Overspent**. Does next month put in 300, or 350 to cover it?"
> **Domain expert:** "300 if it rolls over, and it starts at 250. 350 if it doesn't, and it starts whole."

> **Dev:** "Where does the money a **Funding** puts in come from? Nothing comes out anywhere."
> **Domain expert:** "**Spendable**. It's what no budget has claimed, so a budget claiming more leaves less."

> **Dev:** "A friend paid me back for something I bought from Groceries. Where does the money go?"
> **Domain expert:** "Back into Groceries, where it cancels the spend. A **Budget** records what it is left holding, not where the money came from - which is why a paycheck belongs in an **Income** budget instead."

## Flagged ambiguities

- "balance" meant both a **Budget**'s derived balance and a **Bank Account**'s reported one - resolved: they are distinct, and the second belongs to Banks.
Expand Down
37 changes: 37 additions & 0 deletions lib/spendable/budgets/actions/calculate_funded.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
defmodule Spendable.Budgets.Actions.CalculateFunded do
@moduledoc false

import Ecto.Query

alias Spendable.Budgets.Schemas.Funding
alias Spendable.Repo
alias Spendable.Scope

@zero Decimal.new("0.00")

@doc """
What each of the given budgets was funded with in one month, keyed by budget id.

The companion to `calculate_spent/3`: that says what left a budget this month, this says what
went into it. Every id asked for comes back, at zero if the month never funded it.
"""
def calculate_funded(_scope, [], _month), do: %{}

def calculate_funded(%Scope{user: %{id: user_id}}, budgets, month) do
month = Date.beginning_of_month(month)
budget_ids = Enum.map(budgets, & &1.id)

funded =
from(funding in Funding,
select: {funding.budget_id, coalesce(sum(funding.amount), ^@zero)},
where: funding.user_id == ^user_id,
where: funding.budget_id in ^budget_ids,
where: funding.month == ^month,
group_by: funding.budget_id
)
|> Repo.all()
|> Map.new()

Map.new(budget_ids, &{&1, Map.get(funded, &1, @zero)})
end
end
56 changes: 56 additions & 0 deletions lib/spendable/budgets/actions/calculate_funded_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
defmodule Spendable.Budgets.Actions.CalculateFundedTest do
use Spendable.DataCase, async: true

alias Spendable.Accounts
alias Spendable.Budgets
alias Spendable.Scope

# Behind the current month, which a self-funding budget fills on creation.
@month ~D[2020-05-01]

setup do
{:ok, user} =
Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})

scope = Scope.for_user(user)

{:ok, %{id: budget_id} = budget} =
Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})

%{scope: scope, budget: budget, budget_id: budget_id}
end

test "reports what the month funded", %{scope: scope, budget: budget, budget_id: budget_id} do
{:ok, 1} = Budgets.fund_budgets(scope, @month)

assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], ~D[2020-05-15])
assert Decimal.eq?(funded, "300.00")
end

test "reports zero for a month that funded nothing", %{
scope: scope,
budget: budget,
budget_id: budget_id
} do
{:ok, 1} = Budgets.fund_budgets(scope, @month)

assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], ~D[2020-04-01])
assert Decimal.eq?(funded, "0.00")
end

test "returns nothing when given no budgets", %{scope: scope} do
assert %{} == Budgets.calculate_funded(scope, [], @month)
end

test "leaves another user's funding out", %{scope: scope, budget: budget, budget_id: budget_id} do
{:ok, other_user} =
Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})

{:ok, 1} = Budgets.fund_budgets(scope, @month)

assert %{^budget_id => funded} =
Budgets.calculate_funded(Scope.for_user(other_user), [budget], @month)

assert Decimal.eq?(funded, "0.00")
end
end
23 changes: 16 additions & 7 deletions lib/spendable/budgets/actions/calculate_month_summary.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,39 @@ defmodule Spendable.Budgets.Actions.CalculateMonthSummary do
Every number the budgets screen shows for one month, so the web and the API cannot disagree
about what a month adds up to.

Only envelopes count toward the allocated and spent totals: a tracking budget reserves nothing
and a goal is money going in rather than out.
Only envelopes count toward the allocated, funded and spent totals: a tracking budget reserves
nothing and a goal is money going in rather than out. Earned comes off the income budgets, which
are the only budgets that receive, and it is what says whether the funding amounts are
survivable.
"""
def calculate_month_summary(%Scope{} = scope, %Date{} = month, opts \\ []) do
month = Date.beginning_of_month(month)
budgets = Budgets.list_budgets(scope, search: opts[:search])
spent = Budgets.calculate_spent(scope, budgets, month)
received = Budgets.calculate_received(scope, budgets, month)
funded = Budgets.calculate_funded(scope, budgets, month)
envelopes = Enum.filter(budgets, &(&1.type == :envelope))
income = Enum.filter(budgets, &(&1.type == :income))

%{
month: month,
current_month: Date.compare(month, Date.beginning_of_month(Date.utc_today())) == :eq,
budgets: budgets,
spent: spent,
received: received,
funded: funded,
spent_by_month: Budgets.calculate_spent_by_month(scope),
spendable: Budgets.calculate_spendable(scope),
allocated_total: total(envelopes, & &1.budgeted_amount),
allocated_total: total(envelopes, & &1.funding_amount),
funded_total: total(envelopes, &Map.get(funded, &1.id)),
earned_total: total(income, &Map.get(received, &1.id)),
spent_total: total(envelopes, &Map.get(spent, &1.id))
}
end

defp total(envelopes, amount) do
envelopes
|> Enum.reduce(@zero, &Decimal.add(&2, amount.(&1) || @zero))
|> Decimal.abs()
# No `abs` here: `calculate_spent/3` already nets and negates, so a month refunded more than it
# spent has to stay negative rather than read as that much spending.
defp total(budgets, amount) do
Enum.reduce(budgets, @zero, &Decimal.add(&2, amount.(&1) || @zero))
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ defmodule Spendable.Budgets.Actions.CalculateMonthSummaryTest do
Budgets.create_budget(scope, %{
"name" => "Groceries",
"type" => "envelope",
"budgeted_amount" => "400.00"
"funding_amount" => "400.00"
})

{:ok, transaction} =
Expand All @@ -40,7 +40,7 @@ defmodule Spendable.Budgets.Actions.CalculateMonthSummaryTest do

assert Decimal.eq?(summary.allocated_total, "400.00")
assert Decimal.eq?(summary.spent_total, "30.00")
assert Decimal.eq?(summary.spent[budget_id], "-30.00")
assert Decimal.eq?(summary.spent[budget_id], "30.00")
end

test "any date in a month selects that whole month", %{scope: scope} do
Expand Down Expand Up @@ -82,7 +82,7 @@ defmodule Spendable.Budgets.Actions.CalculateMonthSummaryTest do
summary = Budgets.calculate_month_summary(scope, ~D[2026-08-15])

assert Decimal.eq?(summary.spent_total, "30.00")
assert Decimal.eq?(summary.spent[tracked_id], "-50.00")
assert Decimal.eq?(summary.spent[tracked_id], "50.00")
end

test "narrows the budgets to a search", %{scope: scope} do
Expand Down
28 changes: 28 additions & 0 deletions lib/spendable/budgets/actions/calculate_received.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Spendable.Budgets.Actions.CalculateReceived do
@moduledoc false

import Spendable.Budgets.Utils.SumAllocations

alias Spendable.Scope

@zero Decimal.new("0.00")

@doc """
What each of the given budgets took in over one month, keyed by budget id.

Only an income budget receives. Every other budget spends, and what arrives in one of those is a
refund against its spending rather than money taken in, so it is left out here and comes back at
zero - see `calculate_spent/3`.

The sum is not negated: money arriving is positive, which is the direction an income budget is
read in.
"""
def calculate_received(_scope, [], _month), do: %{}

def calculate_received(%Scope{user: %{id: user_id}}, budgets, month) do
income = Enum.filter(budgets, &(&1.type == :income))
received = sum_allocations(user_id, Enum.map(income, & &1.id), month)

Map.new(budgets, &{&1.id, Map.get(received, &1.id, @zero)})
end
end
Loading
Loading