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

{Utils.format_currency(card.amount)}

@@ -161,15 +167,49 @@ defmodule SpendableWeb.Live.Budgets do type="select" label="Budget Type" field={f[:type]} - options={[{"Envelope", :envelope}, {"Goal", :goal}, {"Track Spending Only", :tracking}]} + options={[ + {"Envelope", :envelope}, + {"Goal", :goal}, + {"Income", :income}, + {"Track Spending Only", :tracking} + ]} + /> + + <.input + :if={budget_type(f) == :envelope} + type="text" + label="Budget Per Month" + field={f[:funding_amount]} /> <.input - :if={f[:type].value != :tracking} + :if={budget_type(f) != :envelope} type="text" - label={if f[:type].value == :envelope, do: "Budgeted Amount", else: "Goal Amount"} + label={amount_label(budget_type(f))} field={f[:budgeted_amount]} /> - <.input :if={f[:type].value != :tracking} type="text" label="Allocated" field={f[:balance]} /> + <.input + :if={budget_type(f) == :goal} + type="text" + label="Monthly Contribution" + field={f[:funding_amount]} + /> + + <.input + :if={budget_type(f) in [:envelope, :goal]} + type="text" + label={if budget_type(f) == :envelope, do: "Remaining", else: "Allocated"} + field={f[:balance]} + /> + + <.input + :if={budget_type(f) == :envelope} + type="checkbox" + label="Carry the balance into next month" + field={f[:rollover]} + />