diff --git a/config/config.exs b/config/config.exs
index 8c8e30e4..f45b4edd 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -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],
diff --git a/lib/spendable/budgets.ex b/lib/spendable/budgets.ex
index f6020aa4..2b6cb0ee 100644
--- a/lib/spendable/budgets.ex
+++ b/lib/spendable/budgets.ex
@@ -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
diff --git a/lib/spendable/budgets/CONTEXT.md b/lib/spendable/budgets/CONTEXT.md
index 127882de..56b2b261 100644
--- a/lib/spendable/budgets/CONTEXT.md
+++ b/lib/spendable/budgets/CONTEXT.md
@@ -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**:
@@ -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
@@ -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
@@ -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.
diff --git a/lib/spendable/budgets/actions/calculate_funded.ex b/lib/spendable/budgets/actions/calculate_funded.ex
new file mode 100644
index 00000000..cb84e839
--- /dev/null
+++ b/lib/spendable/budgets/actions/calculate_funded.ex
@@ -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
diff --git a/lib/spendable/budgets/actions/calculate_funded_test.exs b/lib/spendable/budgets/actions/calculate_funded_test.exs
new file mode 100644
index 00000000..4c03ef61
--- /dev/null
+++ b/lib/spendable/budgets/actions/calculate_funded_test.exs
@@ -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
diff --git a/lib/spendable/budgets/actions/calculate_month_summary.ex b/lib/spendable/budgets/actions/calculate_month_summary.ex
index d47fb8d7..51b35209 100644
--- a/lib/spendable/budgets/actions/calculate_month_summary.ex
+++ b/lib/spendable/budgets/actions/calculate_month_summary.ex
@@ -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
diff --git a/lib/spendable/budgets/actions/calculate_month_summary_test.exs b/lib/spendable/budgets/actions/calculate_month_summary_test.exs
index ee8a967b..4d076e4a 100644
--- a/lib/spendable/budgets/actions/calculate_month_summary_test.exs
+++ b/lib/spendable/budgets/actions/calculate_month_summary_test.exs
@@ -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} =
@@ -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
@@ -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
diff --git a/lib/spendable/budgets/actions/calculate_received.ex b/lib/spendable/budgets/actions/calculate_received.ex
new file mode 100644
index 00000000..ac30e40f
--- /dev/null
+++ b/lib/spendable/budgets/actions/calculate_received.ex
@@ -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
diff --git a/lib/spendable/budgets/actions/calculate_received_test.exs b/lib/spendable/budgets/actions/calculate_received_test.exs
new file mode 100644
index 00000000..1ba96f25
--- /dev/null
+++ b/lib/spendable/budgets/actions/calculate_received_test.exs
@@ -0,0 +1,112 @@
+defmodule Spendable.Budgets.Actions.CalculateReceivedTest do
+ use Spendable.DataCase, async: true
+
+ alias Spendable.Accounts
+ alias Spendable.Budgets
+ alias Spendable.Scope
+ alias Spendable.Transactions
+
+ setup do
+ {:ok, user} =
+ Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})
+
+ scope = Scope.for_user(user)
+
+ {:ok, %{id: salary_id} = salary} =
+ Budgets.create_budget(scope, %{"name" => "Salary", "type" => "income"})
+
+ %{scope: scope, salary: salary, salary_id: salary_id}
+ end
+
+ test "reports what an income budget took in", %{
+ scope: scope,
+ salary: salary,
+ salary_id: salary_id
+ } do
+ {:ok, _paycheck} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Payday",
+ "amount" => "4200.00",
+ "date" => "2026-08-15",
+ "budget_allocations" => %{"0" => %{"amount" => "4200.00", "budget_id" => salary_id}}
+ })
+
+ assert %{^salary_id => received} = Budgets.calculate_received(scope, [salary], ~D[2026-08-15])
+ assert Decimal.eq?(received, "4200.00")
+ end
+
+ test "keeps each source apart", %{scope: scope, salary: salary, salary_id: salary_id} do
+ {:ok, %{id: rewards_id} = rewards} =
+ Budgets.create_budget(scope, %{"name" => "Card Rewards", "type" => "income"})
+
+ {:ok, _paycheck} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Payday",
+ "amount" => "4200.00",
+ "date" => "2026-08-15",
+ "budget_allocations" => %{"0" => %{"amount" => "4200.00", "budget_id" => salary_id}}
+ })
+
+ {:ok, _cashback} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Cashback",
+ "amount" => "40.00",
+ "date" => "2026-08-16",
+ "budget_allocations" => %{"0" => %{"amount" => "40.00", "budget_id" => rewards_id}}
+ })
+
+ received = Budgets.calculate_received(scope, [salary, rewards], ~D[2026-08-15])
+
+ assert Decimal.eq?(received[salary_id], "4200.00")
+ assert Decimal.eq?(received[rewards_id], "40.00")
+ end
+
+ # A refund landing in an envelope is money off that month's spending, not money taken in.
+ test "leaves a budget that spends out of what was received", %{scope: scope} do
+ {:ok, %{id: groceries_id} = groceries} = Budgets.create_budget(scope, %{"name" => "Groceries"})
+
+ {:ok, _refund} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Returned",
+ "amount" => "20.00",
+ "date" => "2026-08-15",
+ "budget_allocations" => %{"0" => %{"amount" => "20.00", "budget_id" => groceries_id}}
+ })
+
+ assert %{^groceries_id => received} =
+ Budgets.calculate_received(scope, [groceries], ~D[2026-08-15])
+
+ assert Decimal.eq?(received, "0.00")
+ end
+
+ test "reports zero for a month nothing arrived in", %{
+ scope: scope,
+ salary: salary,
+ salary_id: salary_id
+ } do
+ assert %{^salary_id => received} = Budgets.calculate_received(scope, [salary], ~D[2026-07-01])
+ assert Decimal.eq?(received, "0.00")
+ end
+
+ test "returns nothing when given no budgets", %{scope: scope} do
+ assert %{} == Budgets.calculate_received(scope, [], ~D[2026-08-15])
+ end
+
+ test "leaves another user's income out", %{scope: scope, salary: salary, salary_id: salary_id} do
+ {:ok, other_user} =
+ Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})
+
+ {:ok, _paycheck} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Payday",
+ "amount" => "4200.00",
+ "date" => "2026-08-15",
+ "budget_allocations" => %{"0" => %{"amount" => "4200.00", "budget_id" => salary_id}}
+ })
+
+ assert %{^salary_id => received} =
+ Budgets.calculate_received(Scope.for_user(other_user), [salary], ~D[2026-08-15])
+
+ assert Decimal.eq?(received, "0.00")
+ end
+end
diff --git a/lib/spendable/budgets/actions/calculate_spendable.ex b/lib/spendable/budgets/actions/calculate_spendable.ex
index 515a6ba9..9fe877b9 100644
--- a/lib/spendable/budgets/actions/calculate_spendable.ex
+++ b/lib/spendable/budgets/actions/calculate_spendable.ex
@@ -2,10 +2,10 @@ defmodule Spendable.Budgets.Actions.CalculateSpendable do
@moduledoc false
import Ecto.Query
+ import Spendable.Budgets.Utils.CalculateBalances
alias Spendable.Banks.Schemas.BankAccount
alias Spendable.Budgets.Schemas.Budget
- alias Spendable.Budgets.Schemas.BudgetAllocation
alias Spendable.Repo
alias Spendable.Scope
@@ -14,10 +14,15 @@ defmodule Spendable.Budgets.Actions.CalculateSpendable do
@doc """
Money in synced accounts that no budget has claimed.
- Tracking budgets are skipped because they record spending without reserving anything, and a
- budget backed by a bank account is skipped because its balance is that account's, not a claim
- on the pool. Allocations belonging to an excluded transaction or to a transfer are left out:
- neither is a claim on the pool either.
+ Tracking and income budgets are skipped because they record a month without holding anything,
+ and a budget backed by a bank account is skipped because its balance is that account's, not a
+ claim on the pool. What is left claims its balance, which is what the user has already spoken
+ for - so an envelope filling itself shrinks this figure, and this figure going negative says
+ the budgets promise more than the accounts hold.
+
+ A claim is signed. An overspent envelope has already borrowed from the pool, and the money it
+ overspent has already left the accounts, so its shortfall adds back rather than subtracting a
+ second time. That keeps `accounts = budgets + spendable` true.
"""
def calculate_spendable(%Scope{user: %{id: user_id}}) do
balance =
@@ -29,30 +34,21 @@ defmodule Spendable.Budgets.Actions.CalculateSpendable do
|> Repo.aggregate(:sum, :balance)
|> Kernel.||(@zero)
- allocations =
- from(allocation in BudgetAllocation,
- join: transaction in assoc(allocation, :transaction),
- where: allocation.user_id == ^user_id,
- where: not transaction.excluded,
- where: is_nil(transaction.transfer_id),
- select: %{budget_id: allocation.budget_id, allocated: sum(allocation.amount)},
- group_by: allocation.budget_id
- )
-
- allocated =
- from(allocation in subquery(allocations),
- full_join: budget in Budget,
- on: allocation.budget_id == budget.id,
- left_join: account in BankAccount,
- on: budget.id == account.budget_id,
- select: fragment("SUM(ABS(COALESCE(?, 0) + ?))", allocation.allocated, budget.adjustment),
- where: budget.user_id == ^user_id,
- where: budget.type != :tracking,
- where: is_nil(account.id)
- )
- |> Repo.one()
- |> Kernel.||(@zero)
+ Decimal.sub(balance, claimed(user_id))
+ end
- Decimal.sub(balance, allocated)
+ # Read through `calculate_balances/1` rather than re-summing here, so a budget's claim on the
+ # pool and the balance the user is shown can never be computed two different ways.
+ defp claimed(user_id) do
+ from(budget in Budget,
+ left_join: account in BankAccount,
+ on: account.budget_id == budget.id,
+ where: budget.user_id == ^user_id,
+ where: budget.type not in [:tracking, :income],
+ where: is_nil(account.id)
+ )
+ |> Repo.all()
+ |> calculate_balances()
+ |> Enum.reduce(@zero, &Decimal.add(&2, &1.balance))
end
end
diff --git a/lib/spendable/budgets/actions/calculate_spendable_test.exs b/lib/spendable/budgets/actions/calculate_spendable_test.exs
index 9307d66e..8acd4a0f 100644
--- a/lib/spendable/budgets/actions/calculate_spendable_test.exs
+++ b/lib/spendable/budgets/actions/calculate_spendable_test.exs
@@ -32,6 +32,32 @@ defmodule Spendable.Budgets.Actions.CalculateSpendableTest do
assert Decimal.eq?(Budgets.calculate_spendable(scope), "0.00")
end
+ test "ignores income budgets", %{scope: scope} do
+ {:ok, salary} = Budgets.create_budget(scope, %{"name" => "Salary", "type" => "income"})
+
+ {:ok, _paycheck} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Payday",
+ "amount" => "4200.00",
+ "date" => "2026-08-15",
+ "budget_allocations" => %{"0" => %{"amount" => "4200.00", "budget_id" => salary.id}}
+ })
+
+ assert Decimal.eq?(Budgets.calculate_spendable(scope), "0.00")
+ end
+
+ # An overspent envelope has borrowed against the pool, and that money already left the bank.
+ # Counting the shortfall as a claim would take it off a second time.
+ test "adds back what an overspent envelope is short", %{scope: scope} do
+ {:ok, groceries} = Budgets.create_budget(scope, %{"name" => "Groceries"})
+ {:ok, _adjusted} = Budgets.update_budget(scope, groceries, %{"balance" => "300.00"})
+
+ {:ok, rent} = Budgets.create_budget(scope, %{"name" => "Rent"})
+ {:ok, _overspent} = Budgets.update_budget(scope, rent, %{"balance" => "-50.00"})
+
+ assert Decimal.eq?(Budgets.calculate_spendable(scope), "-250.00")
+ end
+
test "ignores other users' budgets", %{scope: scope} do
{:ok, other_user} =
Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})
diff --git a/lib/spendable/budgets/actions/calculate_spent.ex b/lib/spendable/budgets/actions/calculate_spent.ex
index 5075e233..d25ef465 100644
--- a/lib/spendable/budgets/actions/calculate_spent.ex
+++ b/lib/spendable/budgets/actions/calculate_spent.ex
@@ -1,46 +1,37 @@
defmodule Spendable.Budgets.Actions.CalculateSpent do
@moduledoc false
- import Ecto.Query
+ import Spendable.Budgets.Utils.SumAllocations
- alias Spendable.Budgets.Schemas.BudgetAllocation
- alias Spendable.Repo
alias Spendable.Scope
- alias Spendable.Transactions.Schemas.Transaction
@zero Decimal.new("0.00")
@doc """
What each of the given budgets was spent against in one month, keyed by budget id.
- Only outgoing allocations count as spending, and an excluded transaction never does. Returns a
- map rather than decorating the budgets: spending belongs to a month, not to a budget, so a
- budget struct is the wrong place to keep it. Every id asked for comes back, at zero if unspent.
+ Every positive allocation reduces spending: money coming back to a budget cancels money that
+ went out of it, so a reimbursement settles the spend it repays and a refund reduces it. That
+ holds whatever the money was - a budget records what it is left holding, not where the money
+ came from.
+
+ An income budget records money arriving and never spends, so it is left out entirely and comes
+ back at zero. `calculate_received/3` is what reads those. An excluded transaction never counts.
+
+ Returns a map rather than decorating the budgets: spending belongs to a month, not to a budget,
+ so a budget struct is the wrong place to keep it. Every id asked for comes back, at zero if
+ nothing moved.
"""
def calculate_spent(_scope, [], _month), do: %{}
def calculate_spent(%Scope{user: %{id: user_id}}, budgets, month) do
- start_date = Date.beginning_of_month(month)
- end_date = Date.end_of_month(month)
- budget_ids = Enum.map(budgets, & &1.id)
+ spending = Enum.reject(budgets, &(&1.type == :income))
spent =
- from(allocation in BudgetAllocation,
- join: transaction in Transaction,
- on: allocation.transaction_id == transaction.id,
- select: {allocation.budget_id, coalesce(sum(allocation.amount), ^@zero)},
- where: allocation.user_id == ^user_id,
- where: allocation.budget_id in ^budget_ids,
- where: transaction.date >= ^start_date,
- where: transaction.date <= ^end_date,
- where: not transaction.excluded,
- where: is_nil(transaction.transfer_id),
- where: allocation.amount < 0,
- group_by: allocation.budget_id
- )
- |> Repo.all()
- |> Map.new()
-
- Map.new(budget_ids, &{&1, Map.get(spent, &1, @zero)})
+ user_id
+ |> sum_allocations(Enum.map(spending, & &1.id), month)
+ |> Map.new(fn {budget_id, net} -> {budget_id, Decimal.negate(net)} end)
+
+ Map.new(budgets, &{&1.id, Map.get(spent, &1.id, @zero)})
end
end
diff --git a/lib/spendable/budgets/actions/calculate_spent_test.exs b/lib/spendable/budgets/actions/calculate_spent_test.exs
index cc371635..8c56deb6 100644
--- a/lib/spendable/budgets/actions/calculate_spent_test.exs
+++ b/lib/spendable/budgets/actions/calculate_spent_test.exs
@@ -29,6 +29,95 @@ defmodule Spendable.Budgets.Actions.CalculateSpentTest do
assert %{} == Budgets.calculate_spent(scope, [], Date.utc_today())
end
+ test "nets money coming back against money that went out", %{
+ scope: scope,
+ budget: budget,
+ budget_id: budget_id
+ } do
+ {:ok, _spend} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Dinner for the table",
+ "amount" => "-200.00",
+ "date" => "2026-08-15",
+ "budget_allocations" => %{"0" => %{"amount" => "-200.00", "budget_id" => budget_id}}
+ })
+
+ {:ok, _reimbursement} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Paid back",
+ "amount" => "200.00",
+ "date" => "2026-08-16",
+ "budget_allocations" => %{"0" => %{"amount" => "200.00", "budget_id" => budget_id}}
+ })
+
+ assert %{^budget_id => spent} = Budgets.calculate_spent(scope, [budget], ~D[2026-08-15])
+ assert Decimal.eq?(spent, "0.00")
+ end
+
+ test "nets a partial refund", %{scope: scope, budget: budget, budget_id: budget_id} do
+ {:ok, _spend} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Groceries",
+ "amount" => "-200.00",
+ "date" => "2026-08-15",
+ "budget_allocations" => %{"0" => %{"amount" => "-200.00", "budget_id" => budget_id}}
+ })
+
+ {:ok, _refund} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Returned the bad melon",
+ "amount" => "5.00",
+ "date" => "2026-08-16",
+ "budget_allocations" => %{"0" => %{"amount" => "5.00", "budget_id" => budget_id}}
+ })
+
+ assert %{^budget_id => spent} = Budgets.calculate_spent(scope, [budget], ~D[2026-08-15])
+ assert Decimal.eq?(spent, "195.00")
+ end
+
+ # An income budget records money arriving and never spends, so it has no figure here at all.
+ test "leaves an income budget out of spending", %{scope: scope} do
+ {:ok, %{id: salary_id} = salary} =
+ Budgets.create_budget(scope, %{"name" => "Salary", "type" => "income"})
+
+ {:ok, _paycheck} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Payday",
+ "amount" => "4200.00",
+ "date" => "2026-08-15",
+ "budget_allocations" => %{"0" => %{"amount" => "4200.00", "budget_id" => salary_id}}
+ })
+
+ assert %{^salary_id => spent} = Budgets.calculate_spent(scope, [salary], ~D[2026-08-15])
+ assert Decimal.eq?(spent, "0.00")
+ end
+
+ # Money arriving in a spending budget is a refund, whatever it was, so it comes off the month.
+ test "reduces spending by money arriving, even a paycheck", %{
+ scope: scope,
+ budget: budget,
+ budget_id: budget_id
+ } do
+ {:ok, _spend} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Market",
+ "amount" => "-300.00",
+ "date" => "2026-08-15",
+ "budget_allocations" => %{"0" => %{"amount" => "-300.00", "budget_id" => budget_id}}
+ })
+
+ {:ok, _pay} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Payday",
+ "amount" => "500.00",
+ "date" => "2026-08-16",
+ "budget_allocations" => %{"0" => %{"amount" => "500.00", "budget_id" => budget_id}}
+ })
+
+ assert %{^budget_id => spent} = Budgets.calculate_spent(scope, [budget], ~D[2026-08-15])
+ assert Decimal.eq?(spent, "-200.00")
+ end
+
test "ignores a transfer", %{scope: scope, budget: budget, budget_id: budget_id} do
{:ok, out} =
Transactions.create_transaction(scope, %{
diff --git a/lib/spendable/budgets/actions/create_budget.ex b/lib/spendable/budgets/actions/create_budget.ex
index ac262c2a..bd47ee30 100644
--- a/lib/spendable/budgets/actions/create_budget.ex
+++ b/lib/spendable/budgets/actions/create_budget.ex
@@ -3,6 +3,7 @@ defmodule Spendable.Budgets.Actions.CreateBudget do
import Spendable.Budgets.Utils.CalculateBalances
+ alias Spendable.Budgets
alias Spendable.Budgets.Schemas.Budget
alias Spendable.Repo
alias Spendable.Scope
@@ -10,14 +11,31 @@ defmodule Spendable.Budgets.Actions.CreateBudget do
@doc """
The balance is filled in on the way out, the same as `update_budget/3` does, so a caller never
reads a budget whose virtual balance is missing.
+
+ A budget that funds itself is funded before that balance is read, so it appears holding its
+ first month rather than empty until the nightly job comes round.
"""
- def create_budget(%Scope{user: %{id: user_id}}, attrs) do
+ def create_budget(%Scope{user: %{id: user_id}} = scope, attrs) do
%Budget{user_id: user_id}
|> Budget.changeset(attrs)
|> Repo.insert()
|> case do
- {:ok, budget} -> {:ok, calculate_balance(budget)}
- {:error, changeset} -> {:error, changeset}
+ {:ok, budget} ->
+ funded = fund_this_month(budget, scope)
+
+ {:ok, calculate_balance(funded)}
+
+ {:error, changeset} ->
+ {:error, changeset}
end
end
+
+ # Funding a month is idempotent, so this only ever fills what this month has not filled yet.
+ defp fund_this_month(%Budget{funding_amount: nil} = budget, _scope), do: budget
+
+ defp fund_this_month(%Budget{} = budget, scope) do
+ {:ok, _funded} = Budgets.fund_budgets(scope, Date.utc_today())
+
+ budget
+ end
end
diff --git a/lib/spendable/budgets/actions/create_budget_test.exs b/lib/spendable/budgets/actions/create_budget_test.exs
index 7cae6953..31aef2ec 100644
--- a/lib/spendable/budgets/actions/create_budget_test.exs
+++ b/lib/spendable/budgets/actions/create_budget_test.exs
@@ -47,6 +47,51 @@ defmodule Spendable.Budgets.Actions.CreateBudgetTest do
assert Decimal.eq?(budgeted_amount, "500.00")
end
+ test "accepts a funding amount", %{scope: scope} do
+ assert {:ok, %Budget{funding_amount: funding_amount}} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ assert Decimal.eq?(funding_amount, "300.00")
+ end
+
+ test "leaves the funding amount unset, so a budget does not fund itself by default", %{scope: scope} do
+ assert {:ok, %Budget{funding_amount: nil}} = Budgets.create_budget(scope, %{"name" => "Groceries"})
+ end
+
+ test "accepts the income type, for money arriving", %{scope: scope} do
+ assert {:ok, %Budget{type: :income}} =
+ Budgets.create_budget(scope, %{"name" => "Card Rewards", "type" => "income"})
+ end
+
+ test "fills itself straight away when it funds itself", %{scope: scope} do
+ {:ok, budget} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ assert Decimal.eq?(budget.balance, "300.00")
+ end
+
+ test "refuses a funding amount on a budget that keeps no balance", %{scope: scope} do
+ for type <- ["tracking", "income"] do
+ assert {:ok, %Budget{funding_amount: nil}} =
+ Budgets.create_budget(scope, %{
+ "name" => "Rewards #{type}",
+ "type" => type,
+ "funding_amount" => "300.00"
+ })
+ end
+ end
+
+ test "accepts a budgeted amount on a tracking budget", %{scope: scope} do
+ assert {:ok, %Budget{type: :tracking, budgeted_amount: budgeted_amount}} =
+ Budgets.create_budget(scope, %{
+ "name" => "Dining",
+ "type" => "tracking",
+ "budgeted_amount" => "200.00"
+ })
+
+ assert Decimal.eq?(budgeted_amount, "200.00")
+ end
+
test "comes back with its balance filled in", %{scope: scope} do
{:ok, budget} = Budgets.create_budget(scope, %{"name" => "Holiday", "balance" => "500.00"})
diff --git a/lib/spendable/budgets/actions/fund_budgets.ex b/lib/spendable/budgets/actions/fund_budgets.ex
new file mode 100644
index 00000000..2f3a49ca
--- /dev/null
+++ b/lib/spendable/budgets/actions/fund_budgets.ex
@@ -0,0 +1,65 @@
+defmodule Spendable.Budgets.Actions.FundBudgets do
+ @moduledoc false
+
+ import Ecto.Query
+ import Spendable.Budgets.Utils.CalculateBalances
+
+ alias Spendable.Budgets.Schemas.Budget
+ alias Spendable.Budgets.Schemas.Funding
+ alias Spendable.Repo
+ alias Spendable.Scope
+
+ @doc """
+ Gives every budget that funds itself its monthly amount, and returns how many were funded.
+
+ This is what replaces dividing a paycheck by hand: the user says once what a budget should hold
+ each month, and the month fills it. Only a budget that keeps a balance can be funded - tracking
+ and income record a month and hold nothing, so there is nowhere for the money to land.
+
+ What a month puts in depends on whether the budget rolls over:
+
+ * rolling over, it puts in the funding amount flat. An envelope 50 short starts the month at
+ 250 rather than 300, because the overspend is a real hole and carrying it is the point.
+ * not rolling over, it puts in whatever brings the balance back to the funding amount. The
+ same envelope gets 350 and starts whole, and one with 100 left over gets 200 rather than
+ keeping it.
+
+ Safe to run repeatedly. The unique index on the month is what makes that true, so a job that
+ runs daily funds the month on its first run and does nothing on the rest.
+ """
+ def fund_budgets(%Scope{user: %{id: user_id}}, %Date{} = month) do
+ month = Date.beginning_of_month(month)
+ now = DateTime.utc_now()
+
+ rows =
+ from(budget in Budget,
+ where: budget.user_id == ^user_id,
+ where: budget.type in [:envelope, :goal],
+ where: not is_nil(budget.funding_amount),
+ where: is_nil(budget.archived_at)
+ )
+ |> Repo.all()
+ |> calculate_balances()
+ |> Enum.map(
+ &%{
+ id: UXID.generate!(prefix: "fnd"),
+ amount: amount(&1),
+ month: month,
+ budget_id: &1.id,
+ user_id: user_id,
+ inserted_at: now,
+ updated_at: now
+ }
+ )
+
+ {funded, _returned} = Repo.insert_all(Funding, rows, on_conflict: :nothing)
+
+ {:ok, funded}
+ end
+
+ defp amount(%Budget{rollover: true} = budget), do: budget.funding_amount
+
+ # The balance already counts this month if it has been funded, which would make the top-up read
+ # as zero - harmless, because the unique index drops the row before it is written.
+ defp amount(%Budget{} = budget), do: Decimal.sub(budget.funding_amount, budget.balance)
+end
diff --git a/lib/spendable/budgets/actions/fund_budgets_test.exs b/lib/spendable/budgets/actions/fund_budgets_test.exs
new file mode 100644
index 00000000..d19043f9
--- /dev/null
+++ b/lib/spendable/budgets/actions/fund_budgets_test.exs
@@ -0,0 +1,207 @@
+defmodule Spendable.Budgets.Actions.FundBudgetsTest do
+ use Spendable.DataCase, async: true
+
+ alias Spendable.Accounts
+ alias Spendable.Budgets
+ alias Spendable.Scope
+ alias Spendable.Transactions
+
+ # Months well behind the current one, so what a self-funding budget puts into today's month on
+ # creation never collides with the month a test is funding on purpose.
+ @month ~D[2020-05-01]
+ @earlier ~D[2020-04-01]
+ @next_month ~D[2020-06-01]
+
+ setup do
+ {:ok, user} =
+ Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})
+
+ %{scope: Scope.for_user(user)}
+ end
+
+ test "puts a budget's funding amount into the month", %{scope: scope} do
+ {:ok, %{id: budget_id} = budget} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ assert {:ok, 1} = Budgets.fund_budgets(scope, @month)
+
+ assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], @month)
+ assert Decimal.eq?(funded, "300.00")
+ end
+
+ test "funds a month once, however many times it runs", %{scope: scope} do
+ {:ok, _budget} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ assert {:ok, 1} = Budgets.fund_budgets(scope, @month)
+ assert {:ok, 0} = Budgets.fund_budgets(scope, @month)
+ assert {:ok, 0} = Budgets.fund_budgets(scope, ~D[2020-05-27])
+ end
+
+ test "funds each month it is asked for, so a balance rolls up", %{scope: scope} do
+ {:ok, budget} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ assert Decimal.eq?(budget.balance, "300.00")
+
+ assert {:ok, 1} = Budgets.fund_budgets(scope, @earlier)
+ assert {:ok, 1} = Budgets.fund_budgets(scope, @month)
+
+ {:ok, filled} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(filled.balance, "900.00")
+ end
+
+ test "skips a budget with no funding amount", %{scope: scope} do
+ {:ok, _budget} = Budgets.create_budget(scope, %{"name" => "Groceries"})
+
+ assert {:ok, 0} = Budgets.fund_budgets(scope, @month)
+ end
+
+ test "skips budgets that keep no balance", %{scope: scope} do
+ {:ok, _tracking} =
+ Budgets.create_budget(scope, %{
+ "name" => "Fuel",
+ "type" => "tracking",
+ "budgeted_amount" => "80.00"
+ })
+
+ {:ok, _income} =
+ Budgets.create_budget(scope, %{
+ "name" => "Salary",
+ "type" => "income",
+ "budgeted_amount" => "4200.00"
+ })
+
+ assert {:ok, 0} = Budgets.fund_budgets(scope, @month)
+ end
+
+ test "funds a goal toward its target", %{scope: scope} do
+ {:ok, budget} =
+ Budgets.create_budget(scope, %{
+ "name" => "Holiday",
+ "type" => "goal",
+ "budgeted_amount" => "2000.00",
+ "funding_amount" => "50.00"
+ })
+
+ assert {:ok, 1} = Budgets.fund_budgets(scope, @month)
+
+ {:ok, filled} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(filled.balance, "100.00")
+ end
+
+ test "skips an archived budget", %{scope: scope} do
+ {:ok, budget} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ {:ok, _archived} = Budgets.archive_budget(scope, budget)
+
+ assert {:ok, 0} = Budgets.fund_budgets(scope, @month)
+ end
+
+ test "leaves another user's budgets alone", %{scope: scope} do
+ {:ok, other_user} =
+ Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})
+
+ {:ok, _theirs} =
+ Budgets.create_budget(Scope.for_user(other_user), %{
+ "name" => "Theirs",
+ "funding_amount" => "300.00"
+ })
+
+ assert {:ok, 0} = Budgets.fund_budgets(scope, @month)
+ end
+
+ test "takes the funded money out of what is left to spend", %{scope: scope} do
+ {:ok, _budget} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ assert Decimal.eq?(Budgets.calculate_spendable(scope), "-300.00")
+ end
+
+ test "carries an overspend into the next month when it rolls over", %{scope: scope} do
+ {:ok, budget} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ {:ok, _spend} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Market",
+ "amount" => "-350.00",
+ "date" => Date.utc_today(),
+ "budget_allocations" => %{"0" => %{"amount" => "-350.00", "budget_id" => budget.id}}
+ })
+
+ {:ok, overspent} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(overspent.balance, "-50.00")
+
+ assert {:ok, 1} = Budgets.fund_budgets(scope, @next_month)
+
+ {:ok, funded} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(funded.balance, "250.00")
+ end
+
+ test "tops an overspend back up when it does not roll over", %{scope: scope} do
+ {:ok, %{id: budget_id} = budget} =
+ Budgets.create_budget(scope, %{
+ "name" => "Groceries",
+ "funding_amount" => "300.00",
+ "rollover" => "false"
+ })
+
+ {:ok, _spend} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Market",
+ "amount" => "-350.00",
+ "date" => Date.utc_today(),
+ "budget_allocations" => %{"0" => %{"amount" => "-350.00", "budget_id" => budget.id}}
+ })
+
+ assert {:ok, 1} = Budgets.fund_budgets(scope, @next_month)
+
+ {:ok, funded} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(funded.balance, "300.00")
+
+ assert %{^budget_id => topped_up} = Budgets.calculate_funded(scope, [budget], @next_month)
+ assert Decimal.eq?(topped_up, "350.00")
+ end
+
+ test "does not let leftover accumulate when it does not roll over", %{scope: scope} do
+ {:ok, budget} =
+ Budgets.create_budget(scope, %{
+ "name" => "Groceries",
+ "funding_amount" => "300.00",
+ "rollover" => "false"
+ })
+
+ {:ok, _spend} =
+ Transactions.create_transaction(scope, %{
+ "name" => "Market",
+ "amount" => "-200.00",
+ "date" => Date.utc_today(),
+ "budget_allocations" => %{"0" => %{"amount" => "-200.00", "budget_id" => budget.id}}
+ })
+
+ assert {:ok, 1} = Budgets.fund_budgets(scope, @next_month)
+
+ {:ok, funded} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(funded.balance, "300.00")
+ end
+
+ test "a goal always rolls over, however it is asked", %{scope: scope} do
+ {:ok, budget} =
+ Budgets.create_budget(scope, %{
+ "name" => "Holiday",
+ "type" => "goal",
+ "budgeted_amount" => "2000.00",
+ "funding_amount" => "50.00",
+ "rollover" => "false"
+ })
+
+ assert budget.rollover
+
+ assert {:ok, 1} = Budgets.fund_budgets(scope, @next_month)
+
+ {:ok, funded} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(funded.balance, "100.00")
+ end
+end
diff --git a/lib/spendable/budgets/actions/update_budget.ex b/lib/spendable/budgets/actions/update_budget.ex
index 5f3a0bd8..6e518c80 100644
--- a/lib/spendable/budgets/actions/update_budget.ex
+++ b/lib/spendable/budgets/actions/update_budget.ex
@@ -3,6 +3,7 @@ defmodule Spendable.Budgets.Actions.UpdateBudget do
import Spendable.Budgets.Utils.CalculateBalances
+ alias Spendable.Budgets
alias Spendable.Budgets.Schemas.Budget
alias Spendable.Repo
alias Spendable.Scope
@@ -10,9 +11,12 @@ defmodule Spendable.Budgets.Actions.UpdateBudget do
@doc """
The balance has to be calculated before the changeset runs: the adjustment it writes is the
difference between the requested balance and the current one.
+
+ A budget that funds itself is funded before the balance is read back, so switching funding on
+ shows the month filled rather than waiting for the nightly job.
"""
def update_budget(
- %Scope{user: %{id: user_id}},
+ %Scope{user: %{id: user_id}} = scope,
%Budget{user_id: user_id} = budget,
attrs
) do
@@ -21,10 +25,25 @@ defmodule Spendable.Budgets.Actions.UpdateBudget do
|> Budget.changeset(attrs)
|> Repo.update()
|> case do
- {:ok, updated} -> {:ok, calculate_balance(updated)}
- {:error, changeset} -> {:error, changeset}
+ {:ok, updated} ->
+ funded = fund_this_month(updated, scope)
+
+ {:ok, calculate_balance(funded)}
+
+ {:error, changeset} ->
+ {:error, changeset}
end
end
def update_budget(_scope, _budget, _attrs), do: {:error, :not_authorized}
+
+ # Funding a month is idempotent, so this only ever fills what this month has not filled yet.
+ # Clearing the amount stops future months without unpicking what past months already put in.
+ defp fund_this_month(%Budget{funding_amount: nil} = budget, _scope), do: budget
+
+ defp fund_this_month(%Budget{} = budget, scope) do
+ {:ok, _funded} = Budgets.fund_budgets(scope, Date.utc_today())
+
+ budget
+ end
end
diff --git a/lib/spendable/budgets/actions/update_budget_test.exs b/lib/spendable/budgets/actions/update_budget_test.exs
index 9bcc4fe6..40fdd313 100644
--- a/lib/spendable/budgets/actions/update_budget_test.exs
+++ b/lib/spendable/budgets/actions/update_budget_test.exs
@@ -21,6 +21,29 @@ defmodule Spendable.Budgets.Actions.UpdateBudgetTest do
Budgets.update_budget(scope, budget, %{"name" => "Food"})
end
+ test "fills the month as soon as a budget starts funding itself", %{scope: scope, budget: budget} do
+ assert {:ok, %Budget{balance: balance}} =
+ Budgets.update_budget(scope, budget, %{"funding_amount" => "300.00"})
+
+ assert Decimal.eq?(balance, "300.00")
+ end
+
+ test "does not fund the month twice when something else is edited", %{scope: scope, budget: budget} do
+ {:ok, funding} = Budgets.update_budget(scope, budget, %{"funding_amount" => "300.00"})
+ {:ok, renamed} = Budgets.update_budget(scope, funding, %{"name" => "Food"})
+
+ assert Decimal.eq?(renamed.balance, "300.00")
+ end
+
+ test "stops funding the month when the amount is cleared", %{scope: scope, budget: budget} do
+ {:ok, funding} = Budgets.update_budget(scope, budget, %{"funding_amount" => "300.00"})
+
+ assert {:ok, %Budget{funding_amount: nil, balance: balance}} =
+ Budgets.update_budget(scope, funding, %{"funding_amount" => ""})
+
+ assert Decimal.eq?(balance, "300.00")
+ end
+
# The user sets a balance; the adjustment is what the app derives to make that balance true.
test "writes the adjustment needed to reach a requested balance", %{
scope: scope,
diff --git a/lib/spendable/budgets/jobs/fund_budgets.ex b/lib/spendable/budgets/jobs/fund_budgets.ex
new file mode 100644
index 00000000..d59bac87
--- /dev/null
+++ b/lib/spendable/budgets/jobs/fund_budgets.ex
@@ -0,0 +1,40 @@
+defmodule Spendable.Budgets.Jobs.FundBudgets do
+ @moduledoc """
+ Fills every user's self-funding budgets for the current month.
+
+ Scheduled daily rather than on the first of the month, because funding a month is idempotent:
+ the first run of the month does the work and the rest cost a query. That means a missed run
+ heals itself the next day, and a budget created part-way through a month is funded without a
+ path of its own.
+
+ The month defaults to the UTC date. No user timezone is stored anywhere, so a user far enough
+ west sees a new month begin before their own calendar turns over. Pass `month` in the job args
+ to fund a different one, which is how a month missed while the queue was down gets filled in.
+ """
+
+ use Oban.Worker, queue: :budgets, max_attempts: 3
+
+ alias Spendable.Accounts.Schemas.User
+ alias Spendable.Budgets
+ alias Spendable.Repo
+ alias Spendable.Scope
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: args}) do
+ month = month(args)
+
+ funded =
+ User
+ |> Repo.all()
+ |> Enum.reduce(0, fn user, funded ->
+ {:ok, count} = Budgets.fund_budgets(Scope.for_user(user), month)
+
+ funded + count
+ end)
+
+ {:ok, funded}
+ end
+
+ defp month(%{"month" => month}), do: Date.from_iso8601!(month)
+ defp month(_args), do: Date.utc_today()
+end
diff --git a/lib/spendable/budgets/jobs/fund_budgets_test.exs b/lib/spendable/budgets/jobs/fund_budgets_test.exs
new file mode 100644
index 00000000..b3a9537e
--- /dev/null
+++ b/lib/spendable/budgets/jobs/fund_budgets_test.exs
@@ -0,0 +1,73 @@
+defmodule Spendable.Budgets.Jobs.FundBudgetsTest do
+ use Spendable.DataCase, async: true
+
+ alias Spendable.Accounts
+ alias Spendable.Budgets
+ alias Spendable.Budgets.Jobs.FundBudgets
+ alias Spendable.Scope
+
+ # Behind the current month, which creating a self-funding budget fills on its own. Funding that
+ # month here would say nothing about whether the job did anything.
+ @month "2020-05-01"
+
+ setup do
+ {:ok, user} =
+ Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})
+
+ %{scope: Scope.for_user(user)}
+ end
+
+ test "funds the month it is given, and only once", %{scope: scope} do
+ {:ok, budget} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ assert {:ok, 1} = perform_job(FundBudgets, %{"month" => @month})
+
+ {:ok, filled} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(filled.balance, "600.00")
+
+ # The daily schedule only works because funding a month again costs nothing - otherwise every
+ # run after the first would double what the budgets hold.
+ assert {:ok, 0} = perform_job(FundBudgets, %{"month" => @month})
+
+ {:ok, unchanged} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(unchanged.balance, "600.00")
+ end
+
+ test "funds every user, not just one", %{scope: scope} do
+ {:ok, other_user} =
+ Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})
+
+ {:ok, _mine} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ {:ok, _theirs} =
+ Budgets.create_budget(Scope.for_user(other_user), %{
+ "name" => "Theirs",
+ "funding_amount" => "50.00"
+ })
+
+ assert {:ok, 2} = perform_job(FundBudgets, %{"month" => @month})
+ end
+
+ test "skips a budget that does not fund itself", %{scope: scope} do
+ {:ok, budget} = Budgets.create_budget(scope, %{"name" => "Groceries"})
+
+ assert {:ok, 0} = perform_job(FundBudgets, %{"month" => @month})
+
+ {:ok, unfilled} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(unfilled.balance, "0.00")
+ end
+
+ # What the cron sends. Creating the budget has already filled this month, which is the whole
+ # reason the job is safe to run every day rather than only on the first.
+ test "falls back to the current month when the args say nothing", %{scope: scope} do
+ {:ok, budget} =
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"})
+
+ assert {:ok, 0} = perform_job(FundBudgets, %{})
+
+ {:ok, filled} = Budgets.get_budget(scope, id: budget.id)
+ assert Decimal.eq?(filled.balance, "300.00")
+ end
+end
diff --git a/lib/spendable/budgets/schemas/budget.ex b/lib/spendable/budgets/schemas/budget.ex
index e9c04a3b..25eff163 100644
--- a/lib/spendable/budgets/schemas/budget.ex
+++ b/lib/spendable/budgets/schemas/budget.ex
@@ -4,16 +4,19 @@ defmodule Spendable.Budgets.Schemas.Budget do
alias Spendable.Accounts.Schemas.User
alias Spendable.Budgets.Schemas.BudgetAllocation
+ alias Spendable.Budgets.Schemas.Funding
alias Spendable.Budgets.Schemas.SplitLine
- @types [:tracking, :envelope, :goal]
+ @types [:tracking, :envelope, :goal, :income]
@primary_key {:id, UXID, autogenerate: true, prefix: "bgt"}
schema "budgets" do
field :name, :string
field :adjustment, :decimal, default: Decimal.new("0.00")
field :budgeted_amount, :decimal
+ field :funding_amount, :decimal
field :type, Ecto.Enum, values: @types, default: :envelope
+ field :rollover, :boolean, default: true
field :archived_at, :utc_datetime_usec
# Derived from the allocations rather than stored, so it is filled in on read.
@@ -22,6 +25,7 @@ defmodule Spendable.Budgets.Schemas.Budget do
belongs_to :user, User
has_many :budget_allocations, BudgetAllocation
+ has_many :fundings, Funding
has_many :split_lines, SplitLine
timestamps()
@@ -29,8 +33,17 @@ defmodule Spendable.Budgets.Schemas.Budget do
def changeset(budget \\ %__MODULE__{}, attrs) do
budget
- |> cast(attrs, [:name, :budgeted_amount, :type, :balance])
+ |> cast(attrs, [
+ :name,
+ :budgeted_amount,
+ :funding_amount,
+ :type,
+ :rollover,
+ :balance
+ ])
|> validate_required([:name, :type])
+ |> clear_unused_amounts()
+ |> force_rollover()
|> put_adjustment()
end
@@ -38,6 +51,27 @@ defmodule Spendable.Budgets.Schemas.Budget do
cast(budget, attrs, [:archived_at])
end
+ # Each type carries exactly the amounts it means. An envelope has one - what a month puts in,
+ # which is also what its spending is read against. A goal has two, because a target and a monthly
+ # contribution are different numbers. Tracking and income hold nothing, so they only have a figure
+ # to be measured against.
+ defp clear_unused_amounts(changeset) do
+ case get_field(changeset, :type) do
+ :envelope -> put_change(changeset, :budgeted_amount, nil)
+ :goal -> changeset
+ _holds_nothing -> put_change(changeset, :funding_amount, nil)
+ end
+ end
+
+ # Only an envelope can decline to roll over. A goal accumulates - that is what saving is - and
+ # tracking and income keep no balance for a month to carry in the first place.
+ defp force_rollover(changeset) do
+ case get_field(changeset, :type) do
+ :envelope -> changeset
+ _accumulates -> put_change(changeset, :rollover, true)
+ end
+ end
+
# A user edits the balance, never the adjustment: the adjustment absorbs the gap between
# the balance they asked for and what the allocations already add up to.
defp put_adjustment(changeset) do
diff --git a/lib/spendable/budgets/schemas/funding.ex b/lib/spendable/budgets/schemas/funding.ex
new file mode 100644
index 00000000..17566691
--- /dev/null
+++ b/lib/spendable/budgets/schemas/funding.ex
@@ -0,0 +1,24 @@
+defmodule Spendable.Budgets.Schemas.Funding do
+ @moduledoc """
+ What one month put into one budget.
+
+ There is no changeset: a funding is never written one at a time from user input. `fund_budgets/2`
+ inserts a month for every budget at once, which is what lets the unique index on
+ `[:budget_id, :month]` make funding a month safe to repeat.
+ """
+ use Spendable.Schema
+
+ alias Spendable.Accounts.Schemas.User
+ alias Spendable.Budgets.Schemas.Budget
+
+ @primary_key {:id, UXID, autogenerate: true, prefix: "fnd"}
+ schema "fundings" do
+ field :amount, :decimal
+ field :month, :date
+
+ belongs_to :budget, Budget
+ belongs_to :user, User
+
+ timestamps()
+ end
+end
diff --git a/lib/spendable/budgets/utils/calculate_balances.ex b/lib/spendable/budgets/utils/calculate_balances.ex
index e5d7c4c8..cc5598b4 100644
--- a/lib/spendable/budgets/utils/calculate_balances.ex
+++ b/lib/spendable/budgets/utils/calculate_balances.ex
@@ -6,16 +6,21 @@ defmodule Spendable.Budgets.Utils.CalculateBalances do
alias Spendable.Banks.Schemas.BankAccount
alias Spendable.Budgets.Schemas.Budget
alias Spendable.Budgets.Schemas.BudgetAllocation
+ alias Spendable.Budgets.Schemas.Funding
alias Spendable.Repo
@zero Decimal.new("0.00")
@doc """
- Fills in the virtual balance for a list of budgets in two queries rather than one per budget.
+ Fills in the virtual balance for a list of budgets in three queries rather than one per budget.
A budget backed by a bank account reports that account's balance; every other budget reports
- what its allocations add up to, plus its manual adjustment. Allocations belonging to an
- excluded transaction or to a transfer are left out: neither is money the budget spent.
+ what it has been funded, plus what its allocations add up to, plus its manual adjustment.
+ Funding is what the budget was given and allocations are what it then spent, so a budget funded
+ 300 that spent 140 reads as 160 left, and one that spent 350 reads as 50 short.
+
+ Allocations belonging to an excluded transaction or to a transfer are left out: neither is money
+ the budget spent.
"""
def calculate_balance(%Budget{} = budget) do
[budget] = calculate_balances([budget])
@@ -48,10 +53,23 @@ defmodule Spendable.Budgets.Utils.CalculateBalances do
|> Repo.all()
|> Map.new()
+ funded =
+ from(funding in Funding,
+ select: {funding.budget_id, sum(funding.amount)},
+ group_by: funding.budget_id,
+ where: funding.budget_id in ^budget_ids
+ )
+ |> Repo.all()
+ |> Map.new()
+
Enum.map(budgets, fn budget ->
- from_allocations = allocated |> Map.get(budget.id, @zero) |> Decimal.add(budget.adjustment)
+ from_the_ledger =
+ allocated
+ |> Map.get(budget.id, @zero)
+ |> Decimal.add(Map.get(funded, budget.id, @zero))
+ |> Decimal.add(budget.adjustment)
- %{budget | balance: Map.get(bank_balances, budget.id, from_allocations)}
+ %{budget | balance: Map.get(bank_balances, budget.id, from_the_ledger)}
end)
end
end
diff --git a/lib/spendable/budgets/utils/sum_allocations.ex b/lib/spendable/budgets/utils/sum_allocations.ex
new file mode 100644
index 00000000..601cf75f
--- /dev/null
+++ b/lib/spendable/budgets/utils/sum_allocations.ex
@@ -0,0 +1,40 @@
+defmodule Spendable.Budgets.Utils.SumAllocations do
+ @moduledoc "Import this module rather than aliasing it."
+
+ import Ecto.Query
+
+ alias Spendable.Budgets.Schemas.BudgetAllocation
+ alias Spendable.Repo
+ alias Spendable.Transactions.Schemas.Transaction
+
+ @zero Decimal.new("0.00")
+
+ @doc """
+ What the given budgets' allocations add up to in one month, keyed by budget id.
+
+ Signed and un-negated: money out is negative and money in is positive, so the caller decides
+ which way round the figure reads. Shared so that what counts as a month's movement - and what an
+ excluded transaction or a transfer does not count toward - is written once.
+ """
+ def sum_allocations(_user_id, [], _month), do: %{}
+
+ def sum_allocations(user_id, budget_ids, month) do
+ start_date = Date.beginning_of_month(month)
+ end_date = Date.end_of_month(month)
+
+ from(allocation in BudgetAllocation,
+ join: transaction in Transaction,
+ on: allocation.transaction_id == transaction.id,
+ select: {allocation.budget_id, coalesce(sum(allocation.amount), ^@zero)},
+ where: allocation.user_id == ^user_id,
+ where: allocation.budget_id in ^budget_ids,
+ where: transaction.date >= ^start_date,
+ where: transaction.date <= ^end_date,
+ where: not transaction.excluded,
+ where: is_nil(transaction.transfer_id),
+ group_by: allocation.budget_id
+ )
+ |> Repo.all()
+ |> Map.new()
+ end
+end
diff --git a/lib/spendable/transactions/utils/allocate_spendable.ex b/lib/spendable/transactions/utils/allocate_spendable.ex
index 1728b9be..7a2cb377 100644
--- a/lib/spendable/transactions/utils/allocate_spendable.ex
+++ b/lib/spendable/transactions/utils/allocate_spendable.ex
@@ -26,7 +26,8 @@ defmodule Spendable.Transactions.Utils.AllocateSpendable do
defp put_remainder(changeset) do
user_id = get_field(changeset, :user_id)
- {:ok, spendable} = Budgets.find_or_create_spendable_budget(Scope.for_user(%User{id: user_id}))
+ user = changeset.repo.get!(User, user_id)
+ {:ok, spendable} = Budgets.find_or_create_spendable_budget(Scope.for_user(user))
changeset = load_allocations(changeset)
diff --git a/lib/spendable_web/api/controllers/budget_controller_test.exs b/lib/spendable_web/api/controllers/budget_controller_test.exs
index de3c001e..3a938a33 100644
--- a/lib/spendable_web/api/controllers/budget_controller_test.exs
+++ b/lib/spendable_web/api/controllers/budget_controller_test.exs
@@ -107,9 +107,9 @@ defmodule SpendableWeb.Api.BudgetControllerTest do
test "omitting a field on update leaves it alone", %{conn: conn, scope: scope} do
{:ok, budget} =
- Budgets.create_budget(scope, %{"name" => "Groceries", "budgeted_amount" => "400.00"})
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "400.00"})
- assert %{"name" => "Food", "budgeted_amount" => "400.00"} =
+ assert %{"name" => "Food", "funding_amount" => "400.00"} =
conn
|> patch(~p"/api/budgets/#{budget.id}", %{"name" => "Food"})
|> json_response(200)
@@ -117,11 +117,11 @@ defmodule SpendableWeb.Api.BudgetControllerTest do
test "clearing a budgeted amount is distinct from omitting it", %{conn: conn, scope: scope} do
{:ok, budget} =
- Budgets.create_budget(scope, %{"name" => "Groceries", "budgeted_amount" => "400.00"})
+ Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "400.00"})
- assert %{"budgeted_amount" => nil} =
+ assert %{"funding_amount" => nil} =
conn
- |> patch(~p"/api/budgets/#{budget.id}", %{"budgeted_amount" => nil})
+ |> patch(~p"/api/budgets/#{budget.id}", %{"funding_amount" => nil})
|> json_response(200)
end
diff --git a/lib/spendable_web/api/controllers/budget_summary_controller_test.exs b/lib/spendable_web/api/controllers/budget_summary_controller_test.exs
index 0cac7d1f..b5116a69 100644
--- a/lib/spendable_web/api/controllers/budget_summary_controller_test.exs
+++ b/lib/spendable_web/api/controllers/budget_summary_controller_test.exs
@@ -22,7 +22,7 @@ defmodule SpendableWeb.Api.BudgetSummaryControllerTest do
Budgets.create_budget(scope, %{
"name" => "Groceries",
"type" => "envelope",
- "budgeted_amount" => "400.00"
+ "funding_amount" => "400.00"
})
{:ok, transaction} =
@@ -50,7 +50,7 @@ defmodule SpendableWeb.Api.BudgetSummaryControllerTest do
"month" => "2026-08-01",
"allocated_total" => "400.00",
"spent_total" => "30.00",
- "spent" => %{^budget_id => "-30.00"}
+ "spent" => %{^budget_id => "30.00"}
} = response
assert_schema(response, "BudgetSummary", @api_spec)
@@ -72,7 +72,7 @@ defmodule SpendableWeb.Api.BudgetSummaryControllerTest do
assert %{
"budgets" => [
%{"name" => "Spendable"},
- %{"name" => "Groceries", "budgeted_amount" => "400.00", "balance" => "-30.00"}
+ %{"name" => "Groceries", "funding_amount" => "400.00", "balance" => "370.00"}
]
} = conn |> get(~p"/api/budgets/summary?month=2026-08-01") |> json_response(200)
end
diff --git a/lib/spendable_web/api/schemas/budget.ex b/lib/spendable_web/api/schemas/budget.ex
index ca9f0076..2aa4bec8 100644
--- a/lib/spendable_web/api/schemas/budget.ex
+++ b/lib/spendable_web/api/schemas/budget.ex
@@ -13,15 +13,30 @@ defmodule SpendableWeb.Api.Schemas.Budget do
properties: %{
id: %Schema{type: :string},
name: %Schema{type: :string},
- type: %Schema{type: :string, enum: ["tracking", "envelope", "goal"]},
+ type: %Schema{type: :string, enum: ["tracking", "envelope", "goal", "income"]},
budgeted_amount: %Schema{type: :string, nullable: true},
+ funding_amount: %Schema{
+ type: :string,
+ nullable: true,
+ description: "What the budget puts into itself each month. Null means it does not fund itself."
+ },
balance: %Schema{
type: :string,
- description: "What the allocations add up to, or the bank account's balance when assigned."
+ description: """
+ What the fundings and allocations add up to, or the bank account's balance when assigned.
+ """
+ },
+ rollover: %Schema{
+ type: :boolean,
+ description: """
+ Whether the balance carries into next month. False means the month tops the budget back up
+ to its funding amount instead, so an overspend does not follow it and leftover does not
+ accumulate. Only an envelope can decline to roll over.
+ """
},
archived_at: %Schema{type: :string, format: :"date-time", nullable: true}
},
- required: [:id, :name, :type, :balance]
+ required: [:id, :name, :type, :balance, :rollover]
})
def build(%Spendable.Budgets.Schemas.Budget{} = budget) do
@@ -30,6 +45,8 @@ defmodule SpendableWeb.Api.Schemas.Budget do
name: budget.name,
type: Atom.to_string(budget.type),
budgeted_amount: amount(budget.budgeted_amount),
+ funding_amount: amount(budget.funding_amount),
+ rollover: budget.rollover,
balance: amount(budget.balance),
archived_at: budget.archived_at
}
diff --git a/lib/spendable_web/api/schemas/budget_request.ex b/lib/spendable_web/api/schemas/budget_request.ex
index 7dc2b580..e34aed7d 100644
--- a/lib/spendable_web/api/schemas/budget_request.ex
+++ b/lib/spendable_web/api/schemas/budget_request.ex
@@ -9,14 +9,28 @@ defmodule SpendableWeb.Api.Schemas.BudgetRequest do
description: """
Amounts are decimal strings. `balance` is what the user wants the budget to hold - the server
works out the adjustment that gets it there, so never send `adjustment`.
+
+ `funding_amount` is what the budget puts into itself each month; setting it is what makes a
+ budget fill on its own instead of being fed by hand. Only an envelope or a goal can hold
+ money, so it is ignored on a tracking or income budget.
+
+ `rollover` says whether the balance carries into next month. Send false and each month tops the
+ budget back up to its funding amount instead. Only an envelope can decline to roll over.
""",
type: :object,
properties: %{
name: %Schema{type: :string},
- type: %Schema{type: :string, enum: ["tracking", "envelope", "goal"]},
+ type: %Schema{type: :string, enum: ["tracking", "envelope", "goal", "income"]},
budgeted_amount: %Schema{type: :string, nullable: true},
+ funding_amount: %Schema{type: :string, nullable: true},
+ rollover: %Schema{type: :boolean},
balance: %Schema{type: :string}
},
- example: %{"name" => "Groceries", "type" => "envelope", "budgeted_amount" => "400.00"}
+ example: %{
+ "name" => "Groceries",
+ "type" => "envelope",
+ "budgeted_amount" => "400.00",
+ "funding_amount" => "400.00"
+ }
})
end
diff --git a/lib/spendable_web/api/schemas/budget_summary.ex b/lib/spendable_web/api/schemas/budget_summary.ex
index dec75c16..956806eb 100644
--- a/lib/spendable_web/api/schemas/budget_summary.ex
+++ b/lib/spendable_web/api/schemas/budget_summary.ex
@@ -23,6 +23,8 @@ defmodule SpendableWeb.Api.Schemas.BudgetSummary do
},
spendable: %Schema{type: :string, description: "Synced money no budget has claimed."},
allocated_total: %Schema{type: :string, description: "Budgeted across envelopes."},
+ funded_total: %Schema{type: :string, description: "Put into envelopes this month."},
+ earned_total: %Schema{type: :string, description: "Taken in across income budgets this month."},
spent_total: %Schema{type: :string, description: "Spent across envelopes this month."},
credit_card_balance: %Schema{type: :string},
budgets: %Schema{type: :array, items: Budget},
@@ -31,6 +33,20 @@ defmodule SpendableWeb.Api.Schemas.BudgetSummary do
description: "Spent this month, keyed by budget id. Every listed budget has an entry.",
additionalProperties: %Schema{type: :string}
},
+ received: %Schema{
+ type: :object,
+ description: """
+ Taken in this month, keyed by budget id. Only an income budget receives; every other
+ budget is zero here, and money arriving in one of those is a refund counted against its
+ spending instead.
+ """,
+ additionalProperties: %Schema{type: :string}
+ },
+ funded: %Schema{
+ type: :object,
+ description: "Funded this month, keyed by budget id. Every listed budget has an entry.",
+ additionalProperties: %Schema{type: :string}
+ },
spent_by_month: %Schema{
type: :array,
description: "Newest first, for the month picker.",
@@ -42,10 +58,14 @@ defmodule SpendableWeb.Api.Schemas.BudgetSummary do
:current_month,
:spendable,
:allocated_total,
+ :funded_total,
+ :earned_total,
:spent_total,
:credit_card_balance,
:budgets,
:spent,
+ :received,
+ :funded,
:spent_by_month
]
})
@@ -56,10 +76,14 @@ defmodule SpendableWeb.Api.Schemas.BudgetSummary do
current_month: fields.current_month,
spendable: amount(fields.spendable),
allocated_total: amount(fields.allocated_total),
+ funded_total: amount(fields.funded_total),
+ earned_total: amount(fields.earned_total),
spent_total: amount(fields.spent_total),
credit_card_balance: amount(fields.credit_card_balance),
budgets: Enum.map(fields.budgets, &Budget.build/1),
spent: Map.new(fields.spent, fn {id, spent} -> {id, amount(spent)} end),
+ received: Map.new(fields.received, fn {id, received} -> {id, amount(received)} end),
+ funded: Map.new(fields.funded, fn {id, funded} -> {id, amount(funded)} end),
spent_by_month: Enum.map(fields.spent_by_month, &MonthSpend.build/1)
}
end
diff --git a/lib/spendable_web/live/budgets.ex b/lib/spendable_web/live/budgets.ex
index 8a41029f..4b0ce229 100644
--- a/lib/spendable_web/live/budgets.ex
+++ b/lib/spendable_web/live/budgets.ex
@@ -81,9 +81,15 @@ defmodule SpendableWeb.Live.Budgets do
+
+
+
{Utils.format_currency(@earned_total)}
+
Earned
+
-
{Utils.format_currency(@allocated_total)}
-
Allocated
+
{Utils.format_currency(@funded_total)}
+
Funded
{Utils.format_currency(@spent_total)}
@@ -123,7 +129,7 @@ defmodule SpendableWeb.Live.Budgets do