From 0a20045c98356a250061f9ef2d368711622564af Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 21:19:50 -0600 Subject: [PATCH 1/3] Auto-fund envelopes, and make income its own budget type Budgets fill themselves from a monthly amount instead of the user splitting every paycheck by hand. A Funding records what one month put into one budget; balance is now fundings + allocations + adjustment. The job runs daily rather than monthly, since funding a month is idempotent, so a missed run heals itself. Rollover is per envelope. On, an overspend carries and eats into the next month. Off, the month funds what brings the balance back to the amount, so the envelope starts whole and leftover does not accumulate. Income is a fourth budget type, one per source, so a paycheck and a card reward are never the same figure. Income budgets receive and never spend; every other budget nets positive allocations against its spending, which is what makes a reimbursement cancel the spend it repays. Behaviour changes to expect on deploy: - Netting is retroactive. Any past month where a positive amount was allocated to a spending budget reports less spent than before. - Spendable no longer takes an overspent envelope off the pool twice. Anyone sitting on a negative envelope sees the figure move. - An envelope in the hole reads OVERSPENT with the shortfall as a positive figure, where it used to read LEFT with a minus sign. - rollover is required on the Budget API response. funding_amount ships nil everywhere, so no budget funds itself until the user sets an amount. Co-Authored-By: Claude Opus 5 --- config/config.exs | 7 +- lib/spendable/budgets.ex | 4 + lib/spendable/budgets/CONTEXT.md | 44 +++- .../budgets/actions/calculate_funded.ex | 37 +++ .../budgets/actions/calculate_funded_test.exs | 56 +++++ .../actions/calculate_month_summary.ex | 21 +- .../actions/calculate_month_summary_test.exs | 4 +- .../budgets/actions/calculate_received.ex | 28 +++ .../actions/calculate_received_test.exs | 112 +++++++++ .../budgets/actions/calculate_spendable.ex | 54 ++-- .../actions/calculate_spendable_test.exs | 26 ++ .../budgets/actions/calculate_spent.ex | 45 ++-- .../budgets/actions/calculate_spent_test.exs | 89 +++++++ .../budgets/actions/create_budget.ex | 24 +- .../budgets/actions/create_budget_test.exs | 45 ++++ lib/spendable/budgets/actions/fund_budgets.ex | 65 +++++ .../budgets/actions/fund_budgets_test.exs | 207 +++++++++++++++ .../budgets/actions/update_budget.ex | 25 +- .../budgets/actions/update_budget_test.exs | 23 ++ .../budgets/actions/update_funding.ex | 36 +++ .../budgets/actions/update_funding_test.exs | 71 ++++++ lib/spendable/budgets/jobs/fund_budgets.ex | 40 +++ .../budgets/jobs/fund_budgets_test.exs | 73 ++++++ lib/spendable/budgets/schemas/budget.ex | 35 ++- lib/spendable/budgets/schemas/funding.ex | 36 +++ .../budgets/utils/calculate_balances.ex | 28 ++- .../budgets/utils/sum_allocations.ex | 40 +++ .../transactions/utils/allocate_spendable.ex | 3 +- .../budget_summary_controller_test.exs | 2 +- lib/spendable_web/api/schemas/budget.ex | 23 +- .../api/schemas/budget_request.ex | 18 +- .../api/schemas/budget_summary.ex | 24 ++ lib/spendable_web/live/budgets.ex | 152 +++++++++-- lib/spendable_web/live/budgets_test.exs | 236 +++++++++++++++++- lib/spendable_web/mcp/server.ex | 1 + lib/spendable_web/mcp/tools/create_budget.ex | 34 ++- lib/spendable_web/mcp/tools/fund_budget.ex | 52 ++++ .../mcp/tools/fund_budget_test.exs | 72 ++++++ lib/spendable_web/mcp/tools/update_budget.ex | 31 ++- lib/spendable_web/utils/budget_card.ex | 94 +++++-- lib/spendable_web/utils/budget_card_test.exs | 11 +- mobile/api/lib/src/model/budget.dart | 45 +++- mobile/api/lib/src/model/budget.g.dart | 32 +++ mobile/api/lib/src/model/budget_request.dart | 43 +++- .../api/lib/src/model/budget_request.g.dart | 38 ++- mobile/api/lib/src/model/budget_summary.dart | 68 +++++ .../api/lib/src/model/budget_summary.g.dart | 59 +++++ mobile/api/lib/src/serializers.g.dart | 8 + mobile/lib/budgets/budget_card.dart | 94 +++++-- mobile/lib/budgets/budget_form.dart | 91 ++++++- mobile/lib/budgets/budgets_providers.dart | 4 +- mobile/lib/budgets/budgets_screen.dart | 64 +++-- mobile/lib/design/money_text.dart | 6 + mobile/test/banks/banks_screen_test.dart | 1 + mobile/test/budgets/budget_card_test.dart | 19 +- mobile/test/budgets/budgets_screen_test.dart | 103 +++++++- mobile/test/design/layout_test.dart | 13 +- .../transactions/transaction_detail_test.dart | 3 + .../transactions_screen_test.dart | 3 + .../migrations/20260815165747_baseline.exs | 14 +- .../20260817000113_add_fundings.exs | 23 ++ .../20260817004911_add_budget_rollover.exs | 11 + priv/static/openapi.json | 76 +++++- shared/budget_cards.json | 218 ++++++++++++---- 64 files changed, 2805 insertions(+), 259 deletions(-) create mode 100644 lib/spendable/budgets/actions/calculate_funded.ex create mode 100644 lib/spendable/budgets/actions/calculate_funded_test.exs create mode 100644 lib/spendable/budgets/actions/calculate_received.ex create mode 100644 lib/spendable/budgets/actions/calculate_received_test.exs create mode 100644 lib/spendable/budgets/actions/fund_budgets.ex create mode 100644 lib/spendable/budgets/actions/fund_budgets_test.exs create mode 100644 lib/spendable/budgets/actions/update_funding.ex create mode 100644 lib/spendable/budgets/actions/update_funding_test.exs create mode 100644 lib/spendable/budgets/jobs/fund_budgets.ex create mode 100644 lib/spendable/budgets/jobs/fund_budgets_test.exs create mode 100644 lib/spendable/budgets/schemas/funding.ex create mode 100644 lib/spendable/budgets/utils/sum_allocations.ex create mode 100644 lib/spendable_web/mcp/tools/fund_budget.ex create mode 100644 lib/spendable_web/mcp/tools/fund_budget_test.exs create mode 100644 priv/repo/migrations/20260817000113_add_fundings.exs create mode 100644 priv/repo/migrations/20260817004911_add_budget_rollover.exs 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..837be863 100644 --- a/lib/spendable/budgets.ex +++ b/lib/spendable/budgets.ex @@ -9,7 +9,11 @@ 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 update_funding(scope, budget, month, amount), to: Actions.UpdateFunding 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..2bcfba78 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. _Avoid_: Target, limit, cap +**Funding Amount**: +What a **Budget** puts into itself each month. +_Avoid_: Contribution, auto-fill, monthly target + +**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 +- Only an **Envelope** or a **Goal** has a **Funding Amount** +- 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..d67b4c62 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), + 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..5fd7cac0 100644 --- a/lib/spendable/budgets/actions/calculate_month_summary_test.exs +++ b/lib/spendable/budgets/actions/calculate_month_summary_test.exs @@ -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/actions/update_funding.ex b/lib/spendable/budgets/actions/update_funding.ex new file mode 100644 index 00000000..4f54cccc --- /dev/null +++ b/lib/spendable/budgets/actions/update_funding.ex @@ -0,0 +1,36 @@ +defmodule Spendable.Budgets.Actions.UpdateFunding do + @moduledoc false + + alias Spendable.Budgets.Schemas.Budget + alias Spendable.Budgets.Schemas.Funding + alias Spendable.Repo + alias Spendable.Scope + + @doc """ + Sets what one month put into one budget, whether or not the month funded it already. + + This is how a user deviates for a single month - "only 200 into groceries this time" - without + changing what the budget funds itself with every other month. Setting it to zero is how a month + is skipped, which is not the same as never having been funded: the row records the decision. + """ + def update_funding( + %Scope{user: %{id: user_id}}, + %Budget{user_id: user_id} = budget, + %Date{} = month, + amount + ) do + month = Date.beginning_of_month(month) + + case Repo.get_by(Funding, budget_id: budget.id, month: month, user_id: user_id) do + %Funding{} = funding -> + funding |> Funding.changeset(%{amount: amount}) |> Repo.update() + + nil -> + %Funding{user_id: user_id} + |> Funding.changeset(%{amount: amount, month: month, budget_id: budget.id}) + |> Repo.insert() + end + end + + def update_funding(_scope, _budget, _month, _amount), do: {:error, :not_authorized} +end diff --git a/lib/spendable/budgets/actions/update_funding_test.exs b/lib/spendable/budgets/actions/update_funding_test.exs new file mode 100644 index 00000000..a91f94e7 --- /dev/null +++ b/lib/spendable/budgets/actions/update_funding_test.exs @@ -0,0 +1,71 @@ +defmodule Spendable.Budgets.Actions.UpdateFundingTest do + use Spendable.DataCase, async: true + + alias Spendable.Accounts + alias Spendable.Budgets + alias Spendable.Budgets.Schemas.Funding + alias Spendable.Scope + + # Behind the current month, so creating a self-funding budget does not fund these as a side + # effect and every figure below is one this test put there. + @month ~D[2020-05-01] + @earlier ~D[2020-04-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 "funds a month that was never funded", %{scope: scope, budget: budget, budget_id: budget_id} do + assert {:ok, %Funding{id: "fnd_" <> _uxid, month: @month}} = + Budgets.update_funding(scope, budget, ~D[2020-05-15], "200.00") + + assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], @month) + assert Decimal.eq?(funded, "200.00") + end + + test "replaces what the month was already funded with", %{scope: scope, budget: budget, budget_id: budget_id} do + {:ok, 1} = Budgets.fund_budgets(scope, @month) + {:ok, _funding} = Budgets.update_funding(scope, budget, @month, "200.00") + + assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], @month) + assert Decimal.eq?(funded, "200.00") + end + + test "leaves every other month alone", %{scope: scope, budget: budget, budget_id: budget_id} do + {:ok, 1} = Budgets.fund_budgets(scope, @earlier) + {:ok, 1} = Budgets.fund_budgets(scope, @month) + {:ok, _funding} = Budgets.update_funding(scope, budget, @month, "0.00") + + assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], @earlier) + assert Decimal.eq?(funded, "300.00") + end + + test "records a skipped month rather than removing it", %{scope: scope, budget: budget} do + {:ok, %Funding{amount: amount}} = Budgets.update_funding(scope, budget, @month, "0.00") + + assert Decimal.eq?(amount, "0.00") + assert {:ok, 0} = Budgets.fund_budgets(scope, @month) + end + + test "refuses a budget belonging to someone else", %{budget: budget} do + {:ok, other_user} = + Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) + + assert {:error, :not_authorized} = + Budgets.update_funding(Scope.for_user(other_user), budget, @month, "200.00") + end + + test "errors without an amount", %{scope: scope, budget: budget} do + assert {:error, changeset} = Budgets.update_funding(scope, budget, @month, nil) + + assert %{amount: ["can't be blank"]} = errors_on(changeset) + end +end 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..3cc667e1 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_funding_amount() + |> force_rollover() |> put_adjustment() end @@ -38,6 +51,24 @@ defmodule Spendable.Budgets.Schemas.Budget do cast(budget, attrs, [:archived_at]) end + # Only a budget that holds money can fund itself. Tracking and income record a month and keep no + # balance, so a funding amount on either would be money with nowhere to land. + defp clear_funding_amount(changeset) do + case get_field(changeset, :type) do + type when type in [:tracking, :income] -> put_change(changeset, :funding_amount, nil) + _holds_money -> changeset + 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..373b0347 --- /dev/null +++ b/lib/spendable/budgets/schemas/funding.ex @@ -0,0 +1,36 @@ +defmodule Spendable.Budgets.Schemas.Funding do + @moduledoc false + 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 + + def changeset(funding \\ %__MODULE__{}, attrs) do + funding + |> cast(attrs, [:amount, :month, :budget_id]) + |> validate_required([:amount, :month, :budget_id]) + |> put_beginning_of_month() + |> validate_relationships([:budget]) + end + + # A funding belongs to a month, not a day, so any date in that month names the same row. Pinning + # it here rather than in the caller is what makes the unique index on [:budget_id, :month] mean + # "funded once this month". + defp put_beginning_of_month(changeset) do + case fetch_change(changeset, :month) do + {:ok, %Date{} = month} -> put_change(changeset, :month, Date.beginning_of_month(month)) + _no_month -> changeset + end + 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_summary_controller_test.exs b/lib/spendable_web/api/controllers/budget_summary_controller_test.exs index 0cac7d1f..fa8161d1 100644 --- a/lib/spendable_web/api/controllers/budget_summary_controller_test.exs +++ b/lib/spendable_web/api/controllers/budget_summary_controller_test.exs @@ -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) 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..ba26fe80 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,45 @@ 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 type="text" label={amount_label(f[:type].value)} field={f[:budgeted_amount]} /> + + + <.input + :if={f[:type].value == :envelope} + type="text" + label="Fund Each Month" + field={f[:funding_amount]} + /> + + <.input + :if={f[:type].value == :envelope} + type="checkbox" + label="Carry the balance into next month" + field={f[:rollover]} + /> + <.input + :if={f[:type].value == :goal} + type="text" + label="Monthly Contribution" + field={f[:funding_amount]} /> <.input - :if={f[:type].value != :tracking} + :if={f[:type].value in [:envelope, :goal]} type="text" - label={if f[:type].value == :envelope, do: "Budgeted Amount", else: "Goal Amount"} - field={f[:budgeted_amount]} + label="Allocated" + field={f[:balance]} /> - <.input :if={f[:type].value != :tracking} type="text" label="Allocated" field={f[:balance]} />
+ +
+ <.input + type="text" + label="Funded This Month" + name="funding[amount]" + value={@funded_this_month} + /> + +
""" @@ -235,7 +294,27 @@ defmodule SpendableWeb.Live.Budgets do def handle_event("select_budget", params, socket) do budget = Enum.find(socket.assigns.budgets, &(&1.id == params["id"])) - {:noreply, assign(socket, :changeset, Budget.changeset(budget, %{}))} + funded = Map.get(socket.assigns.funded, budget.id, Decimal.new("0.00")) + + socket + |> assign(:changeset, Budget.changeset(budget, %{})) + |> assign(:funded_this_month, Decimal.to_string(funded)) + |> noreply() + end + + # Holds what was typed, so the figure does not snap back to what the month already funded. + def handle_event("fund_change", %{"funding" => %{"amount" => amount}}, socket) do + {:noreply, assign(socket, :funded_this_month, amount)} + end + + def handle_event("fund", %{"funding" => %{"amount" => amount}}, socket) do + scope = socket.assigns.current_scope + budget = socket.assigns.changeset.data + + case Budgets.update_funding(scope, budget, socket.assigns.selected_month, amount) do + {:ok, _funding} -> socket |> fetch_data() |> noreply() + {:error, _changeset} -> socket |> assign(:funded_this_month, amount) |> noreply() + end end def handle_event("archive", _params, socket) do @@ -275,8 +354,11 @@ defmodule SpendableWeb.Live.Budgets do |> assign(:spent_by_month, summary.spent_by_month) |> assign(:selected_month, selected_month) |> assign(:budgets, listed) - |> assign(:cards, build_cards(listed, summary.spent, summary.current_month)) - |> assign(:allocated_total, summary.allocated_total) + |> assign(:funded, summary.funded) + |> assign(:funded_this_month, "0.00") + |> assign(:cards, build_cards(listed, summary, summary.current_month)) + |> assign(:funded_total, summary.funded_total) + |> assign(:earned_total, summary.earned_total) |> assign(:spent_total, summary.spent_total) |> assign(:current_month_is_selected, summary.current_month) |> assign(:changeset, nil) @@ -302,24 +384,31 @@ defmodule SpendableWeb.Live.Budgets do end # Envelopes, then what is only tracked, alphabetical inside each. Grouping them by what they are - # does the work a heading over each group would, without the headings. Goals go last: a goal is - # money going in rather than out, so it is not what the month is about. + # does the work a heading over each group would, without the headings. Income and goals go last: + # both are money going in rather than out, so neither is what the month is about. defp by_type(budgets), do: Enum.sort_by(budgets, &{type_order(&1.type), &1.name}) defp type_order(:envelope), do: 0 defp type_order(:tracking), do: 1 - defp type_order(:goal), do: 2 + defp type_order(:income), do: 2 + defp type_order(:goal), do: 3 - defp build_cards(budgets, spent, current_month_is_selected) do + defp build_cards(budgets, summary, current_month_is_selected) do Enum.map(budgets, fn budget -> - spent_here = spent |> Map.get(budget.id, Decimal.new(0)) |> Decimal.abs() + month = %{ + spent: figure(summary.spent, budget.id), + received: figure(summary.received, budget.id), + funded: figure(summary.funded, budget.id) + } + credit_cards? = is_nil(budget.id) - card = build_budget_card(budget, spent_here, current_month_is_selected) + card = build_budget_card(budget, month, current_month_is_selected) # Card debt is not an envelope with something left in it, it is what is owed right now, and # the pill calling it one is only there to satisfy the card it is built from. Map.merge(card, %{ budget: budget, + amount: if(credit_cards?, do: budget.balance, else: card.amount), label: if(credit_cards?, do: "BALANCE", else: card.label), pill: if(credit_cards?, do: nil, else: pill(budget.type)), pill_class: pill_class(budget.type), @@ -329,15 +418,44 @@ defmodule SpendableWeb.Live.Budgets do end # Only reached when there is a percent to draw, and a card has a bar exactly when it has one. + # The synthetic Credit Cards row has no id, so it is in none of the month's maps. + defp figure(figures, budget_id), do: Map.get(figures, budget_id, Decimal.new("0.00")) + + # Only a saved budget that holds money can have a month's funding edited, and only the month the + # user is actually looking at. + defp fundable?(%{data: %{id: id, type: type}}, true = _current_month_is_selected) + when is_binary(id), + do: type in [:envelope, :goal] + + defp fundable?(_changeset, _current_month_is_selected), do: false + + # An overspend reads as a positive figure, so the label is what says it is bad rather than a + # minus sign. + defp amount_class(%{label: "OVERSPENT"}), do: "text-red-400" + + defp amount_class(%{amount: amount}) do + if Decimal.negative?(amount), do: "text-red-400", else: "text-white" + end + + defp amount_label(:goal), do: "Goal Amount" + defp amount_label(:income), do: "Expected Each Month" + defp amount_label(:tracking), do: "Monthly Limit" + defp amount_label(_envelope), do: "Budgeted Amount" + defp bar_class("over"), do: "bg-red-500" defp bar_class("under"), do: "bg-blue-500" defp bar_class("goal"), do: "bg-green-500" + defp bar_class("income"), do: "bg-emerald-400" defp pill(:tracking), do: "Tracking" defp pill(:envelope), do: "Envelope" defp pill(:goal), do: "Goal" + defp pill(:income), do: "Income" defp pill_class(:tracking), do: "text-gray-400 bg-gray-400/10 ring-gray-400/20 group-hover:ring-gray-400/50" defp pill_class(:envelope), do: "text-blue-400 bg-blue-400/10 ring-blue-400/20 group-hover:ring-blue-400/50" defp pill_class(:goal), do: "text-green-400 bg-green-400/10 ring-green-400/20 group-hover:ring-green-400/50" + + defp pill_class(:income), + do: "text-emerald-400 bg-emerald-400/10 ring-emerald-400/20 group-hover:ring-emerald-400/50" end diff --git a/lib/spendable_web/live/budgets_test.exs b/lib/spendable_web/live/budgets_test.exs index 45c019b0..0fcc1085 100644 --- a/lib/spendable_web/live/budgets_test.exs +++ b/lib/spendable_web/live/budgets_test.exs @@ -111,7 +111,8 @@ defmodule SpendableWeb.Live.BudgetsTest do Budgets.create_budget(scope, %{ "name" => "Groceries", "type" => "envelope", - "budgeted_amount" => "650.00" + "budgeted_amount" => "650.00", + "funding_amount" => "650.00" }) {:ok, _transaction} = @@ -126,9 +127,9 @@ defmodule SpendableWeb.Live.BudgetsTest do assert html =~ "LEFT" assert html =~ "$488.12 of $650.00 spent" - # The month summary pairs what the envelopes hold against what went out of them. - assert html =~ "Allocated" - assert html =~ "$650.00" + # The month summary pairs what went into the envelopes against what went out of them. + assert html =~ "Funded" + assert html =~ "Earned" assert html =~ "$488.12" end @@ -304,4 +305,231 @@ defmodule SpendableWeb.Live.BudgetsTest do assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/budgets") end + + test "sets an envelope to fund itself each month", %{conn: conn, scope: scope} do + {:ok, budget} = + Budgets.create_budget(scope, %{"name" => "Groceries", "budgeted_amount" => "300.00"}) + + {:ok, view, _html} = live(conn, ~p"/budgets") + + view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() + + view + |> element(~s(form[phx-submit="submit"])) + |> render_submit(%{ + budget: %{ + "name" => "Groceries", + "type" => "envelope", + "budgeted_amount" => "300.00", + "funding_amount" => "300.00" + } + }) + + {:ok, funded} = Budgets.get_budget(scope, id: budget.id) + + assert Decimal.eq?(funded.funding_amount, "300.00") + assert Decimal.eq?(funded.balance, "300.00") + end + + test "stops funding an envelope without unpicking what it already holds", %{ + conn: conn, + scope: scope + } do + {:ok, budget} = + Budgets.create_budget(scope, %{ + "name" => "Groceries", + "budgeted_amount" => "300.00", + "funding_amount" => "300.00" + }) + + {:ok, view, _html} = live(conn, ~p"/budgets") + + view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() + + view + |> element(~s(form[phx-submit="submit"])) + |> render_submit(%{ + budget: %{ + "name" => "Groceries", + "type" => "envelope", + "budgeted_amount" => "300.00", + "funding_amount" => "" + } + }) + + {:ok, stopped} = Budgets.get_budget(scope, id: budget.id) + + assert is_nil(stopped.funding_amount) + assert Decimal.eq?(stopped.balance, "300.00") + end + + test "funds a single month from the drawer", %{conn: conn, scope: scope} do + {:ok, budget} = + Budgets.create_budget(scope, %{ + "name" => "Groceries", + "budgeted_amount" => "300.00", + "funding_amount" => "300.00" + }) + + {:ok, view, _html} = live(conn, ~p"/budgets") + + view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() + + view + |> element(~s(form[phx-submit="fund"])) + |> render_submit(%{funding: %{"amount" => "200.00"}}) + + {:ok, funded} = Budgets.get_budget(scope, id: budget.id) + + assert Decimal.eq?(funded.balance, "200.00") + end + + test "reads an income budget as what it took in", %{conn: conn, scope: scope} do + {:ok, salary} = + Budgets.create_budget(scope, %{ + "name" => "Salary", + "type" => "income", + "budgeted_amount" => "4200.00" + }) + + {:ok, _paycheck} = + Transactions.create_transaction(scope, %{ + "amount" => "4200.00", + "date" => Date.utc_today(), + "name" => "Payday", + "budget_allocations" => %{"0" => %{"amount" => "4200.00", "budget_id" => salary.id}} + }) + + {:ok, _view, html} = live(conn, ~p"/budgets") + + assert html =~ "EARNED" + assert html =~ "$4,200.00 of $4,200.00 received" + end + + test "keeps a typed funding amount while it is being typed", %{conn: conn, scope: scope} do + {:ok, budget} = + Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"}) + + {:ok, view, _html} = live(conn, ~p"/budgets") + + view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() + + html = + view + |> element(~s(form[phx-submit="fund"])) + |> render_change(%{funding: %{"amount" => "12"}}) + + assert html =~ ~s(value="12") + end + + test "keeps the funding form open when the amount will not parse", %{conn: conn, scope: scope} do + {:ok, budget} = + Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"}) + + {:ok, view, _html} = live(conn, ~p"/budgets") + + view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() + + html = + view + |> element(~s(form[phx-submit="fund"])) + |> render_submit(%{funding: %{"amount" => "not money"}}) + + assert html =~ ~s(phx-submit="fund") + + {:ok, unchanged} = Budgets.get_budget(scope, id: budget.id) + assert Decimal.eq?(unchanged.balance, "300.00") + end + + test "asks a tracking budget for a limit and an income budget for what it expects", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/budgets") + + view |> element("#new-budget") |> render_click() + + tracking = + view + |> element(~s(form[phx-submit="submit"])) + |> render_change(%{budget: %{"name" => "Fuel", "type" => "tracking"}}) + + assert tracking =~ "Monthly Limit" + refute tracking =~ "Allocated" + + income = + view + |> element(~s(form[phx-submit="submit"])) + |> render_change(%{budget: %{"name" => "Salary", "type" => "income"}}) + + assert income =~ "Expected Each Month" + refute income =~ "Allocated" + end + + test "asks a goal for its target and its monthly contribution", %{conn: conn, scope: scope} do + {:ok, budget} = + Budgets.create_budget(scope, %{ + "name" => "Holiday", + "type" => "goal", + "budgeted_amount" => "2000.00" + }) + + {:ok, view, _html} = live(conn, ~p"/budgets") + + html = view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() + + assert html =~ "Goal Amount" + assert html =~ "Monthly Contribution" + refute html =~ "Fund automatically each month" + end + + # An envelope holds what it was funded less what it spent, so one that was never funded and has + # been spent from is genuinely in the hole - the money came out of Spendable. + test "reads an envelope in the hole as overspent", %{conn: conn, scope: scope} do + {:ok, budget} = + Budgets.create_budget(scope, %{ + "name" => "Dining out", + "type" => "envelope", + "budgeted_amount" => "200.00" + }) + + {:ok, _transaction} = + Transactions.create_transaction(scope, %{ + "amount" => "-264.50", + "date" => Date.utc_today(), + "name" => "Dinner", + "budget_allocations" => %{"0" => %{"amount" => "-264.50", "budget_id" => budget.id}} + }) + + {:ok, _view, html} = live(conn, ~p"/budgets") + + # The shortfall reads as a positive figure, so the label is what says it is bad. The month + # picker still reports the month's spending as negative, so this looks at the card itself. + assert html =~ "OVERSPENT" + assert html =~ ~r/text-red-400[^>]*">\s*\$264\.50\s* "Groceries"}) + + {:ok, view, _html} = live(conn, ~p"/budgets") + + view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() + + view + |> element(~s(form[phx-submit="submit"])) + |> render_submit(%{ + budget: %{ + "name" => "Groceries", + "type" => "envelope", + "budgeted_amount" => "400.00", + "funding_amount" => "300.00" + } + }) + + {:ok, saved} = Budgets.get_budget(scope, id: budget.id) + + assert Decimal.eq?(saved.budgeted_amount, "400.00") + assert Decimal.eq?(saved.funding_amount, "300.00") + assert Decimal.eq?(saved.balance, "300.00") + end end diff --git a/lib/spendable_web/mcp/server.ex b/lib/spendable_web/mcp/server.ex index 0e3595a2..ee0a3594 100644 --- a/lib/spendable_web/mcp/server.ex +++ b/lib/spendable_web/mcp/server.ex @@ -16,6 +16,7 @@ defmodule SpendableWeb.MCP.Server do component(SpendableWeb.MCP.Tools.ArchiveSplit) component(SpendableWeb.MCP.Tools.CreateBudget) component(SpendableWeb.MCP.Tools.CreateSplit) + component(SpendableWeb.MCP.Tools.FundBudget) component(SpendableWeb.MCP.Tools.ListBudgets) component(SpendableWeb.MCP.Tools.ListSplits) component(SpendableWeb.MCP.Tools.ListTransactions) diff --git a/lib/spendable_web/mcp/tools/create_budget.ex b/lib/spendable_web/mcp/tools/create_budget.ex index a92a4beb..ec64672f 100644 --- a/lib/spendable_web/mcp/tools/create_budget.ex +++ b/lib/spendable_web/mcp/tools/create_budget.ex @@ -1,8 +1,9 @@ defmodule SpendableWeb.MCP.Tools.CreateBudget do @moduledoc """ - Creates a budget: an envelope reserves money for a purpose, a goal saves toward a target, and - tracking only records spending without reserving anything. `budgeted_amount` is what the user - intends it to hold; `balance` is what it holds now, and setting it records an adjustment for the + Creates a budget: an envelope reserves money for a purpose, a goal saves toward a target, + tracking only records spending without reserving anything, and income records money arriving. + `budgeted_amount` is what the user intends it to hold; `funding_amount` is what it puts into + itself each month; `balance` is what it holds now, and setting it records an adjustment for the difference rather than inventing transactions. """ use Anubis.Server.Component, type: :tool, annotations: %{readOnlyHint: false} @@ -15,15 +16,29 @@ defmodule SpendableWeb.MCP.Tools.CreateBudget do field :name, {:required, :string}, description: "What the budget is called, e.g. \"Groceries\"." # Strings, not atoms: the value arrives from JSON and is compared before anything casts it. - field :type, {:enum, ["envelope", "goal", "tracking"]}, + field :type, {:enum, ["envelope", "goal", "tracking", "income"]}, description: "envelope reserves money for a purpose, goal saves toward a target, tracking records spending " <> - "without reserving anything. Defaults to envelope." + "without reserving anything, income records money arriving. Defaults to envelope." field :budgeted_amount, :string, description: "What the user intends this budget to hold - the figure its balance is read against. It is a " <> - "target, not money moved in, so it never changes the balance. Decimal string, e.g. \"250.00\"." + "target on its own; use funding_amount to actually put money in. Decimal string, e.g. \"250.00\"." + + field :funding_amount, :string, + description: + "What this budget puts into itself at the start of every month, drawn from what is spendable. " <> + "Setting it is what makes a budget fill on its own instead of being fed from a paycheck by " <> + "hand, and the current month is funded straight away. Only an envelope or a goal can hold " <> + "money, so it is ignored on tracking and income. Decimal string, e.g. \"250.00\"." + + field :rollover, :boolean, + description: + "Whether the balance carries into next month. False means each month tops the budget back up " <> + "to its funding amount instead, so an overspend does not follow it into the next month and " <> + "leftover does not pile up. Only an envelope can decline to roll over; everything else always " <> + "does. Defaults to true." field :balance, :string, description: @@ -39,6 +54,8 @@ defmodule SpendableWeb.MCP.Tools.CreateBudget do %{"name" => params.name} |> put_present("type", params[:type]) |> put_present("budgeted_amount", params[:budgeted_amount]) + |> put_present("funding_amount", params[:funding_amount]) + |> put_present("rollover", params[:rollover]) |> put_present("balance", params[:balance]) case Budgets.create_budget(frame.assigns.current_scope, attrs) do @@ -48,7 +65,10 @@ defmodule SpendableWeb.MCP.Tools.CreateBudget do id: budget.id, name: budget.name, type: budget.type, - budgeted_amount: budget.budgeted_amount && Decimal.to_string(budget.budgeted_amount) + budgeted_amount: budget.budgeted_amount && Decimal.to_string(budget.budgeted_amount), + funding_amount: budget.funding_amount && Decimal.to_string(budget.funding_amount), + rollover: budget.rollover, + balance: Decimal.to_string(budget.balance) } }) diff --git a/lib/spendable_web/mcp/tools/fund_budget.ex b/lib/spendable_web/mcp/tools/fund_budget.ex new file mode 100644 index 00000000..085b6ed8 --- /dev/null +++ b/lib/spendable_web/mcp/tools/fund_budget.ex @@ -0,0 +1,52 @@ +defmodule SpendableWeb.MCP.Tools.FundBudget do + @moduledoc """ + Sets what one month puts into one budget, overriding the amount it usually funds itself with. + + Use this to deviate for a single month - "only 200 into groceries this time" - or to skip a + month by funding it with zero. Every other month keeps the budget's usual amount. This moves + money into the budget, which is what makes it different from `update_budget`'s `balance`: that + records an adjustment to correct a figure, this records the month's funding. + """ + use Anubis.Server.Component, type: :tool, annotations: %{readOnlyHint: false} + + import SpendableWeb.Utils.ToolReply + + alias Spendable.Budgets + + schema do + field :budget_id, {:required, :string}, description: "The id of the budget to fund." + + field :amount, {:required, :string}, + description: + "What this month should put into the budget. Zero skips the month, and the skip is recorded " <> + "rather than left blank. Decimal string, e.g. \"200.00\"." + + field :month, :string, + description: "Any date inside the month to fund, as YYYY-MM-DD. Defaults to the current month." + end + + @impl true + def execute(params, frame) do + scope = frame.assigns.current_scope + + with {:ok, month} <- parse_month(params[:month]), + {:ok, budget} <- Budgets.get_budget(scope, id: params.budget_id), + {:ok, funding} <- Budgets.update_funding(scope, budget, month, params.amount), + {:ok, funded} <- Budgets.get_budget(scope, id: budget.id) do + reply(frame, %{ + funding: %{ + budget_id: funded.id, + name: funded.name, + month: Date.to_string(funding.month), + amount: Decimal.to_string(funding.amount), + balance: Decimal.to_string(funded.balance) + } + }) + else + {:error, reason} -> reply_error(frame, reason) + end + end + + defp parse_month(nil), do: {:ok, Date.utc_today()} + defp parse_month(month), do: Date.from_iso8601(month) +end diff --git a/lib/spendable_web/mcp/tools/fund_budget_test.exs b/lib/spendable_web/mcp/tools/fund_budget_test.exs new file mode 100644 index 00000000..9a59f9f5 --- /dev/null +++ b/lib/spendable_web/mcp/tools/fund_budget_test.exs @@ -0,0 +1,72 @@ +defmodule SpendableWeb.MCP.Tools.FundBudgetTest do + use Spendable.DataCase, async: true + + alias Anubis.Server.Frame + alias Anubis.Server.Response + alias Spendable.Accounts + alias Spendable.Budgets + alias Spendable.Scope + alias SpendableWeb.MCP.Tools.FundBudget + + 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"}) + + %{frame: Frame.new(%{current_scope: scope}), scope: scope, budget: budget, budget_id: budget_id} + end + + test "puts money into a named month", %{frame: frame, budget: budget} do + assert {:reply, + %Response{ + structured_content: %{ + funding: %{name: "Groceries", month: "2020-05-01", amount: "200.00", balance: "200.00"} + } + }, ^frame} = + FundBudget.execute( + %{budget_id: budget.id, amount: "200.00", month: "2020-05-15"}, + frame + ) + end + + test "funds the current month when none is given", %{ + frame: frame, + scope: scope, + budget: budget, + budget_id: budget_id + } do + assert {:reply, %Response{isError: false}, ^frame} = + FundBudget.execute(%{budget_id: budget_id, amount: "200.00"}, frame) + + month = Date.beginning_of_month(Date.utc_today()) + + assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], month) + assert Decimal.eq?(funded, "200.00") + end + + test "replaces what a month was funded with rather than adding to it", %{ + frame: frame, + budget: budget + } do + {:reply, %Response{isError: false}, ^frame} = + FundBudget.execute(%{budget_id: budget.id, amount: "300.00", month: "2020-05-01"}, frame) + + assert {:reply, %Response{structured_content: %{funding: %{balance: "200.00"}}}, ^frame} = + FundBudget.execute( + %{budget_id: budget.id, amount: "200.00", month: "2020-05-01"}, + frame + ) + end + + test "reports a month it cannot read", %{frame: frame, budget: budget} do + assert {:reply, %Response{isError: true}, ^frame} = + FundBudget.execute(%{budget_id: budget.id, amount: "200.00", month: "May"}, frame) + end + + test "reports a budget that is not the user's", %{frame: frame} do + assert {:reply, %Response{isError: true}, ^frame} = + FundBudget.execute(%{budget_id: "bgt_nope", amount: "200.00"}, frame) + end +end diff --git a/lib/spendable_web/mcp/tools/update_budget.ex b/lib/spendable_web/mcp/tools/update_budget.ex index a6d01d83..0d2838ac 100644 --- a/lib/spendable_web/mcp/tools/update_budget.ex +++ b/lib/spendable_web/mcp/tools/update_budget.ex @@ -1,7 +1,8 @@ defmodule SpendableWeb.MCP.Tools.UpdateBudget do @moduledoc """ - Changes a budget's name, type, budgeted amount, or balance. Setting `balance` states what the - budget should read and records the adjustment that gets it there, leaving its transactions alone. + Changes a budget's name, type, budgeted amount, funding amount, or balance. Setting `balance` + states what the budget should read and records the adjustment that gets it there, leaving its + transactions alone. Setting `funding_amount` is what makes the budget fill itself every month. Only the fields given are changed. """ use Anubis.Server.Component, type: :tool, annotations: %{readOnlyHint: false} @@ -15,15 +16,29 @@ defmodule SpendableWeb.MCP.Tools.UpdateBudget do field :name, :string, description: "What the budget is called, e.g. \"Groceries\"." # Strings, not atoms: the value arrives from JSON and is compared before anything casts it. - field :type, {:enum, ["envelope", "goal", "tracking"]}, + field :type, {:enum, ["envelope", "goal", "tracking", "income"]}, description: "envelope reserves money for a purpose, goal saves toward a target, tracking records spending " <> - "without reserving anything." + "without reserving anything, income records money arriving." field :budgeted_amount, :string, description: "What the user intends this budget to hold - the figure its balance is read against. It is a " <> - "target, not money moved in, so it never changes the balance. Decimal string, e.g. \"250.00\"." + "target on its own; use funding_amount to actually put money in. Decimal string, e.g. \"250.00\"." + + field :funding_amount, :string, + description: + "What this budget puts into itself at the start of every month, drawn from what is spendable. " <> + "Setting it makes the budget fill on its own rather than being fed from a paycheck by hand, " <> + "and funds the current month straight away. Only an envelope or a goal can hold money, so it " <> + "is ignored on tracking and income. Decimal string, e.g. \"250.00\"." + + field :rollover, :boolean, + description: + "Whether the balance carries into next month. False means each month tops the budget back up " <> + "to its funding amount instead, so an overspend does not follow it into the next month and " <> + "leftover does not pile up. Only an envelope can decline to roll over; everything else always " <> + "does. Defaults to true." field :balance, :string, description: @@ -41,6 +56,8 @@ defmodule SpendableWeb.MCP.Tools.UpdateBudget do |> put_present("name", params[:name]) |> put_present("type", params[:type]) |> put_present("budgeted_amount", params[:budgeted_amount]) + |> put_present("funding_amount", params[:funding_amount]) + |> put_present("rollover", params[:rollover]) |> put_present("balance", params[:balance]) with {:ok, budget} <- Budgets.get_budget(scope, id: params.budget_id), @@ -51,7 +68,9 @@ defmodule SpendableWeb.MCP.Tools.UpdateBudget do name: budget.name, type: budget.type, balance: Decimal.to_string(budget.balance), - budgeted_amount: budget.budgeted_amount && Decimal.to_string(budget.budgeted_amount) + budgeted_amount: budget.budgeted_amount && Decimal.to_string(budget.budgeted_amount), + funding_amount: budget.funding_amount && Decimal.to_string(budget.funding_amount), + rollover: budget.rollover } }) else diff --git a/lib/spendable_web/utils/budget_card.ex b/lib/spendable_web/utils/budget_card.ex index 3b095d80..79e14efe 100644 --- a/lib/spendable_web/utils/budget_card.ex +++ b/lib/spendable_web/utils/budget_card.ex @@ -5,52 +5,114 @@ defmodule SpendableWeb.Utils.BudgetCard do Extracted from the LiveView rather than left private because the iOS client has to reach the same six answers, and `shared/budget_cards.json` drives a table test on both sides. Colours - stay with each client - `bar` says which of the three bars this is, not what it looks like. + stay with each client - `bar` says which of the four bars this is, not what it looks like. + + The month is passed as one map of what moved - `spent`, `received` and `funded` - because which + of them a card reads depends on what kind of budget it is, and a budget that spends never + receives. """ import Spendable.Utils alias Spendable.Budgets.Schemas.Budget - # A past month is a record of what was spent, so a balance read now says nothing about it. - def build_budget_card(_budget, spent, false = _current_month_is_selected) do - %{amount: spent, label: "SPENT", percent: nil, bar: nil, footer: nil} + # A past month is a record of what moved, so a balance read now says nothing about it. + def build_budget_card(%Budget{type: :income}, %{received: received}, false = _current_month) do + %{amount: received, label: "EARNED", percent: nil, bar: nil, footer: nil} end - def build_budget_card(%Budget{type: :tracking}, spent, _current_month_is_selected) do + def build_budget_card(_budget, %{spent: spent}, false = _current_month_is_selected) do %{amount: spent, label: "SPENT", percent: nil, bar: nil, footer: nil} end - def build_budget_card(%Budget{type: :envelope, budgeted_amount: nil} = budget, _spent, _current) do - %{amount: budget.balance, label: "LEFT", percent: nil, bar: nil, footer: nil} + def build_budget_card(%Budget{type: :tracking, budgeted_amount: nil}, %{spent: spent}, _current) do + %{amount: spent, label: "SPENT", percent: nil, bar: nil, footer: nil} end - def build_budget_card(%Budget{type: :envelope} = budget, spent, _current_month_is_selected) do - over_budget? = Decimal.compare(spent, budget.budgeted_amount) == :gt - + def build_budget_card(%Budget{type: :tracking} = budget, %{spent: spent}, _current) do %{ - amount: budget.balance, - label: "LEFT", + amount: spent, + label: "SPENT", percent: percent(spent, budget.budgeted_amount), - bar: if(over_budget?, do: "over", else: "under"), + bar: spending_bar(spent, budget.budgeted_amount), footer: "#{format_currency(spent)} of #{format_currency(budget.budgeted_amount)} spent" } end - def build_budget_card(%Budget{type: :goal, budgeted_amount: nil} = budget, _spent, _current) do + def build_budget_card(%Budget{type: :income, budgeted_amount: nil}, %{received: received}, _current) do + %{amount: received, label: "EARNED", percent: nil, bar: nil, footer: nil} + end + + # Money in is the point here, so there is no bar to be the wrong side of: it fills as the month + # earns and the footer says how far along that is. + def build_budget_card(%Budget{type: :income} = budget, %{received: received}, _current) do + %{ + amount: received, + label: "EARNED", + percent: percent(received, budget.budgeted_amount), + bar: "income", + footer: "#{format_currency(received)} of #{format_currency(budget.budgeted_amount)} received" + } + end + + def build_budget_card(%Budget{type: :envelope, budgeted_amount: nil} = budget, %{funded: funded}, _current) do + %{held(budget) | percent: nil, bar: nil, footer: funded_footer(funded)} + end + + def build_budget_card(%Budget{type: :envelope} = budget, %{spent: spent, funded: funded}, _current) do + spend_line = "#{format_currency(spent)} of #{format_currency(budget.budgeted_amount)} spent" + + %{ + held(budget) + | percent: percent(spent, budget.budgeted_amount), + bar: spending_bar(spent, budget.budgeted_amount), + footer: prefix_funded(spend_line, funded) + } + end + + def build_budget_card(%Budget{type: :goal, budgeted_amount: nil} = budget, _month, _current) do %{amount: budget.balance, label: "SAVED", percent: nil, bar: nil, footer: "No goal set"} end - def build_budget_card(%Budget{type: :goal} = budget, _spent, _current_month_is_selected) do + def build_budget_card(%Budget{type: :goal} = budget, _month, _current_month_is_selected) do + saved_line = + "#{format_currency(budget.balance)} of #{format_currency(budget.budgeted_amount)} saved" + %{ amount: Decimal.sub(budget.budgeted_amount, budget.balance), label: "TO GO", percent: percent(budget.balance, budget.budgeted_amount), bar: "goal", - footer: "#{format_currency(budget.balance)} of #{format_currency(budget.budgeted_amount)} saved" + footer: suffix_monthly(saved_line, budget.funding_amount) } end + # An envelope in the hole is not holding a negative amount, it is short by a positive one - the + # same way a goal counts what is still TO GO rather than a negative saving. Clients colour the + # label, since the figure no longer carries a minus sign to key off. + defp held(%Budget{balance: balance} = budget) do + if Decimal.negative?(balance) do + %{amount: Decimal.abs(balance), label: "OVERSPENT", percent: nil, bar: nil, footer: nil} + else + %{amount: budget.balance, label: "LEFT", percent: nil, bar: nil, footer: nil} + end + end + + defp spending_bar(spent, budgeted_amount) do + if Decimal.compare(spent, budgeted_amount) == :gt, do: "over", else: "under" + end + + # What the month put in is only worth saying when a month put something in, so a budget the user + # fills by hand reads exactly as it did before funding existed. + defp funded_footer(%Decimal{coef: 0}), do: nil + defp funded_footer(funded), do: "#{format_currency(funded)} funded" + + defp prefix_funded(line, %Decimal{coef: 0}), do: line + defp prefix_funded(line, funded), do: "#{format_currency(funded)} funded · #{line}" + + defp suffix_monthly(line, nil), do: line + defp suffix_monthly(line, funding_amount), do: "#{line} · #{format_currency(funding_amount)}/mo" + defp percent(_part, %Decimal{coef: 0}), do: 0.0 defp percent(part, whole) do diff --git a/lib/spendable_web/utils/budget_card_test.exs b/lib/spendable_web/utils/budget_card_test.exs index 6969cd1b..096d964c 100644 --- a/lib/spendable_web/utils/budget_card_test.exs +++ b/lib/spendable_web/utils/budget_card_test.exs @@ -15,14 +15,21 @@ defmodule SpendableWeb.Utils.BudgetCardTest do test @fixture["name"] do budgeted_amount = @fixture["budget"]["budgeted_amount"] + funding_amount = @fixture["budget"]["funding_amount"] budget = %Budget{ type: String.to_existing_atom(@fixture["budget"]["type"]), balance: Decimal.new(@fixture["budget"]["balance"]), - budgeted_amount: budgeted_amount && Decimal.new(budgeted_amount) + budgeted_amount: budgeted_amount && Decimal.new(budgeted_amount), + funding_amount: funding_amount && Decimal.new(funding_amount) } - card = build_budget_card(budget, Decimal.new(@fixture["spent"]), @fixture["current_month"]) + month = + Map.new(@fixture["month"], fn {figure, amount} -> + {String.to_existing_atom(figure), Decimal.new(amount)} + end) + + card = build_budget_card(budget, month, @fixture["current_month"]) assert card.label == @fixture["card"]["label"] assert card.percent == @fixture["card"]["percent"] diff --git a/mobile/api/lib/src/model/budget.dart b/mobile/api/lib/src/model/budget.dart index d6772279..0f0d2542 100644 --- a/mobile/api/lib/src/model/budget.dart +++ b/mobile/api/lib/src/model/budget.dart @@ -13,32 +13,42 @@ part 'budget.g.dart'; /// /// Properties: /// * [archivedAt] -/// * [balance] - What the allocations add up to, or the bank account's balance when assigned. +/// * [balance] - What the fundings and allocations add up to, or the bank account's balance when assigned. /// * [budgetedAmount] +/// * [fundingAmount] - What the budget puts into itself each month. Null means it does not fund itself. /// * [id] /// * [name] +/// * [rollover] - 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. /// * [type] @BuiltValue() abstract class Budget implements Built { @BuiltValueField(wireName: r'archived_at') DateTime? get archivedAt; - /// What the allocations add up to, or the bank account's balance when assigned. + /// What the fundings and allocations add up to, or the bank account's balance when assigned. @BuiltValueField(wireName: r'balance') String get balance; @BuiltValueField(wireName: r'budgeted_amount') String? get budgetedAmount; + /// What the budget puts into itself each month. Null means it does not fund itself. + @BuiltValueField(wireName: r'funding_amount') + String? get fundingAmount; + @BuiltValueField(wireName: r'id') String get id; @BuiltValueField(wireName: r'name') String get name; + /// 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. + @BuiltValueField(wireName: r'rollover') + bool get rollover; + @BuiltValueField(wireName: r'type') BudgetTypeEnum get type; - // enum typeEnum { tracking, envelope, goal, }; + // enum typeEnum { tracking, envelope, goal, income, }; Budget._(); @@ -82,6 +92,13 @@ class _$BudgetSerializer implements PrimitiveSerializer { specifiedType: const FullType.nullable(String), ); } + if (object.fundingAmount != null) { + yield r'funding_amount'; + yield serializers.serialize( + object.fundingAmount, + specifiedType: const FullType.nullable(String), + ); + } yield r'id'; yield serializers.serialize( object.id, @@ -92,6 +109,11 @@ class _$BudgetSerializer implements PrimitiveSerializer { object.name, specifiedType: const FullType(String), ); + yield r'rollover'; + yield serializers.serialize( + object.rollover, + specifiedType: const FullType(bool), + ); yield r'type'; yield serializers.serialize( object.type, @@ -143,6 +165,14 @@ class _$BudgetSerializer implements PrimitiveSerializer { if (valueDes == null) continue; result.budgetedAmount = valueDes; break; + case r'funding_amount': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.fundingAmount = valueDes; + break; case r'id': final valueDes = serializers.deserialize( value, @@ -157,6 +187,13 @@ class _$BudgetSerializer implements PrimitiveSerializer { ) as String; result.name = valueDes; break; + case r'rollover': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(bool), + ) as bool; + result.rollover = valueDes; + break; case r'type': final valueDes = serializers.deserialize( value, @@ -201,6 +238,8 @@ class BudgetTypeEnum extends EnumClass { static const BudgetTypeEnum envelope = _$budgetTypeEnum_envelope; @BuiltValueEnumConst(wireName: r'goal') static const BudgetTypeEnum goal = _$budgetTypeEnum_goal; + @BuiltValueEnumConst(wireName: r'income') + static const BudgetTypeEnum income = _$budgetTypeEnum_income; static Serializer get serializer => _$budgetTypeEnumSerializer; diff --git a/mobile/api/lib/src/model/budget.g.dart b/mobile/api/lib/src/model/budget.g.dart index 1ea618b0..acb1a9e7 100644 --- a/mobile/api/lib/src/model/budget.g.dart +++ b/mobile/api/lib/src/model/budget.g.dart @@ -11,6 +11,7 @@ const BudgetTypeEnum _$budgetTypeEnum_tracking = const BudgetTypeEnum _$budgetTypeEnum_envelope = const BudgetTypeEnum._('envelope'); const BudgetTypeEnum _$budgetTypeEnum_goal = const BudgetTypeEnum._('goal'); +const BudgetTypeEnum _$budgetTypeEnum_income = const BudgetTypeEnum._('income'); BudgetTypeEnum _$budgetTypeEnumValueOf(String name) { switch (name) { @@ -20,6 +21,8 @@ BudgetTypeEnum _$budgetTypeEnumValueOf(String name) { return _$budgetTypeEnum_envelope; case 'goal': return _$budgetTypeEnum_goal; + case 'income': + return _$budgetTypeEnum_income; default: throw ArgumentError(name); } @@ -30,6 +33,7 @@ final BuiltSet _$budgetTypeEnumValues = _$budgetTypeEnum_tracking, _$budgetTypeEnum_envelope, _$budgetTypeEnum_goal, + _$budgetTypeEnum_income, ]); Serializer _$budgetTypeEnumSerializer = @@ -41,11 +45,13 @@ class _$BudgetTypeEnumSerializer 'tracking': 'tracking', 'envelope': 'envelope', 'goal': 'goal', + 'income': 'income', }; static const Map _fromWire = const { 'tracking': 'tracking', 'envelope': 'envelope', 'goal': 'goal', + 'income': 'income', }; @override @@ -73,10 +79,14 @@ class _$Budget extends Budget { @override final String? budgetedAmount; @override + final String? fundingAmount; + @override final String id; @override final String name; @override + final bool rollover; + @override final BudgetTypeEnum type; factory _$Budget([void Function(BudgetBuilder)? updates]) => @@ -86,8 +96,10 @@ class _$Budget extends Budget { {this.archivedAt, required this.balance, this.budgetedAmount, + this.fundingAmount, required this.id, required this.name, + required this.rollover, required this.type}) : super._(); @override @@ -104,8 +116,10 @@ class _$Budget extends Budget { archivedAt == other.archivedAt && balance == other.balance && budgetedAmount == other.budgetedAmount && + fundingAmount == other.fundingAmount && id == other.id && name == other.name && + rollover == other.rollover && type == other.type; } @@ -115,8 +129,10 @@ class _$Budget extends Budget { _$hash = $jc(_$hash, archivedAt.hashCode); _$hash = $jc(_$hash, balance.hashCode); _$hash = $jc(_$hash, budgetedAmount.hashCode); + _$hash = $jc(_$hash, fundingAmount.hashCode); _$hash = $jc(_$hash, id.hashCode); _$hash = $jc(_$hash, name.hashCode); + _$hash = $jc(_$hash, rollover.hashCode); _$hash = $jc(_$hash, type.hashCode); _$hash = $jf(_$hash); return _$hash; @@ -128,8 +144,10 @@ class _$Budget extends Budget { ..add('archivedAt', archivedAt) ..add('balance', balance) ..add('budgetedAmount', budgetedAmount) + ..add('fundingAmount', fundingAmount) ..add('id', id) ..add('name', name) + ..add('rollover', rollover) ..add('type', type)) .toString(); } @@ -151,6 +169,11 @@ class BudgetBuilder implements Builder { set budgetedAmount(String? budgetedAmount) => _$this._budgetedAmount = budgetedAmount; + String? _fundingAmount; + String? get fundingAmount => _$this._fundingAmount; + set fundingAmount(String? fundingAmount) => + _$this._fundingAmount = fundingAmount; + String? _id; String? get id => _$this._id; set id(String? id) => _$this._id = id; @@ -159,6 +182,10 @@ class BudgetBuilder implements Builder { String? get name => _$this._name; set name(String? name) => _$this._name = name; + bool? _rollover; + bool? get rollover => _$this._rollover; + set rollover(bool? rollover) => _$this._rollover = rollover; + BudgetTypeEnum? _type; BudgetTypeEnum? get type => _$this._type; set type(BudgetTypeEnum? type) => _$this._type = type; @@ -173,8 +200,10 @@ class BudgetBuilder implements Builder { _archivedAt = $v.archivedAt; _balance = $v.balance; _budgetedAmount = $v.budgetedAmount; + _fundingAmount = $v.fundingAmount; _id = $v.id; _name = $v.name; + _rollover = $v.rollover; _type = $v.type; _$v = null; } @@ -201,8 +230,11 @@ class BudgetBuilder implements Builder { balance: BuiltValueNullFieldError.checkNotNull( balance, r'Budget', 'balance'), budgetedAmount: budgetedAmount, + fundingAmount: fundingAmount, id: BuiltValueNullFieldError.checkNotNull(id, r'Budget', 'id'), name: BuiltValueNullFieldError.checkNotNull(name, r'Budget', 'name'), + rollover: BuiltValueNullFieldError.checkNotNull( + rollover, r'Budget', 'rollover'), type: BuiltValueNullFieldError.checkNotNull(type, r'Budget', 'type'), ); replace(_$result); diff --git a/mobile/api/lib/src/model/budget_request.dart b/mobile/api/lib/src/model/budget_request.dart index 888f8419..65d3735b 100644 --- a/mobile/api/lib/src/model/budget_request.dart +++ b/mobile/api/lib/src/model/budget_request.dart @@ -9,12 +9,14 @@ import 'package:built_value/serializer.dart'; part 'budget_request.g.dart'; -/// 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`. +/// 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. /// /// Properties: /// * [balance] /// * [budgetedAmount] +/// * [fundingAmount] /// * [name] +/// * [rollover] /// * [type] @BuiltValue() abstract class BudgetRequest implements Built { @@ -24,12 +26,18 @@ abstract class BudgetRequest implements Built { specifiedType: const FullType.nullable(String), ); } + if (object.fundingAmount != null) { + yield r'funding_amount'; + yield serializers.serialize( + object.fundingAmount, + specifiedType: const FullType.nullable(String), + ); + } if (object.name != null) { yield r'name'; yield serializers.serialize( @@ -75,6 +90,13 @@ class _$BudgetRequestSerializer implements PrimitiveSerializer { specifiedType: const FullType(String), ); } + if (object.rollover != null) { + yield r'rollover'; + yield serializers.serialize( + object.rollover, + specifiedType: const FullType(bool), + ); + } if (object.type != null) { yield r'type'; yield serializers.serialize( @@ -120,6 +142,14 @@ class _$BudgetRequestSerializer implements PrimitiveSerializer { if (valueDes == null) continue; result.budgetedAmount = valueDes; break; + case r'funding_amount': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType.nullable(String), + ) as String?; + if (valueDes == null) continue; + result.fundingAmount = valueDes; + break; case r'name': final valueDes = serializers.deserialize( value, @@ -127,6 +157,13 @@ class _$BudgetRequestSerializer implements PrimitiveSerializer { ) as String; result.name = valueDes; break; + case r'rollover': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(bool), + ) as bool; + result.rollover = valueDes; + break; case r'type': final valueDes = serializers.deserialize( value, @@ -171,6 +208,8 @@ class BudgetRequestTypeEnum extends EnumClass { static const BudgetRequestTypeEnum envelope = _$budgetRequestTypeEnum_envelope; @BuiltValueEnumConst(wireName: r'goal') static const BudgetRequestTypeEnum goal = _$budgetRequestTypeEnum_goal; + @BuiltValueEnumConst(wireName: r'income') + static const BudgetRequestTypeEnum income = _$budgetRequestTypeEnum_income; static Serializer get serializer => _$budgetRequestTypeEnumSerializer; diff --git a/mobile/api/lib/src/model/budget_request.g.dart b/mobile/api/lib/src/model/budget_request.g.dart index 25a10f4f..277a7180 100644 --- a/mobile/api/lib/src/model/budget_request.g.dart +++ b/mobile/api/lib/src/model/budget_request.g.dart @@ -12,6 +12,8 @@ const BudgetRequestTypeEnum _$budgetRequestTypeEnum_envelope = const BudgetRequestTypeEnum._('envelope'); const BudgetRequestTypeEnum _$budgetRequestTypeEnum_goal = const BudgetRequestTypeEnum._('goal'); +const BudgetRequestTypeEnum _$budgetRequestTypeEnum_income = + const BudgetRequestTypeEnum._('income'); BudgetRequestTypeEnum _$budgetRequestTypeEnumValueOf(String name) { switch (name) { @@ -21,6 +23,8 @@ BudgetRequestTypeEnum _$budgetRequestTypeEnumValueOf(String name) { return _$budgetRequestTypeEnum_envelope; case 'goal': return _$budgetRequestTypeEnum_goal; + case 'income': + return _$budgetRequestTypeEnum_income; default: throw ArgumentError(name); } @@ -31,6 +35,7 @@ final BuiltSet _$budgetRequestTypeEnumValues = _$budgetRequestTypeEnum_tracking, _$budgetRequestTypeEnum_envelope, _$budgetRequestTypeEnum_goal, + _$budgetRequestTypeEnum_income, ]); Serializer _$budgetRequestTypeEnumSerializer = @@ -42,11 +47,13 @@ class _$BudgetRequestTypeEnumSerializer 'tracking': 'tracking', 'envelope': 'envelope', 'goal': 'goal', + 'income': 'income', }; static const Map _fromWire = const { 'tracking': 'tracking', 'envelope': 'envelope', 'goal': 'goal', + 'income': 'income', }; @override @@ -72,14 +79,24 @@ class _$BudgetRequest extends BudgetRequest { @override final String? budgetedAmount; @override + final String? fundingAmount; + @override final String? name; @override + final bool? rollover; + @override final BudgetRequestTypeEnum? type; factory _$BudgetRequest([void Function(BudgetRequestBuilder)? updates]) => (BudgetRequestBuilder()..update(updates))._build(); - _$BudgetRequest._({this.balance, this.budgetedAmount, this.name, this.type}) + _$BudgetRequest._( + {this.balance, + this.budgetedAmount, + this.fundingAmount, + this.name, + this.rollover, + this.type}) : super._(); @override BudgetRequest rebuild(void Function(BudgetRequestBuilder) updates) => @@ -94,7 +111,9 @@ class _$BudgetRequest extends BudgetRequest { return other is BudgetRequest && balance == other.balance && budgetedAmount == other.budgetedAmount && + fundingAmount == other.fundingAmount && name == other.name && + rollover == other.rollover && type == other.type; } @@ -103,7 +122,9 @@ class _$BudgetRequest extends BudgetRequest { var _$hash = 0; _$hash = $jc(_$hash, balance.hashCode); _$hash = $jc(_$hash, budgetedAmount.hashCode); + _$hash = $jc(_$hash, fundingAmount.hashCode); _$hash = $jc(_$hash, name.hashCode); + _$hash = $jc(_$hash, rollover.hashCode); _$hash = $jc(_$hash, type.hashCode); _$hash = $jf(_$hash); return _$hash; @@ -114,7 +135,9 @@ class _$BudgetRequest extends BudgetRequest { return (newBuiltValueToStringHelper(r'BudgetRequest') ..add('balance', balance) ..add('budgetedAmount', budgetedAmount) + ..add('fundingAmount', fundingAmount) ..add('name', name) + ..add('rollover', rollover) ..add('type', type)) .toString(); } @@ -133,10 +156,19 @@ class BudgetRequestBuilder set budgetedAmount(String? budgetedAmount) => _$this._budgetedAmount = budgetedAmount; + String? _fundingAmount; + String? get fundingAmount => _$this._fundingAmount; + set fundingAmount(String? fundingAmount) => + _$this._fundingAmount = fundingAmount; + String? _name; String? get name => _$this._name; set name(String? name) => _$this._name = name; + bool? _rollover; + bool? get rollover => _$this._rollover; + set rollover(bool? rollover) => _$this._rollover = rollover; + BudgetRequestTypeEnum? _type; BudgetRequestTypeEnum? get type => _$this._type; set type(BudgetRequestTypeEnum? type) => _$this._type = type; @@ -150,7 +182,9 @@ class BudgetRequestBuilder if ($v != null) { _balance = $v.balance; _budgetedAmount = $v.budgetedAmount; + _fundingAmount = $v.fundingAmount; _name = $v.name; + _rollover = $v.rollover; _type = $v.type; _$v = null; } @@ -175,7 +209,9 @@ class BudgetRequestBuilder _$BudgetRequest._( balance: balance, budgetedAmount: budgetedAmount, + fundingAmount: fundingAmount, name: name, + rollover: rollover, type: type, ); replace(_$result); diff --git a/mobile/api/lib/src/model/budget_summary.dart b/mobile/api/lib/src/model/budget_summary.dart index 7471f130..f78dfc0b 100644 --- a/mobile/api/lib/src/model/budget_summary.dart +++ b/mobile/api/lib/src/model/budget_summary.dart @@ -19,7 +19,11 @@ part 'budget_summary.g.dart'; /// * [budgets] /// * [creditCardBalance] /// * [currentMonth] - Spendable, allocated and credit cards only apply to the current month. +/// * [earnedTotal] - Taken in across income budgets this month. +/// * [funded] - Funded this month, keyed by budget id. Every listed budget has an entry. +/// * [fundedTotal] - Put into envelopes this month. /// * [month] +/// * [received] - 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. /// * [spendable] - Synced money no budget has claimed. /// * [spent] - Spent this month, keyed by budget id. Every listed budget has an entry. /// * [spentByMonth] - Newest first, for the month picker. @@ -40,9 +44,25 @@ abstract class BudgetSummary implements Built get funded; + + /// Put into envelopes this month. + @BuiltValueField(wireName: r'funded_total') + String get fundedTotal; + @BuiltValueField(wireName: r'month') Date get month; + /// 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. + @BuiltValueField(wireName: r'received') + BuiltMap get received; + /// Synced money no budget has claimed. @BuiltValueField(wireName: r'spendable') String get spendable; @@ -102,11 +122,31 @@ class _$BudgetSummarySerializer implements PrimitiveSerializer { object.currentMonth, specifiedType: const FullType(bool), ); + yield r'earned_total'; + yield serializers.serialize( + object.earnedTotal, + specifiedType: const FullType(String), + ); + yield r'funded'; + yield serializers.serialize( + object.funded, + specifiedType: const FullType(BuiltMap, [FullType(String), FullType(String)]), + ); + yield r'funded_total'; + yield serializers.serialize( + object.fundedTotal, + specifiedType: const FullType(String), + ); yield r'month'; yield serializers.serialize( object.month, specifiedType: const FullType(Date), ); + yield r'received'; + yield serializers.serialize( + object.received, + specifiedType: const FullType(BuiltMap, [FullType(String), FullType(String)]), + ); yield r'spendable'; yield serializers.serialize( object.spendable, @@ -178,6 +218,27 @@ class _$BudgetSummarySerializer implements PrimitiveSerializer { ) as bool; result.currentMonth = valueDes; break; + case r'earned_total': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.earnedTotal = valueDes; + break; + case r'funded': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltMap, [FullType(String), FullType(String)]), + ) as BuiltMap; + result.funded.replace(valueDes); + break; + case r'funded_total': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(String), + ) as String; + result.fundedTotal = valueDes; + break; case r'month': final valueDes = serializers.deserialize( value, @@ -185,6 +246,13 @@ class _$BudgetSummarySerializer implements PrimitiveSerializer { ) as Date; result.month = valueDes; break; + case r'received': + final valueDes = serializers.deserialize( + value, + specifiedType: const FullType(BuiltMap, [FullType(String), FullType(String)]), + ) as BuiltMap; + result.received.replace(valueDes); + break; case r'spendable': final valueDes = serializers.deserialize( value, diff --git a/mobile/api/lib/src/model/budget_summary.g.dart b/mobile/api/lib/src/model/budget_summary.g.dart index 2ffc74c3..a9fe640a 100644 --- a/mobile/api/lib/src/model/budget_summary.g.dart +++ b/mobile/api/lib/src/model/budget_summary.g.dart @@ -16,8 +16,16 @@ class _$BudgetSummary extends BudgetSummary { @override final bool currentMonth; @override + final String earnedTotal; + @override + final BuiltMap funded; + @override + final String fundedTotal; + @override final Date month; @override + final BuiltMap received; + @override final String spendable; @override final BuiltMap spent; @@ -34,7 +42,11 @@ class _$BudgetSummary extends BudgetSummary { required this.budgets, required this.creditCardBalance, required this.currentMonth, + required this.earnedTotal, + required this.funded, + required this.fundedTotal, required this.month, + required this.received, required this.spendable, required this.spent, required this.spentByMonth, @@ -55,7 +67,11 @@ class _$BudgetSummary extends BudgetSummary { budgets == other.budgets && creditCardBalance == other.creditCardBalance && currentMonth == other.currentMonth && + earnedTotal == other.earnedTotal && + funded == other.funded && + fundedTotal == other.fundedTotal && month == other.month && + received == other.received && spendable == other.spendable && spent == other.spent && spentByMonth == other.spentByMonth && @@ -69,7 +85,11 @@ class _$BudgetSummary extends BudgetSummary { _$hash = $jc(_$hash, budgets.hashCode); _$hash = $jc(_$hash, creditCardBalance.hashCode); _$hash = $jc(_$hash, currentMonth.hashCode); + _$hash = $jc(_$hash, earnedTotal.hashCode); + _$hash = $jc(_$hash, funded.hashCode); + _$hash = $jc(_$hash, fundedTotal.hashCode); _$hash = $jc(_$hash, month.hashCode); + _$hash = $jc(_$hash, received.hashCode); _$hash = $jc(_$hash, spendable.hashCode); _$hash = $jc(_$hash, spent.hashCode); _$hash = $jc(_$hash, spentByMonth.hashCode); @@ -85,7 +105,11 @@ class _$BudgetSummary extends BudgetSummary { ..add('budgets', budgets) ..add('creditCardBalance', creditCardBalance) ..add('currentMonth', currentMonth) + ..add('earnedTotal', earnedTotal) + ..add('funded', funded) + ..add('fundedTotal', fundedTotal) ..add('month', month) + ..add('received', received) ..add('spendable', spendable) ..add('spent', spent) ..add('spentByMonth', spentByMonth) @@ -116,10 +140,29 @@ class BudgetSummaryBuilder bool? get currentMonth => _$this._currentMonth; set currentMonth(bool? currentMonth) => _$this._currentMonth = currentMonth; + String? _earnedTotal; + String? get earnedTotal => _$this._earnedTotal; + set earnedTotal(String? earnedTotal) => _$this._earnedTotal = earnedTotal; + + MapBuilder? _funded; + MapBuilder get funded => + _$this._funded ??= MapBuilder(); + set funded(MapBuilder? funded) => _$this._funded = funded; + + String? _fundedTotal; + String? get fundedTotal => _$this._fundedTotal; + set fundedTotal(String? fundedTotal) => _$this._fundedTotal = fundedTotal; + Date? _month; Date? get month => _$this._month; set month(Date? month) => _$this._month = month; + MapBuilder? _received; + MapBuilder get received => + _$this._received ??= MapBuilder(); + set received(MapBuilder? received) => + _$this._received = received; + String? _spendable; String? get spendable => _$this._spendable; set spendable(String? spendable) => _$this._spendable = spendable; @@ -150,7 +193,11 @@ class BudgetSummaryBuilder _budgets = $v.budgets.toBuilder(); _creditCardBalance = $v.creditCardBalance; _currentMonth = $v.currentMonth; + _earnedTotal = $v.earnedTotal; + _funded = $v.funded.toBuilder(); + _fundedTotal = $v.fundedTotal; _month = $v.month; + _received = $v.received.toBuilder(); _spendable = $v.spendable; _spent = $v.spent.toBuilder(); _spentByMonth = $v.spentByMonth.toBuilder(); @@ -185,8 +232,14 @@ class BudgetSummaryBuilder creditCardBalance, r'BudgetSummary', 'creditCardBalance'), currentMonth: BuiltValueNullFieldError.checkNotNull( currentMonth, r'BudgetSummary', 'currentMonth'), + earnedTotal: BuiltValueNullFieldError.checkNotNull( + earnedTotal, r'BudgetSummary', 'earnedTotal'), + funded: funded.build(), + fundedTotal: BuiltValueNullFieldError.checkNotNull( + fundedTotal, r'BudgetSummary', 'fundedTotal'), month: BuiltValueNullFieldError.checkNotNull( month, r'BudgetSummary', 'month'), + received: received.build(), spendable: BuiltValueNullFieldError.checkNotNull( spendable, r'BudgetSummary', 'spendable'), spent: spent.build(), @@ -200,6 +253,12 @@ class BudgetSummaryBuilder _$failedField = 'budgets'; budgets.build(); + _$failedField = 'funded'; + funded.build(); + + _$failedField = 'received'; + received.build(); + _$failedField = 'spent'; spent.build(); _$failedField = 'spentByMonth'; diff --git a/mobile/api/lib/src/serializers.g.dart b/mobile/api/lib/src/serializers.g.dart index fb862e98..537a6be1 100644 --- a/mobile/api/lib/src/serializers.g.dart +++ b/mobile/api/lib/src/serializers.g.dart @@ -61,6 +61,14 @@ Serializers _$serializers = (Serializers().toBuilder() const FullType( BuiltMap, const [const FullType(String), const FullType(String)]), () => MapBuilder()) + ..addBuilderFactory( + const FullType( + BuiltMap, const [const FullType(String), const FullType(String)]), + () => MapBuilder()) + ..addBuilderFactory( + const FullType( + BuiltMap, const [const FullType(String), const FullType(String)]), + () => MapBuilder()) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(MonthSpend)]), () => ListBuilder()) diff --git a/mobile/lib/budgets/budget_card.dart b/mobile/lib/budgets/budget_card.dart index ce834467..f83a140c 100644 --- a/mobile/lib/budgets/budget_card.dart +++ b/mobile/lib/budgets/budget_card.dart @@ -3,48 +3,102 @@ import 'package:spendable_api/spendable_api.dart'; import '../money.dart'; -/// Which of the three bars a card draws. Colours are the screen's business. -enum CardBar { under, over, goal } +/// Which of the four bars a card draws. Colours are the screen's business. +enum CardBar { under, over, goal, income } + +/// What one month moved through a budget. Which figure a card reads depends on what kind of +/// budget it is, and a budget that spends never receives. +class BudgetMonth { + const BudgetMonth({required this.spent, required this.received, required this.funded}); + + final Decimal spent; + final Decimal received; + final Decimal funded; +} /// What a budget reads as, mirroring `SpendableWeb.Utils.BudgetCard`. Both are driven by /// shared/budget_cards.json, which is the only thing keeping them from drifting apart. class BudgetCard { const BudgetCard({required this.amount, required this.label, this.percent, this.bar, this.footer}); - factory BudgetCard.build({required Budget budget, required Decimal spent, required bool currentMonth}) { - // A past month is a record of what was spent, so a balance read now says nothing about it. - if (!currentMonth) return BudgetCard(amount: spent, label: 'SPENT'); + factory BudgetCard.build({ + required Budget budget, + required BudgetMonth month, + required bool currentMonth, + }) { + final income = budget.type == BudgetTypeEnum.income; + final budgeted = budget.budgetedAmount == null ? null : money(budget.budgetedAmount!); + final spent = month.spent; + + // A past month is a record of what moved, so a balance read now says nothing about it. + if (!currentMonth) { + return income + ? BudgetCard(amount: month.received, label: 'EARNED') + : BudgetCard(amount: spent, label: 'SPENT'); + } if (budget.type == BudgetTypeEnum.tracking) { - return BudgetCard(amount: spent, label: 'SPENT'); + if (budgeted == null) return BudgetCard(amount: spent, label: 'SPENT'); + + return BudgetCard( + amount: spent, + label: 'SPENT', + percent: _percent(spent, budgeted), + bar: spent > budgeted ? CardBar.over : CardBar.under, + footer: '${formatCurrency(spent)} of ${formatCurrency(budgeted)} spent', + ); + } + + // Money in is the point here, so there is no bar to be the wrong side of: it fills as the + // month earns and the footer says how far along that is. + if (income) { + final received = month.received; + + if (budgeted == null) return BudgetCard(amount: received, label: 'EARNED'); + + return BudgetCard( + amount: received, + label: 'EARNED', + percent: _percent(received, budgeted), + bar: CardBar.income, + footer: '${formatCurrency(received)} of ${formatCurrency(budgeted)} received', + ); } final goal = budget.type == BudgetTypeEnum.goal; final balance = money(budget.balance); - final budgeted = budget.budgetedAmount == null ? null : money(budget.budgetedAmount!); + final fundsItself = budget.fundingAmount == null ? null : money(budget.fundingAmount!); + final funded = month.funded; if (budgeted == null) { - return goal - ? BudgetCard(amount: balance, label: 'SAVED', footer: 'No goal set') - : BudgetCard(amount: balance, label: 'LEFT'); + if (goal) return BudgetCard(amount: balance, label: 'SAVED', footer: 'No goal set'); + + final held = _held(balance); + + return BudgetCard(amount: held.amount, label: held.label, footer: _fundedFooter(funded)); } if (goal) { + final saved = '${formatCurrency(balance)} of ${formatCurrency(budgeted)} saved'; + return BudgetCard( amount: budgeted - balance, label: 'TO GO', percent: _percent(balance, budgeted), bar: CardBar.goal, - footer: '${formatCurrency(balance)} of ${formatCurrency(budgeted)} saved', + footer: fundsItself == null ? saved : '$saved · ${formatCurrency(fundsItself)}/mo', ); } + final spend = '${formatCurrency(spent)} of ${formatCurrency(budgeted)} spent'; + final held = _held(balance); + return BudgetCard( - amount: balance, - label: 'LEFT', + amount: held.amount, + label: held.label, percent: _percent(spent, budgeted), bar: spent > budgeted ? CardBar.over : CardBar.under, - footer: '${formatCurrency(spent)} of ${formatCurrency(budgeted)} spent', + footer: funded == Decimal.zero ? spend : '${formatCurrency(funded)} funded · $spend', ); } @@ -54,6 +108,18 @@ class BudgetCard { final CardBar? bar; final String? footer; + /// An envelope in the hole is not holding a negative amount, it is short by a positive one - + /// the same way a goal counts what is still TO GO rather than a negative saving. The screen + /// colours the label, since the figure no longer carries a minus sign to key off. + static ({Decimal amount, String label}) _held(Decimal balance) => balance < Decimal.zero + ? (amount: -balance, label: 'OVERSPENT') + : (amount: balance, label: 'LEFT'); + + /// What the month put in is only worth saying when a month put something in, so a budget the + /// user fills by hand reads exactly as it did before funding existed. + static String? _fundedFooter(Decimal funded) => + funded == Decimal.zero ? null : '${formatCurrency(funded)} funded'; + static double _percent(Decimal part, Decimal whole) { if (whole == Decimal.zero) return 0; diff --git a/mobile/lib/budgets/budget_form.dart b/mobile/lib/budgets/budget_form.dart index b89d4736..cda1c178 100644 --- a/mobile/lib/budgets/budget_form.dart +++ b/mobile/lib/budgets/budget_form.dart @@ -13,6 +13,7 @@ import 'budgets_controller.dart'; const _types = { BudgetRequestTypeEnum.envelope: 'Envelope', BudgetRequestTypeEnum.goal: 'Goal', + BudgetRequestTypeEnum.income: 'Income', BudgetRequestTypeEnum.tracking: 'Tracking', }; @@ -32,16 +33,24 @@ class _BudgetFormState extends ConsumerState { late final _budgetedAmount = TextEditingController(text: widget.budget?.budgetedAmount ?? ''); late final _balance = TextEditingController(text: widget.budget?.balance ?? ''); + late final _fundingAmount = TextEditingController(text: widget.budget?.fundingAmount ?? ''); + late var _type = switch (widget.budget?.type) { BudgetTypeEnum.goal => BudgetRequestTypeEnum.goal, BudgetTypeEnum.tracking => BudgetRequestTypeEnum.tracking, + BudgetTypeEnum.income => BudgetRequestTypeEnum.income, _ => BudgetRequestTypeEnum.envelope, }; + /// Off means the month tops the envelope back up to its amount, so an overspend does not follow + /// it into the next month and leftover does not pile up. + late var _rollover = widget.budget?.rollover ?? true; + @override void dispose() { _name.dispose(); _budgetedAmount.dispose(); + _fundingAmount.dispose(); _balance.dispose(); super.dispose(); } @@ -53,7 +62,9 @@ class _BudgetFormState extends ConsumerState { final errors = state.error is ApiError ? (state.error! as ApiError).fieldErrors : const {}; - final tracking = _type == BudgetRequestTypeEnum.tracking; + // Tracking and income record a month and hold nothing, so neither has a balance to allocate. + final holdsMoney = + _type == BudgetRequestTypeEnum.envelope || _type == BudgetRequestTypeEnum.goal; return Padding( padding: EdgeInsets.only( @@ -89,18 +100,63 @@ class _BudgetFormState extends ConsumerState { }, onValueChanged: (value) => setState(() => _type = value ?? _type), ), - if (!tracking) ...[ + const SizedBox(height: SpendableSpace.tight), + TextField( + key: const Key('budget-amount'), + controller: _budgetedAmount, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: SpendableType.moneyInline.copyWith(color: colors.primary), + decoration: InputDecoration( + labelText: _amountLabel, + errorText: errors['/budgeted_amount'], + ), + ), + if (_type == BudgetRequestTypeEnum.envelope) ...[ + const SizedBox(height: SpendableSpace.gutter), + // Its own amount rather than a switch tied to the budgeted one: a user can measure + // spending against 400 while only being able to put 300 in. Blank means it does not + // fund itself and the user fills it. + TextField( + key: const Key('budget-funding'), + controller: _fundingAmount, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: SpendableType.moneyInline.copyWith(color: colors.primary), + decoration: InputDecoration( + labelText: 'Fund each month', + errorText: errors['/funding_amount'], + ), + ), const SizedBox(height: SpendableSpace.tight), + Row( + children: [ + Expanded( + child: Text( + 'Carry the balance into next month', + style: SpendableType.body.copyWith(color: colors.primary), + ), + ), + CupertinoSwitch( + key: const Key('budget-rollover'), + value: _rollover, + onChanged: (value) => setState(() => _rollover = value), + ), + ], + ), + ], + if (_type == BudgetRequestTypeEnum.goal) ...[ + const SizedBox(height: SpendableSpace.gutter), TextField( - key: const Key('budget-amount'), - controller: _budgetedAmount, + key: const Key('budget-funding'), + controller: _fundingAmount, keyboardType: const TextInputType.numberWithOptions(decimal: true), style: SpendableType.moneyInline.copyWith(color: colors.primary), decoration: InputDecoration( - labelText: _type == BudgetRequestTypeEnum.goal ? 'Goal amount' : 'Budgeted amount', - errorText: errors['/budgeted_amount'], + labelText: 'Monthly contribution', + errorText: errors['/funding_amount'], ), ), + ], + if (holdsMoney) ...[ const SizedBox(height: SpendableSpace.gutter), TextField( key: const Key('budget-balance'), @@ -131,14 +187,17 @@ class _BudgetFormState extends ConsumerState { } Future _save() async { - final tracking = _type == BudgetRequestTypeEnum.tracking; + final holdsMoney = + _type == BudgetRequestTypeEnum.envelope || _type == BudgetRequestTypeEnum.goal; final request = BudgetRequest( (builder) => builder ..name = _name.text ..type = _type - ..budgetedAmount = tracking ? null : _blankToNull(_budgetedAmount.text) - ..balance = tracking ? null : _blankToNull(_balance.text), + ..budgetedAmount = _blankToNull(_budgetedAmount.text) + ..fundingAmount = _fundsItself() + ..rollover = _type == BudgetRequestTypeEnum.envelope ? _rollover : true + ..balance = holdsMoney ? _blankToNull(_balance.text) : null, ); await _close(ref.read(budgetsControllerProvider.notifier).save(id: widget.budget?.id, request: request)); @@ -154,4 +213,18 @@ class _BudgetFormState extends ConsumerState { } String? _blankToNull(String value) => value.trim().isEmpty ? null : value.trim(); + + String get _amountLabel => switch (_type) { + BudgetRequestTypeEnum.goal => 'Goal amount', + BudgetRequestTypeEnum.income => 'Expected each month', + BudgetRequestTypeEnum.tracking => 'Monthly limit', + _ => 'Budgeted amount', + }; + + /// Only a budget that holds money can fund itself. Blank means it does not, and the user fills + /// it themselves. + String? _fundsItself() => switch (_type) { + BudgetRequestTypeEnum.envelope || BudgetRequestTypeEnum.goal => _blankToNull(_fundingAmount.text), + _ => null, + }; } diff --git a/mobile/lib/budgets/budgets_providers.dart b/mobile/lib/budgets/budgets_providers.dart index bcf22764..d779dc70 100644 --- a/mobile/lib/budgets/budgets_providers.dart +++ b/mobile/lib/budgets/budgets_providers.dart @@ -70,6 +70,7 @@ List listedBudgets(BudgetSummary summary) { ..id = creditCardsId ..name = 'Credit Cards' ..type = BudgetTypeEnum.envelope + ..rollover = true ..balance = (-money(summary.creditCardBalance)).toString(), ); @@ -80,7 +81,8 @@ List listedBudgets(BudgetSummary summary) { int _typeOrder(BudgetTypeEnum type) => switch (type) { BudgetTypeEnum.envelope => 0, - BudgetTypeEnum.goal => 2, + BudgetTypeEnum.income => 2, + BudgetTypeEnum.goal => 3, _ => 1, }; diff --git a/mobile/lib/budgets/budgets_screen.dart b/mobile/lib/budgets/budgets_screen.dart index 64c49a79..440a9fd9 100644 --- a/mobile/lib/budgets/budgets_screen.dart +++ b/mobile/lib/budgets/budgets_screen.dart @@ -171,28 +171,33 @@ class _Totals extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (summary.currentMonth) - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Caption('Spendable'), - MoneyText( - money(summary.spendable), - key: const Key('spendable-total'), - style: SpendableType.moneyHero, - creditIsPositive: true, - ), - ], - ), + if (summary.currentMonth) ...[ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Caption('Spendable'), + MoneyText( + money(summary.spendable), + key: const Key('spendable-total'), + style: SpendableType.moneyHero, + creditIsPositive: true, ), - if (summary.currentMonth) ...[ - _Total(label: 'Allocated', amount: money(summary.allocatedTotal)), - const SizedBox(width: SpendableSpace.gutter), ], + ), + const SizedBox(height: SpendableSpace.tight), + ], + // Three figures will not sit beside the hero on a phone, so they take the line under it, + // and wrap rather than overflow when the amounts run long. Earned beside Funded is the + // check the whole method rests on: budgets that promise more each month than the month + // brings in are the thing worth noticing. + Wrap( + alignment: WrapAlignment.end, + spacing: SpendableSpace.gutter, + runSpacing: SpendableSpace.tight, + children: [ + _Total(label: 'Earned', amount: money(summary.earnedTotal)), + if (summary.currentMonth) + _Total(label: 'Funded', amount: money(summary.fundedTotal)), _Total(label: 'Spent', amount: money(summary.spentTotal)), ], ), @@ -233,21 +238,31 @@ class _Row extends StatelessWidget { Widget build(BuildContext context) { final colors = SpendableColors.of(context); + // No `abs` here: spending arrives already netted and signed, so a month refunded more than it + // spent has to stay negative rather than read as that much spending. final card = BudgetCard.build( budget: budget, - spent: money(summary.spent[budget.id] ?? '0').abs(), + month: BudgetMonth( + spent: money(summary.spent[budget.id] ?? '0'), + received: money(summary.received[budget.id] ?? '0'), + funded: money(summary.funded[budget.id] ?? '0'), + ), currentMonth: summary.currentMonth, ); // Card debt is not an envelope with something left in it, it is what is owed right now, and // calling it an envelope in the margin is only there to satisfy the card it is built from. + // What is owed reads as a negative balance rather than as an overspend, which is what the + // card would otherwise make of an envelope in the hole. final creditCards = budget.id == creditCardsId; final label = creditCards ? 'BALANCE' : card.label; + final amount = creditCards ? money(budget.balance) : card.amount; final barColors = { CardBar.under: colors.accent, CardBar.over: colors.negative, CardBar.goal: colors.positive, + CardBar.income: colors.positive, }; return LedgerRow( @@ -306,7 +321,12 @@ class _Row extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - MoneyText(card.amount, key: Key('amount-${budget.id}'), style: SpendableType.moneyRow), + MoneyText( + amount, + key: Key('amount-${budget.id}'), + style: SpendableType.moneyRow, + danger: label == 'OVERSPENT', + ), Caption(label), ], ), diff --git a/mobile/lib/design/money_text.dart b/mobile/lib/design/money_text.dart index 3169de87..b8276e38 100644 --- a/mobile/lib/design/money_text.dart +++ b/mobile/lib/design/money_text.dart @@ -13,6 +13,7 @@ class MoneyText extends StatelessWidget { required this.style, this.creditIsPositive = false, this.neutral = false, + this.danger = false, }); final Decimal amount; @@ -23,11 +24,16 @@ class MoneyText extends StatelessWidget { /// nothing - the sign is already in the figure. final bool neutral; + /// Set where the figure is bad news but carries no minus sign to say so, as an overspend does: + /// it is what is short, written as a positive amount. + final bool danger; + @override Widget build(BuildContext context) { final colors = SpendableColors.of(context); final color = switch (amount.sign) { + _ when danger => colors.negative, _ when neutral => colors.primary, < 0 => colors.negative, _ => creditIsPositive ? colors.positive : colors.primary, diff --git a/mobile/test/banks/banks_screen_test.dart b/mobile/test/banks/banks_screen_test.dart index 30ffde9a..1aad9fa3 100644 --- a/mobile/test/banks/banks_screen_test.dart +++ b/mobile/test/banks/banks_screen_test.dart @@ -83,6 +83,7 @@ const _budgets = [ 'type': 'envelope', 'balance': '900.00', 'budgeted_amount': '1000.00', + 'rollover': true, 'archived_at': null, }, ]; diff --git a/mobile/test/budgets/budget_card_test.dart b/mobile/test/budgets/budget_card_test.dart index 17bb98f9..0a3965fa 100644 --- a/mobile/test/budgets/budget_card_test.dart +++ b/mobile/test/budgets/budget_card_test.dart @@ -11,7 +11,12 @@ import 'package:spendable_api/spendable_api.dart'; /// side that the other did not follow fails here. final _fixtures = jsonDecode(File('../shared/budget_cards.json').readAsStringSync()) as Map; -const _bars = {'under': CardBar.under, 'over': CardBar.over, 'goal': CardBar.goal}; +const _bars = { + 'under': CardBar.under, + 'over': CardBar.over, + 'goal': CardBar.goal, + 'income': CardBar.income, +}; void main() { for (final entry in _fixtures['cards'] as List) { @@ -26,12 +31,20 @@ void main() { ..name = 'Fixture' ..type = BudgetTypeEnum.valueOf(source['type'] as String) ..balance = source['balance'] as String - ..budgetedAmount = source['budgeted_amount'] as String?, + ..budgetedAmount = source['budgeted_amount'] as String? + ..fundingAmount = source['funding_amount'] as String? + ..rollover = (source['rollover'] as bool?) ?? true, ); + final figures = fixture['month'] as Map; + final card = BudgetCard.build( budget: budget, - spent: money(fixture['spent'] as String), + month: BudgetMonth( + spent: money(figures['spent'] as String), + received: money(figures['received'] as String), + funded: money(figures['funded'] as String), + ), currentMonth: fixture['current_month'] as bool, ); diff --git a/mobile/test/budgets/budgets_screen_test.dart b/mobile/test/budgets/budgets_screen_test.dart index b20d5d6e..f8237eb7 100644 --- a/mobile/test/budgets/budgets_screen_test.dart +++ b/mobile/test/budgets/budgets_screen_test.dart @@ -13,12 +13,14 @@ Map _budget( String type = 'envelope', String balance = '0.00', String? budgetedAmount, + bool rollover = true, }) => { 'id': id, 'name': name, 'type': type, 'balance': balance, 'budgeted_amount': budgetedAmount, + 'rollover': rollover, 'archived_at': null, }; @@ -27,11 +29,15 @@ Map _summary({ String creditCardBalance = '0.00', List>? budgets, Map? spent, + Map? funded, + Map? received, }) => { 'month': '2026-08-01', 'current_month': currentMonth, 'spendable': '420.00', 'allocated_total': '1000.00', + 'funded_total': '1000.00', + 'earned_total': '0.00', 'spent_total': '250.00', 'credit_card_balance': creditCardBalance, 'budgets': @@ -40,7 +46,9 @@ Map _summary({ _budget('bgt_spendable', 'Spendable'), _budget('bgt_food', 'Food', balance: '50.00', budgetedAmount: '200.00'), ], - 'spent': spent ?? {'bgt_spendable': '0.00', 'bgt_food': '-150.00'}, + 'spent': spent ?? {'bgt_spendable': '0.00', 'bgt_food': '150.00'}, + 'funded': funded ?? {'bgt_spendable': '0.00', 'bgt_food': '0.00'}, + 'received': received ?? {'bgt_spendable': '0.00', 'bgt_food': '0.00'}, 'spent_by_month': [ {'month': '2026-08-01', 'spent': '-250.00'}, {'month': '2026-07-01', 'spent': '-310.00'}, @@ -259,4 +267,97 @@ void main() { expect(rows, orderedEquals(rows.toList()..sort())); }); + + testWidgets('setting an amount makes an envelope fund itself', (tester) async { + final api = await _pump( + tester, + replies: { + 'GET /api/budgets/summary': (status: 200, body: _summary()), + 'PATCH /api/budgets/bgt_food': ( + status: 200, + body: _budget('bgt_food', 'Food', balance: '200.00', budgetedAmount: '200.00'), + ), + }, + ); + + await tester.tap(find.text('Food')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('budget-funding')), '150.00'); + await tester.tap(find.byKey(const Key('budget-save'))); + await tester.pumpAndSettle(); + + final sent = api.requests.firstWhere((request) => request.method == 'PATCH').data as Map; + + // The two amounts are separate questions: measure against 200, put 150 in. + expect(sent['budgeted_amount'], '200.00'); + expect(sent['funding_amount'], '150.00'); + }); + + testWidgets('an income budget is asked what it expects, not what it allocates', (tester) async { + await _pump(tester); + + await tester.tap(find.text('Food')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Income')); + await tester.pumpAndSettle(); + + expect(find.text('Expected each month'), findsOneWidget); + expect(find.byKey(const Key('budget-balance')), findsNothing); + expect(find.byKey(const Key('budget-funding')), findsNothing); + }); + + testWidgets('a goal names its own monthly contribution', (tester) async { + await _pump(tester); + + await tester.tap(find.text('Food')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Goal')); + await tester.pumpAndSettle(); + + expect(find.text('Monthly contribution'), findsOneWidget); + }); + + + testWidgets('an envelope can decline to carry its balance into next month', (tester) async { + final api = await _pump( + tester, + replies: { + 'GET /api/budgets/summary': (status: 200, body: _summary()), + 'PATCH /api/budgets/bgt_food': ( + status: 200, + body: _budget('bgt_food', 'Food', balance: '50.00', budgetedAmount: '200.00'), + ), + }, + ); + + await tester.tap(find.text('Food')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('budget-rollover'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('budget-save'))); + await tester.pumpAndSettle(); + + final sent = api.requests.firstWhere((request) => request.method == 'PATCH').data; + + expect((sent! as Map)['rollover'], false); + }); + + testWidgets('only an envelope is offered the rollover switch', (tester) async { + await _pump(tester); + + await tester.tap(find.text('Food')); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('budget-rollover')), findsOneWidget); + + await tester.tap(find.text('Goal')); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('budget-rollover')), findsNothing); + }); } diff --git a/mobile/test/design/layout_test.dart b/mobile/test/design/layout_test.dart index d6dcea6b..6397d116 100644 --- a/mobile/test/design/layout_test.dart +++ b/mobile/test/design/layout_test.dart @@ -22,6 +22,8 @@ class _Api implements HttpClientAdapter { 'current_month': true, 'spendable': '1284.55', 'allocated_total': '4200.00', + 'funded_total': '4200.00', + 'earned_total': '5100.00', 'spent_total': '2915.45', 'credit_card_balance': '1204.66', 'budgets': [ @@ -31,6 +33,7 @@ class _Api implements HttpClientAdapter { 'type': 'envelope', 'balance': '142.18', 'budgeted_amount': '500.00', + 'rollover': true, 'archived_at': null, }, { @@ -39,6 +42,7 @@ class _Api implements HttpClientAdapter { 'type': 'envelope', 'balance': '-63.40', 'budgeted_amount': '200.00', + 'rollover': true, 'archived_at': null, }, { @@ -47,6 +51,7 @@ class _Api implements HttpClientAdapter { 'type': 'goal', 'balance': '1500.00', 'budgeted_amount': '5000.00', + 'rollover': true, 'archived_at': null, }, { @@ -55,10 +60,13 @@ class _Api implements HttpClientAdapter { 'type': 'tracking', 'balance': '0.00', 'budgeted_amount': null, + 'rollover': true, 'archived_at': null, }, ], - 'spent': {'b1': '-357.82', 'b2': '-263.40', 'b3': '0.00', 'b4': '-88.02'}, + 'spent': {'b1': '357.82', 'b2': '263.40', 'b3': '0.00', 'b4': '88.02'}, + 'funded': {'b1': '500.00', 'b2': '200.00', 'b3': '0.00', 'b4': '0.00'}, + 'received': {'b1': '0.00', 'b2': '0.00', 'b3': '0.00', 'b4': '0.00'}, 'spent_by_month': [ {'month': '2026-08-01', 'spent': '-2915.45'}, {'month': '2026-07-01', 'spent': '-2480.19'}, @@ -126,7 +134,8 @@ class _Api implements HttpClientAdapter { { 'id': 's1', 'name': 'Payday', - 'archived_at': null, + 'rollover': true, + 'archived_at': null, 'split_lines': [ {'id': 'sl1', 'budget_id': 'b1', 'amount': '100.00'}, ], diff --git a/mobile/test/transactions/transaction_detail_test.dart b/mobile/test/transactions/transaction_detail_test.dart index ff56447a..3a624fc5 100644 --- a/mobile/test/transactions/transaction_detail_test.dart +++ b/mobile/test/transactions/transaction_detail_test.dart @@ -35,6 +35,7 @@ const _budgets = [ 'type': 'envelope', 'balance': '50.00', 'budgeted_amount': '200.00', + 'rollover': true, 'archived_at': null, }, { @@ -43,6 +44,7 @@ const _budgets = [ 'type': 'envelope', 'balance': '30.00', 'budgeted_amount': '100.00', + 'rollover': true, 'archived_at': null, }, ]; @@ -51,6 +53,7 @@ const _splits = [ { 'id': 'spl_payday', 'name': 'Payday', + 'rollover': true, 'archived_at': null, 'split_lines': [ {'id': 'spll_1', 'amount': '-12.00', 'budget_id': 'bgt_food'}, diff --git a/mobile/test/transactions/transactions_screen_test.dart b/mobile/test/transactions/transactions_screen_test.dart index 14e2163e..ee56b5a3 100644 --- a/mobile/test/transactions/transactions_screen_test.dart +++ b/mobile/test/transactions/transactions_screen_test.dart @@ -54,6 +54,7 @@ const _budgets = [ 'type': 'envelope', 'balance': '50.00', 'budgeted_amount': '200.00', + 'rollover': true, 'archived_at': null, }, { @@ -62,6 +63,7 @@ const _budgets = [ 'type': 'envelope', 'balance': '30.00', 'budgeted_amount': '100.00', + 'rollover': true, 'archived_at': null, }, ]; @@ -70,6 +72,7 @@ const _splits = [ { 'id': 'spl_payday', 'name': 'Payday', + 'rollover': true, 'archived_at': null, 'split_lines': [ {'id': 'spll_1', 'amount': '-12.00', 'budget_id': 'bgt_food'}, diff --git a/priv/repo/migrations/20260815165747_baseline.exs b/priv/repo/migrations/20260815165747_baseline.exs index d5b8c0bb..b4f7796e 100644 --- a/priv/repo/migrations/20260815165747_baseline.exs +++ b/priv/repo/migrations/20260815165747_baseline.exs @@ -17,8 +17,8 @@ defmodule Spendable.Repo.Migrations.Baseline do create table(:budgets) do add :name, :citext, null: false - add :adjustment, :numeric, null: false, default: 0.00 - add :budgeted_amount, :numeric + add :adjustment, :decimal, null: false, default: 0.00 + add :budgeted_amount, :decimal add :type, :text, null: false, default: "envelope" add :archived_at, :utc_datetime_usec add :user_id, references(:users), null: false @@ -46,7 +46,7 @@ defmodule Spendable.Repo.Migrations.Baseline do create table(:bank_accounts) do add :external_id, :text, null: false - add :balance, :numeric, null: false + add :balance, :decimal, null: false add :name, :text, null: false add :number, :text add :sub_type, :text, null: false @@ -64,7 +64,7 @@ defmodule Spendable.Repo.Migrations.Baseline do create table(:bank_transactions) do add :external_id, :text, null: false - add :amount, :numeric, precision: 17, scale: 2, null: false + add :amount, :decimal, precision: 17, scale: 2, null: false add :date, :date, null: false add :name, :text, null: false add :pending, :boolean, null: false @@ -79,7 +79,7 @@ defmodule Spendable.Repo.Migrations.Baseline do create index(:bank_transactions, [:user_id]) create table(:transactions) do - add :amount, :numeric, null: false + add :amount, :decimal, null: false add :date, :date, null: false add :name, :citext, null: false add :note, :citext @@ -95,7 +95,7 @@ defmodule Spendable.Repo.Migrations.Baseline do create index(:transactions, [:user_id]) create table(:budget_allocations) do - add :amount, :numeric, precision: 17, scale: 2, null: false + add :amount, :decimal, precision: 17, scale: 2, null: false add :user_id, references(:users), null: false add :budget_id, references(:budgets), null: false add :transaction_id, references(:transactions, on_delete: :delete_all), null: false @@ -118,7 +118,7 @@ defmodule Spendable.Repo.Migrations.Baseline do create index(:budget_allocation_templates, [:user_id]) create table(:budget_allocation_template_lines) do - add :amount, :numeric, precision: 17, scale: 2, null: false + add :amount, :decimal, precision: 17, scale: 2, null: false add :user_id, references(:users), null: false add :budget_id, references(:budgets), null: false diff --git a/priv/repo/migrations/20260817000113_add_fundings.exs b/priv/repo/migrations/20260817000113_add_fundings.exs new file mode 100644 index 00000000..0db75ea5 --- /dev/null +++ b/priv/repo/migrations/20260817000113_add_fundings.exs @@ -0,0 +1,23 @@ +defmodule Spendable.Repo.Migrations.AddFundings do + use Ecto.Migration + + def change do + create table(:fundings) do + add :amount, :decimal, precision: 17, scale: 2, null: false + add :month, :date, null: false + add :user_id, references(:users), null: false + add :budget_id, references(:budgets, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:fundings, [:user_id]) + + # One row per budget per month is what makes funding a month safe to re-run. + create unique_index(:fundings, [:budget_id, :month]) + + alter table(:budgets) do + add :funding_amount, :decimal + end + end +end diff --git a/priv/repo/migrations/20260817004911_add_budget_rollover.exs b/priv/repo/migrations/20260817004911_add_budget_rollover.exs new file mode 100644 index 00000000..41ede09d --- /dev/null +++ b/priv/repo/migrations/20260817004911_add_budget_rollover.exs @@ -0,0 +1,11 @@ +defmodule Spendable.Repo.Migrations.AddBudgetRollover do + use Ecto.Migration + + def change do + # True keeps what every budget already does: a balance carries forward, so an overspend eats + # into the next month rather than being topped back up. + alter table(:budgets) do + add :rollover, :boolean, null: false, default: true + end + end +end diff --git a/priv/static/openapi.json b/priv/static/openapi.json index ee2e758a..29444221 100644 --- a/priv/static/openapi.json +++ b/priv/static/openapi.json @@ -154,7 +154,7 @@ "x-validate": null }, "balance": { - "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.\n", "type": "string", "x-struct": null, "x-validate": null @@ -165,6 +165,13 @@ "x-struct": null, "x-validate": null }, + "funding_amount": { + "description": "What the budget puts into itself each month. Null means it does not fund itself.", + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, "id": { "type": "string", "x-struct": null, @@ -175,11 +182,18 @@ "x-struct": null, "x-validate": null }, + "rollover": { + "description": "Whether the balance carries into next month. False means the month tops the budget back up\nto its funding amount instead, so an overspend does not follow it and leftover does not\naccumulate. Only an envelope can decline to roll over.\n", + "type": "boolean", + "x-struct": null, + "x-validate": null + }, "type": { "enum": [ "tracking", "envelope", - "goal" + "goal", + "income" ], "type": "string", "x-struct": null, @@ -190,7 +204,8 @@ "id", "name", "type", - "balance" + "balance", + "rollover" ], "title": "Budget", "type": "object", @@ -256,9 +271,10 @@ "x-validate": null }, "BudgetRequest": { - "description": "Amounts are decimal strings. `balance` is what the user wants the budget to hold - the server\nworks out the adjustment that gets it there, so never send `adjustment`.\n", + "description": "Amounts are decimal strings. `balance` is what the user wants the budget to hold - the server\nworks out the adjustment that gets it there, so never send `adjustment`.\n\n`funding_amount` is what the budget puts into itself each month; setting it is what makes a\nbudget fill on its own instead of being fed by hand. Only an envelope or a goal can hold\nmoney, so it is ignored on a tracking or income budget.\n\n`rollover` says whether the balance carries into next month. Send false and each month tops the\nbudget back up to its funding amount instead. Only an envelope can decline to roll over.\n", "example": { "budgeted_amount": "400.00", + "funding_amount": "400.00", "name": "Groceries", "type": "envelope" }, @@ -274,16 +290,28 @@ "x-struct": null, "x-validate": null }, + "funding_amount": { + "nullable": true, + "type": "string", + "x-struct": null, + "x-validate": null + }, "name": { "type": "string", "x-struct": null, "x-validate": null }, + "rollover": { + "type": "boolean", + "x-struct": null, + "x-validate": null + }, "type": { "enum": [ "tracking", "envelope", - "goal" + "goal", + "income" ], "type": "string", "x-struct": null, @@ -323,12 +351,46 @@ "x-struct": null, "x-validate": null }, + "earned_total": { + "description": "Taken in across income budgets this month.", + "type": "string", + "x-struct": null, + "x-validate": null + }, + "funded": { + "additionalProperties": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "description": "Funded this month, keyed by budget id. Every listed budget has an entry.", + "type": "object", + "x-struct": null, + "x-validate": null + }, + "funded_total": { + "description": "Put into envelopes this month.", + "type": "string", + "x-struct": null, + "x-validate": null + }, "month": { "format": "date", "type": "string", "x-struct": null, "x-validate": null }, + "received": { + "additionalProperties": { + "type": "string", + "x-struct": null, + "x-validate": null + }, + "description": "Taken in this month, keyed by budget id. Only an income budget receives; every other\nbudget is zero here, and money arriving in one of those is a refund counted against its\nspending instead.\n", + "type": "object", + "x-struct": null, + "x-validate": null + }, "spendable": { "description": "Synced money no budget has claimed.", "type": "string", @@ -367,10 +429,14 @@ "current_month", "spendable", "allocated_total", + "funded_total", + "earned_total", "spent_total", "credit_card_balance", "budgets", "spent", + "received", + "funded", "spent_by_month" ], "title": "BudgetSummary", diff --git a/shared/budget_cards.json b/shared/budget_cards.json index 44eceb94..7b4df27d 100644 --- a/shared/budget_cards.json +++ b/shared/budget_cards.json @@ -8,41 +8,29 @@ { "name": "a past month is a record of what was spent", "current_month": false, - "budget": { "type": "envelope", "balance": "50.00", "budgeted_amount": "200.00" }, - "spent": "45.50", + "budget": { "type": "envelope", "balance": "50.00", "budgeted_amount": "200.00", "funding_amount": null }, + "month": { "spent": "45.50", "received": "0.00", "funded": "0.00" }, "card": { "amount": "45.50", "label": "SPENT", "percent": null, "bar": null, "footer": null } }, { "name": "a tracking budget only ever reports the spend", "current_month": true, - "budget": { "type": "tracking", "balance": "0.00", "budgeted_amount": null }, - "spent": "12.34", - "card": { - "amount": "12.34", - "label": "SPENT", - "percent": null, - "bar": null, - "footer": null - } + "budget": { "type": "tracking", "balance": "0.00", "budgeted_amount": null, "funding_amount": null }, + "month": { "spent": "12.34", "received": "0.00", "funded": "0.00" }, + "card": { "amount": "12.34", "label": "SPENT", "percent": null, "bar": null, "footer": null } }, { "name": "an envelope with no amount budgeted has nothing to be a fraction of", "current_month": true, - "budget": { "type": "envelope", "balance": "80.00", "budgeted_amount": null }, - "spent": "10.00", - "card": { - "amount": "80.00", - "label": "LEFT", - "percent": null, - "bar": null, - "footer": null - } + "budget": { "type": "envelope", "balance": "80.00", "budgeted_amount": null, "funding_amount": null }, + "month": { "spent": "10.00", "received": "0.00", "funded": "0.00" }, + "card": { "amount": "80.00", "label": "LEFT", "percent": null, "bar": null, "footer": null } }, { "name": "an envelope under budget", "current_month": true, - "budget": { "type": "envelope", "balance": "50.00", "budgeted_amount": "200.00" }, - "spent": "150.00", + "budget": { "type": "envelope", "balance": "50.00", "budgeted_amount": "200.00", "funding_amount": null }, + "month": { "spent": "150.00", "received": "0.00", "funded": "0.00" }, "card": { "amount": "50.00", "label": "LEFT", @@ -54,8 +42,8 @@ { "name": "spending exactly the budget is not over it", "current_month": true, - "budget": { "type": "envelope", "balance": "0.00", "budgeted_amount": "200.00" }, - "spent": "200.00", + "budget": { "type": "envelope", "balance": "0.00", "budgeted_amount": "200.00", "funding_amount": null }, + "month": { "spent": "200.00", "received": "0.00", "funded": "0.00" }, "card": { "amount": "0.00", "label": "LEFT", @@ -67,11 +55,11 @@ { "name": "an envelope over budget keeps the bar full", "current_month": true, - "budget": { "type": "envelope", "balance": "-25.00", "budgeted_amount": "200.00" }, - "spent": "225.00", + "budget": { "type": "envelope", "balance": "-25.00", "budgeted_amount": "200.00", "funding_amount": null }, + "month": { "spent": "225.00", "received": "0.00", "funded": "0.00" }, "card": { - "amount": "-25.00", - "label": "LEFT", + "amount": "25.00", + "label": "OVERSPENT", "percent": 100.0, "bar": "over", "footer": "$225.00 of $200.00 spent" @@ -80,8 +68,8 @@ { "name": "a budget of zero divides by nothing and is over the moment anything is spent", "current_month": true, - "budget": { "type": "envelope", "balance": "10.00", "budgeted_amount": "0.00" }, - "spent": "5.00", + "budget": { "type": "envelope", "balance": "10.00", "budgeted_amount": "0.00", "funding_amount": null }, + "month": { "spent": "5.00", "received": "0.00", "funded": "0.00" }, "card": { "amount": "10.00", "label": "LEFT", @@ -93,8 +81,8 @@ { "name": "a percentage that does not divide evenly rounds to one place", "current_month": true, - "budget": { "type": "envelope", "balance": "100.00", "budgeted_amount": "300.00" }, - "spent": "200.00", + "budget": { "type": "envelope", "balance": "100.00", "budgeted_amount": "300.00", "funding_amount": null }, + "month": { "spent": "200.00", "received": "0.00", "funded": "0.00" }, "card": { "amount": "100.00", "label": "LEFT", @@ -106,11 +94,11 @@ { "name": "thousands are grouped on both sides of the footer", "current_month": true, - "budget": { "type": "envelope", "balance": "-1500.75", "budgeted_amount": "1000.00" }, - "spent": "2500.75", + "budget": { "type": "envelope", "balance": "-1500.75", "budgeted_amount": "1000.00", "funding_amount": null }, + "month": { "spent": "2500.75", "received": "0.00", "funded": "0.00" }, "card": { - "amount": "-1500.75", - "label": "LEFT", + "amount": "1500.75", + "label": "OVERSPENT", "percent": 100.0, "bar": "over", "footer": "$2,500.75 of $1,000.00 spent" @@ -119,21 +107,15 @@ { "name": "a goal with no target has nothing to go", "current_month": true, - "budget": { "type": "goal", "balance": "500.00", "budgeted_amount": null }, - "spent": "0.00", - "card": { - "amount": "500.00", - "label": "SAVED", - "percent": null, - "bar": null, - "footer": "No goal set" - } + "budget": { "type": "goal", "balance": "500.00", "budgeted_amount": null, "funding_amount": null }, + "month": { "spent": "0.00", "received": "0.00", "funded": "0.00" }, + "card": { "amount": "500.00", "label": "SAVED", "percent": null, "bar": null, "footer": "No goal set" } }, { "name": "a goal counts down to the target rather than up from zero", "current_month": true, - "budget": { "type": "goal", "balance": "250.00", "budgeted_amount": "1000.00" }, - "spent": "0.00", + "budget": { "type": "goal", "balance": "250.00", "budgeted_amount": "1000.00", "funding_amount": null }, + "month": { "spent": "0.00", "received": "0.00", "funded": "0.00" }, "card": { "amount": "750.00", "label": "TO GO", @@ -145,8 +127,8 @@ { "name": "saving past the goal goes negative and the bar stops at full", "current_month": true, - "budget": { "type": "goal", "balance": "1200.00", "budgeted_amount": "1000.00" }, - "spent": "0.00", + "budget": { "type": "goal", "balance": "1200.00", "budgeted_amount": "1000.00", "funding_amount": null }, + "month": { "spent": "0.00", "received": "0.00", "funded": "0.00" }, "card": { "amount": "-200.00", "label": "TO GO", @@ -154,6 +136,144 @@ "bar": "goal", "footer": "$1,200.00 of $1,000.00 saved" } + }, + { + "name": "a tracking budget with a monthly amount reads like an envelope's bar", + "current_month": true, + "budget": { "type": "tracking", "balance": "0.00", "budgeted_amount": "200.00", "funding_amount": null }, + "month": { "spent": "150.00", "received": "0.00", "funded": "0.00" }, + "card": { + "amount": "150.00", + "label": "SPENT", + "percent": 75.0, + "bar": "under", + "footer": "$150.00 of $200.00 spent" + } + }, + { + "name": "a tracking budget over its monthly amount", + "current_month": true, + "budget": { "type": "tracking", "balance": "0.00", "budgeted_amount": "200.00", "funding_amount": null }, + "month": { "spent": "225.00", "received": "0.00", "funded": "0.00" }, + "card": { + "amount": "225.00", + "label": "SPENT", + "percent": 100.0, + "bar": "over", + "footer": "$225.00 of $200.00 spent" + } + }, + { + "name": "an income budget reads as what it took in", + "current_month": true, + "budget": { "type": "income", "balance": "0.00", "budgeted_amount": null, "funding_amount": null }, + "month": { "spent": "0.00", "received": "4200.00", "funded": "0.00" }, + "card": { "amount": "4200.00", "label": "EARNED", "percent": null, "bar": null, "footer": null } + }, + { + "name": "income short of what the month expected", + "current_month": true, + "budget": { "type": "income", "balance": "0.00", "budgeted_amount": "4200.00", "funding_amount": null }, + "month": { "spent": "0.00", "received": "3150.00", "funded": "0.00" }, + "card": { + "amount": "3150.00", + "label": "EARNED", + "percent": 75.0, + "bar": "income", + "footer": "$3,150.00 of $4,200.00 received" + } + }, + { + "name": "earning past what the month expected stops the bar at full", + "current_month": true, + "budget": { "type": "income", "balance": "0.00", "budgeted_amount": "4200.00", "funding_amount": null }, + "month": { "spent": "0.00", "received": "5000.00", "funded": "0.00" }, + "card": { + "amount": "5000.00", + "label": "EARNED", + "percent": 100.0, + "bar": "income", + "footer": "$5,000.00 of $4,200.00 received" + } + }, + { + "name": "a past month reports what an income budget took in, not what it spent", + "current_month": false, + "budget": { "type": "income", "balance": "0.00", "budgeted_amount": "4200.00", "funding_amount": null }, + "month": { "spent": "0.00", "received": "4200.00", "funded": "0.00" }, + "card": { "amount": "4200.00", "label": "EARNED", "percent": null, "bar": null, "footer": null } + }, + { + "name": "an envelope says what the month funded it with", + "current_month": true, + "budget": { "type": "envelope", "balance": "160.00", "budgeted_amount": "300.00", "funding_amount": "300.00" }, + "month": { "spent": "140.00", "received": "0.00", "funded": "300.00" }, + "card": { + "amount": "160.00", + "label": "LEFT", + "percent": 46.7, + "bar": "under", + "footer": "$300.00 funded \u00b7 $140.00 of $300.00 spent" + } + }, + { + "name": "a month funded below the usual amount says so", + "current_month": true, + "budget": { "type": "envelope", "balance": "60.00", "budgeted_amount": "300.00", "funding_amount": "300.00" }, + "month": { "spent": "140.00", "received": "0.00", "funded": "200.00" }, + "card": { + "amount": "60.00", + "label": "LEFT", + "percent": 46.7, + "bar": "under", + "footer": "$200.00 funded \u00b7 $140.00 of $300.00 spent" + } + }, + { + "name": "an envelope with no amount budgeted still says what went in", + "current_month": true, + "budget": { "type": "envelope", "balance": "300.00", "budgeted_amount": null, "funding_amount": "300.00" }, + "month": { "spent": "0.00", "received": "0.00", "funded": "300.00" }, + "card": { + "amount": "300.00", + "label": "LEFT", + "percent": null, + "bar": null, + "footer": "$300.00 funded" + } + }, + { + "name": "a goal saving monthly says what it puts away", + "current_month": true, + "budget": { "type": "goal", "balance": "250.00", "budgeted_amount": "1000.00", "funding_amount": "50.00" }, + "month": { "spent": "0.00", "received": "0.00", "funded": "50.00" }, + "card": { + "amount": "750.00", + "label": "TO GO", + "percent": 25.0, + "bar": "goal", + "footer": "$250.00 of $1,000.00 saved \u00b7 $50.00/mo" + } + }, + { + "name": "an envelope that does not roll over is topped back up rather than left short", + "current_month": true, + "budget": { "type": "envelope", "balance": "300.00", "budgeted_amount": "300.00", "funding_amount": "300.00" }, + "month": { "spent": "0.00", "received": "0.00", "funded": "350.00" }, + "card": { + "amount": "300.00", + "label": "LEFT", + "percent": 0.0, + "bar": "under", + "footer": "$350.00 funded \u00b7 $0.00 of $300.00 spent" + } + }, + { + "name": "an envelope with no budgeted amount still reads as overspent", + "current_month": true, + "budget": { "type": "envelope", "balance": "-15.00", "budgeted_amount": null, "funding_amount": null }, + "month": { "spent": "15.00", "received": "0.00", "funded": "0.00" }, + "card": { "amount": "15.00", "label": "OVERSPENT", "percent": null, "bar": null, "footer": null } } ], "currency": [ From ac9fda3303dcdb71301dda5bd5d00b5484551cd1 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 22:30:34 -0600 Subject: [PATCH 2/3] Give an envelope one amount, and call what is left Remaining An envelope had two amounts that were the same number wearing two hats: what its spending was measured against, and what a month put into it. That is what put two clocks on one card - a lifetime balance above a this-month bar, disagreeing. It keeps one. Having it is what makes the envelope fund itself, so the balance is always a figure the month can explain. The migration folds budgeted_amount into funding_amount, so no amount is lost and every existing envelope starts funding. Goals keep two, because a target and a monthly contribution are different numbers; tracking and income keep the figure they are measured against. The envelope card reads REMAINING, or OVERSPENT with the shortfall as a positive figure. The form asks two questions and no more: Budget Per Month, and Remaining. Funding a month by hand is gone with it - the fund_budget MCP tool and the update_funding action. A month is funded one way now, by the amount on the budget. That left Funding.changeset unreachable, so the schema is fields only. Behaviour changes to expect on deploy: - budgeted_amount is null on every envelope, and its value now lives in funding_amount. Clients reading the old field see nothing there. - Envelopes that had an amount but were never funded start funding. Their historical balance is left alone, so one that was spent from before funding existed still reads OVERSPENT until it is corrected. Co-Authored-By: Claude Opus 5 --- lib/spendable/budgets.ex | 1 - lib/spendable/budgets/CONTEXT.md | 6 +- .../actions/calculate_month_summary.ex | 2 +- .../actions/calculate_month_summary_test.exs | 2 +- .../budgets/actions/update_funding.ex | 36 ------ .../budgets/actions/update_funding_test.exs | 71 ----------- lib/spendable/budgets/schemas/budget.ex | 15 ++- lib/spendable/budgets/schemas/funding.ex | 26 ++-- .../controllers/budget_controller_test.exs | 10 +- .../budget_summary_controller_test.exs | 4 +- lib/spendable_web/live/budgets.ex | 90 ++++---------- lib/spendable_web/live/budgets_test.exs | 103 +++------------- lib/spendable_web/mcp/server.ex | 1 - lib/spendable_web/mcp/tools/create_budget.ex | 8 +- .../mcp/tools/create_budget_test.exs | 4 +- lib/spendable_web/mcp/tools/fund_budget.ex | 52 -------- .../mcp/tools/fund_budget_test.exs | 72 ------------ lib/spendable_web/mcp/tools/list_budgets.ex | 3 +- .../mcp/tools/list_budgets_test.exs | 6 +- lib/spendable_web/mcp/tools/update_budget.ex | 8 +- .../mcp/tools/update_budget_test.exs | 4 +- lib/spendable_web/utils/budget_card.ex | 31 ++--- mobile/lib/budgets/budget_card.dart | 52 ++++---- mobile/lib/budgets/budget_form.dart | 45 +++---- mobile/lib/budgets/budgets_screen.dart | 1 - mobile/test/budgets/budget_card_test.dart | 1 - mobile/test/budgets/budgets_screen_test.dart | 16 +-- ...7041037_fold_envelope_amounts_into_one.exs | 27 +++++ shared/budget_cards.json | 111 ++++++------------ 29 files changed, 216 insertions(+), 592 deletions(-) delete mode 100644 lib/spendable/budgets/actions/update_funding.ex delete mode 100644 lib/spendable/budgets/actions/update_funding_test.exs delete mode 100644 lib/spendable_web/mcp/tools/fund_budget.ex delete mode 100644 lib/spendable_web/mcp/tools/fund_budget_test.exs create mode 100644 priv/repo/migrations/20260817041037_fold_envelope_amounts_into_one.exs diff --git a/lib/spendable/budgets.ex b/lib/spendable/budgets.ex index 837be863..2b6cb0ee 100644 --- a/lib/spendable/budgets.ex +++ b/lib/spendable/budgets.ex @@ -10,7 +10,6 @@ defmodule Spendable.Budgets do 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 update_funding(scope, budget, month, amount), to: Actions.UpdateFunding 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 diff --git a/lib/spendable/budgets/CONTEXT.md b/lib/spendable/budgets/CONTEXT.md index 2bcfba78..56b2b261 100644 --- a/lib/spendable/budgets/CONTEXT.md +++ b/lib/spendable/budgets/CONTEXT.md @@ -38,12 +38,12 @@ 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, monthly target +_Avoid_: Contribution, auto-fill, budgeted amount **Funding**: Money put into a **Budget** for one month. @@ -89,7 +89,7 @@ _Avoid_: Row, item, split allocation - An **Allocation** belongs to exactly one **Budget** and one **Transaction** - 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 -- Only an **Envelope** or a **Goal** has a **Funding Amount** +- 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 diff --git a/lib/spendable/budgets/actions/calculate_month_summary.ex b/lib/spendable/budgets/actions/calculate_month_summary.ex index d67b4c62..51b35209 100644 --- a/lib/spendable/budgets/actions/calculate_month_summary.ex +++ b/lib/spendable/budgets/actions/calculate_month_summary.ex @@ -33,7 +33,7 @@ defmodule Spendable.Budgets.Actions.CalculateMonthSummary do 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)) diff --git a/lib/spendable/budgets/actions/calculate_month_summary_test.exs b/lib/spendable/budgets/actions/calculate_month_summary_test.exs index 5fd7cac0..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} = diff --git a/lib/spendable/budgets/actions/update_funding.ex b/lib/spendable/budgets/actions/update_funding.ex deleted file mode 100644 index 4f54cccc..00000000 --- a/lib/spendable/budgets/actions/update_funding.ex +++ /dev/null @@ -1,36 +0,0 @@ -defmodule Spendable.Budgets.Actions.UpdateFunding do - @moduledoc false - - alias Spendable.Budgets.Schemas.Budget - alias Spendable.Budgets.Schemas.Funding - alias Spendable.Repo - alias Spendable.Scope - - @doc """ - Sets what one month put into one budget, whether or not the month funded it already. - - This is how a user deviates for a single month - "only 200 into groceries this time" - without - changing what the budget funds itself with every other month. Setting it to zero is how a month - is skipped, which is not the same as never having been funded: the row records the decision. - """ - def update_funding( - %Scope{user: %{id: user_id}}, - %Budget{user_id: user_id} = budget, - %Date{} = month, - amount - ) do - month = Date.beginning_of_month(month) - - case Repo.get_by(Funding, budget_id: budget.id, month: month, user_id: user_id) do - %Funding{} = funding -> - funding |> Funding.changeset(%{amount: amount}) |> Repo.update() - - nil -> - %Funding{user_id: user_id} - |> Funding.changeset(%{amount: amount, month: month, budget_id: budget.id}) - |> Repo.insert() - end - end - - def update_funding(_scope, _budget, _month, _amount), do: {:error, :not_authorized} -end diff --git a/lib/spendable/budgets/actions/update_funding_test.exs b/lib/spendable/budgets/actions/update_funding_test.exs deleted file mode 100644 index a91f94e7..00000000 --- a/lib/spendable/budgets/actions/update_funding_test.exs +++ /dev/null @@ -1,71 +0,0 @@ -defmodule Spendable.Budgets.Actions.UpdateFundingTest do - use Spendable.DataCase, async: true - - alias Spendable.Accounts - alias Spendable.Budgets - alias Spendable.Budgets.Schemas.Funding - alias Spendable.Scope - - # Behind the current month, so creating a self-funding budget does not fund these as a side - # effect and every figure below is one this test put there. - @month ~D[2020-05-01] - @earlier ~D[2020-04-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 "funds a month that was never funded", %{scope: scope, budget: budget, budget_id: budget_id} do - assert {:ok, %Funding{id: "fnd_" <> _uxid, month: @month}} = - Budgets.update_funding(scope, budget, ~D[2020-05-15], "200.00") - - assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], @month) - assert Decimal.eq?(funded, "200.00") - end - - test "replaces what the month was already funded with", %{scope: scope, budget: budget, budget_id: budget_id} do - {:ok, 1} = Budgets.fund_budgets(scope, @month) - {:ok, _funding} = Budgets.update_funding(scope, budget, @month, "200.00") - - assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], @month) - assert Decimal.eq?(funded, "200.00") - end - - test "leaves every other month alone", %{scope: scope, budget: budget, budget_id: budget_id} do - {:ok, 1} = Budgets.fund_budgets(scope, @earlier) - {:ok, 1} = Budgets.fund_budgets(scope, @month) - {:ok, _funding} = Budgets.update_funding(scope, budget, @month, "0.00") - - assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], @earlier) - assert Decimal.eq?(funded, "300.00") - end - - test "records a skipped month rather than removing it", %{scope: scope, budget: budget} do - {:ok, %Funding{amount: amount}} = Budgets.update_funding(scope, budget, @month, "0.00") - - assert Decimal.eq?(amount, "0.00") - assert {:ok, 0} = Budgets.fund_budgets(scope, @month) - end - - test "refuses a budget belonging to someone else", %{budget: budget} do - {:ok, other_user} = - Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"}) - - assert {:error, :not_authorized} = - Budgets.update_funding(Scope.for_user(other_user), budget, @month, "200.00") - end - - test "errors without an amount", %{scope: scope, budget: budget} do - assert {:error, changeset} = Budgets.update_funding(scope, budget, @month, nil) - - assert %{amount: ["can't be blank"]} = errors_on(changeset) - end -end diff --git a/lib/spendable/budgets/schemas/budget.ex b/lib/spendable/budgets/schemas/budget.ex index 3cc667e1..25eff163 100644 --- a/lib/spendable/budgets/schemas/budget.ex +++ b/lib/spendable/budgets/schemas/budget.ex @@ -42,7 +42,7 @@ defmodule Spendable.Budgets.Schemas.Budget do :balance ]) |> validate_required([:name, :type]) - |> clear_funding_amount() + |> clear_unused_amounts() |> force_rollover() |> put_adjustment() end @@ -51,12 +51,15 @@ defmodule Spendable.Budgets.Schemas.Budget do cast(budget, attrs, [:archived_at]) end - # Only a budget that holds money can fund itself. Tracking and income record a month and keep no - # balance, so a funding amount on either would be money with nowhere to land. - defp clear_funding_amount(changeset) do + # 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 - type when type in [:tracking, :income] -> put_change(changeset, :funding_amount, nil) - _holds_money -> changeset + :envelope -> put_change(changeset, :budgeted_amount, nil) + :goal -> changeset + _holds_nothing -> put_change(changeset, :funding_amount, nil) end end diff --git a/lib/spendable/budgets/schemas/funding.ex b/lib/spendable/budgets/schemas/funding.ex index 373b0347..17566691 100644 --- a/lib/spendable/budgets/schemas/funding.ex +++ b/lib/spendable/budgets/schemas/funding.ex @@ -1,5 +1,11 @@ defmodule Spendable.Budgets.Schemas.Funding do - @moduledoc false + @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 @@ -15,22 +21,4 @@ defmodule Spendable.Budgets.Schemas.Funding do timestamps() end - - def changeset(funding \\ %__MODULE__{}, attrs) do - funding - |> cast(attrs, [:amount, :month, :budget_id]) - |> validate_required([:amount, :month, :budget_id]) - |> put_beginning_of_month() - |> validate_relationships([:budget]) - end - - # A funding belongs to a month, not a day, so any date in that month names the same row. Pinning - # it here rather than in the caller is what makes the unique index on [:budget_id, :month] mean - # "funded once this month". - defp put_beginning_of_month(changeset) do - case fetch_change(changeset, :month) do - {:ok, %Date{} = month} -> put_change(changeset, :month, Date.beginning_of_month(month)) - _no_month -> changeset - end - end end 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 fa8161d1..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} = @@ -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/live/budgets.ex b/lib/spendable_web/live/budgets.ex index ba26fe80..fbbdbe59 100644 --- a/lib/spendable_web/live/budgets.ex +++ b/lib/spendable_web/live/budgets.ex @@ -174,25 +174,19 @@ defmodule SpendableWeb.Live.Budgets do {"Track Spending Only", :tracking} ]} /> - <.input type="text" label={amount_label(f[:type].value)} field={f[:budgeted_amount]} /> - - + <.input :if={f[:type].value == :envelope} type="text" - label="Fund Each Month" + label="Budget Per Month" field={f[:funding_amount]} /> - <.input - :if={f[:type].value == :envelope} - type="checkbox" - label="Carry the balance into next month" - field={f[:rollover]} + :if={f[:type].value != :envelope} + type="text" + label={amount_label(f[:type].value)} + field={f[:budgeted_amount]} /> <.input :if={f[:type].value == :goal} @@ -200,12 +194,22 @@ defmodule SpendableWeb.Live.Budgets do label="Monthly Contribution" field={f[:funding_amount]} /> + <.input :if={f[:type].value in [:envelope, :goal]} type="text" - label="Allocated" + label={if f[:type].value == :envelope, do: "Remaining", else: "Allocated"} field={f[:balance]} /> + + <.input + :if={f[:type].value == :envelope} + type="checkbox" + label="Carry the balance into next month" + field={f[:rollover]} + />
- -
- <.input - type="text" - label="Funded This Month" - name="funding[amount]" - value={@funded_this_month} - /> - -
""" @@ -294,27 +275,7 @@ defmodule SpendableWeb.Live.Budgets do def handle_event("select_budget", params, socket) do budget = Enum.find(socket.assigns.budgets, &(&1.id == params["id"])) - funded = Map.get(socket.assigns.funded, budget.id, Decimal.new("0.00")) - - socket - |> assign(:changeset, Budget.changeset(budget, %{})) - |> assign(:funded_this_month, Decimal.to_string(funded)) - |> noreply() - end - - # Holds what was typed, so the figure does not snap back to what the month already funded. - def handle_event("fund_change", %{"funding" => %{"amount" => amount}}, socket) do - {:noreply, assign(socket, :funded_this_month, amount)} - end - - def handle_event("fund", %{"funding" => %{"amount" => amount}}, socket) do - scope = socket.assigns.current_scope - budget = socket.assigns.changeset.data - - case Budgets.update_funding(scope, budget, socket.assigns.selected_month, amount) do - {:ok, _funding} -> socket |> fetch_data() |> noreply() - {:error, _changeset} -> socket |> assign(:funded_this_month, amount) |> noreply() - end + {:noreply, assign(socket, :changeset, Budget.changeset(budget, %{}))} end def handle_event("archive", _params, socket) do @@ -354,8 +315,6 @@ defmodule SpendableWeb.Live.Budgets do |> assign(:spent_by_month, summary.spent_by_month) |> assign(:selected_month, selected_month) |> assign(:budgets, listed) - |> assign(:funded, summary.funded) - |> assign(:funded_this_month, "0.00") |> assign(:cards, build_cards(listed, summary, summary.current_month)) |> assign(:funded_total, summary.funded_total) |> assign(:earned_total, summary.earned_total) @@ -397,8 +356,7 @@ defmodule SpendableWeb.Live.Budgets do Enum.map(budgets, fn budget -> month = %{ spent: figure(summary.spent, budget.id), - received: figure(summary.received, budget.id), - funded: figure(summary.funded, budget.id) + received: figure(summary.received, budget.id) } credit_cards? = is_nil(budget.id) @@ -421,14 +379,6 @@ defmodule SpendableWeb.Live.Budgets do # The synthetic Credit Cards row has no id, so it is in none of the month's maps. defp figure(figures, budget_id), do: Map.get(figures, budget_id, Decimal.new("0.00")) - # Only a saved budget that holds money can have a month's funding edited, and only the month the - # user is actually looking at. - defp fundable?(%{data: %{id: id, type: type}}, true = _current_month_is_selected) - when is_binary(id), - do: type in [:envelope, :goal] - - defp fundable?(_changeset, _current_month_is_selected), do: false - # An overspend reads as a positive figure, so the label is what says it is bad rather than a # minus sign. defp amount_class(%{label: "OVERSPENT"}), do: "text-red-400" diff --git a/lib/spendable_web/live/budgets_test.exs b/lib/spendable_web/live/budgets_test.exs index 0fcc1085..1af11d21 100644 --- a/lib/spendable_web/live/budgets_test.exs +++ b/lib/spendable_web/live/budgets_test.exs @@ -111,7 +111,6 @@ defmodule SpendableWeb.Live.BudgetsTest do Budgets.create_budget(scope, %{ "name" => "Groceries", "type" => "envelope", - "budgeted_amount" => "650.00", "funding_amount" => "650.00" }) @@ -125,7 +124,7 @@ defmodule SpendableWeb.Live.BudgetsTest do {:ok, _view, html} = live(conn, ~p"/budgets") - assert html =~ "LEFT" + assert html =~ "REMAINING" assert html =~ "$488.12 of $650.00 spent" # The month summary pairs what went into the envelopes against what went out of them. assert html =~ "Funded" @@ -139,7 +138,7 @@ defmodule SpendableWeb.Live.BudgetsTest do Budgets.create_budget(scope, %{ "name" => "Dining out", "type" => "envelope", - "budgeted_amount" => "200.00" + "funding_amount" => "200.00" }) {:ok, _transaction} = @@ -196,7 +195,7 @@ defmodule SpendableWeb.Live.BudgetsTest do Budgets.create_budget(scope, %{ "name" => "Gifts", "type" => "envelope", - "budgeted_amount" => "0.00" + "funding_amount" => "0.00" }) {:ok, _view, html} = live(conn, ~p"/budgets") @@ -363,27 +362,6 @@ defmodule SpendableWeb.Live.BudgetsTest do assert Decimal.eq?(stopped.balance, "300.00") end - test "funds a single month from the drawer", %{conn: conn, scope: scope} do - {:ok, budget} = - Budgets.create_budget(scope, %{ - "name" => "Groceries", - "budgeted_amount" => "300.00", - "funding_amount" => "300.00" - }) - - {:ok, view, _html} = live(conn, ~p"/budgets") - - view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() - - view - |> element(~s(form[phx-submit="fund"])) - |> render_submit(%{funding: %{"amount" => "200.00"}}) - - {:ok, funded} = Budgets.get_budget(scope, id: budget.id) - - assert Decimal.eq?(funded.balance, "200.00") - end - test "reads an income budget as what it took in", %{conn: conn, scope: scope} do {:ok, salary} = Budgets.create_budget(scope, %{ @@ -406,41 +384,6 @@ defmodule SpendableWeb.Live.BudgetsTest do assert html =~ "$4,200.00 of $4,200.00 received" end - test "keeps a typed funding amount while it is being typed", %{conn: conn, scope: scope} do - {:ok, budget} = - Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"}) - - {:ok, view, _html} = live(conn, ~p"/budgets") - - view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() - - html = - view - |> element(~s(form[phx-submit="fund"])) - |> render_change(%{funding: %{"amount" => "12"}}) - - assert html =~ ~s(value="12") - end - - test "keeps the funding form open when the amount will not parse", %{conn: conn, scope: scope} do - {:ok, budget} = - Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"}) - - {:ok, view, _html} = live(conn, ~p"/budgets") - - view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() - - html = - view - |> element(~s(form[phx-submit="fund"])) - |> render_submit(%{funding: %{"amount" => "not money"}}) - - assert html =~ ~s(phx-submit="fund") - - {:ok, unchanged} = Budgets.get_budget(scope, id: budget.id) - assert Decimal.eq?(unchanged.balance, "300.00") - end - test "asks a tracking budget for a limit and an income budget for what it expects", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/budgets") @@ -480,14 +423,14 @@ defmodule SpendableWeb.Live.BudgetsTest do refute html =~ "Fund automatically each month" end - # An envelope holds what it was funded less what it spent, so one that was never funded and has - # been spent from is genuinely in the hole - the money came out of Spendable. + # An envelope holds what it was funded less what it spent: funded 200, spent 264.50, so it is + # 64.50 in the hole and that money came out of Spendable. test "reads an envelope in the hole as overspent", %{conn: conn, scope: scope} do {:ok, budget} = Budgets.create_budget(scope, %{ "name" => "Dining out", "type" => "envelope", - "budgeted_amount" => "200.00" + "funding_amount" => "200.00" }) {:ok, _transaction} = @@ -503,33 +446,23 @@ defmodule SpendableWeb.Live.BudgetsTest do # The shortfall reads as a positive figure, so the label is what says it is bad. The month # picker still reports the month's spending as negative, so this looks at the card itself. assert html =~ "OVERSPENT" - assert html =~ ~r/text-red-400[^>]*">\s*\$264\.50\s*]*">\s*\$64\.50\s* "Groceries"}) + # An envelope asks two questions and no more: what goes in each month, and what it holds now. + test "offers an envelope only its budget and what remains", %{conn: conn, scope: scope} do + {:ok, budget} = + Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"}) {:ok, view, _html} = live(conn, ~p"/budgets") - view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() - - view - |> element(~s(form[phx-submit="submit"])) - |> render_submit(%{ - budget: %{ - "name" => "Groceries", - "type" => "envelope", - "budgeted_amount" => "400.00", - "funding_amount" => "300.00" - } - }) - - {:ok, saved} = Budgets.get_budget(scope, id: budget.id) + html = view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() - assert Decimal.eq?(saved.budgeted_amount, "400.00") - assert Decimal.eq?(saved.funding_amount, "300.00") - assert Decimal.eq?(saved.balance, "300.00") + assert html =~ "Budget Per Month" + assert html =~ "Remaining" + refute html =~ "Budgeted Amount" + refute html =~ "Allocated" + refute html =~ "Fund Each Month" + refute html =~ "Funded This Month" end end diff --git a/lib/spendable_web/mcp/server.ex b/lib/spendable_web/mcp/server.ex index ee0a3594..0e3595a2 100644 --- a/lib/spendable_web/mcp/server.ex +++ b/lib/spendable_web/mcp/server.ex @@ -16,7 +16,6 @@ defmodule SpendableWeb.MCP.Server do component(SpendableWeb.MCP.Tools.ArchiveSplit) component(SpendableWeb.MCP.Tools.CreateBudget) component(SpendableWeb.MCP.Tools.CreateSplit) - component(SpendableWeb.MCP.Tools.FundBudget) component(SpendableWeb.MCP.Tools.ListBudgets) component(SpendableWeb.MCP.Tools.ListSplits) component(SpendableWeb.MCP.Tools.ListTransactions) diff --git a/lib/spendable_web/mcp/tools/create_budget.ex b/lib/spendable_web/mcp/tools/create_budget.ex index ec64672f..45216593 100644 --- a/lib/spendable_web/mcp/tools/create_budget.ex +++ b/lib/spendable_web/mcp/tools/create_budget.ex @@ -23,12 +23,14 @@ defmodule SpendableWeb.MCP.Tools.CreateBudget do field :budgeted_amount, :string, description: - "What the user intends this budget to hold - the figure its balance is read against. It is a " <> - "target on its own; use funding_amount to actually put money in. Decimal string, e.g. \"250.00\"." + "The figure a month is measured against, for a budget that holds nothing - a tracking limit, or " <> + "what an income budget expects to take in. A goal uses it for the target it saves toward. An " <> + "envelope has no budgeted amount; its one figure is funding_amount. Decimal string." field :funding_amount, :string, description: - "What this budget puts into itself at the start of every month, drawn from what is spendable. " <> + "What this budget puts into itself at the start of every month, drawn from what is spendable. For " <> + "an envelope this is its only amount, and it is what its spending is read against too. " <> "Setting it is what makes a budget fill on its own instead of being fed from a paycheck by " <> "hand, and the current month is funded straight away. Only an envelope or a goal can hold " <> "money, so it is ignored on tracking and income. Decimal string, e.g. \"250.00\"." diff --git a/lib/spendable_web/mcp/tools/create_budget_test.exs b/lib/spendable_web/mcp/tools/create_budget_test.exs index bc51e351..8bede6a6 100644 --- a/lib/spendable_web/mcp/tools/create_budget_test.exs +++ b/lib/spendable_web/mcp/tools/create_budget_test.exs @@ -20,9 +20,9 @@ defmodule SpendableWeb.MCP.Tools.CreateBudgetTest do test "creates a budget the user can then list", %{frame: frame, scope: scope} do assert {:reply, %Response{ - structured_content: %{budget: %{name: "Groceries", type: :envelope, budgeted_amount: "250.00"}} + structured_content: %{budget: %{name: "Groceries", type: :envelope, funding_amount: "250.00"}} }, ^frame} = - CreateBudget.execute(%{name: "Groceries", budgeted_amount: "250.00"}, frame) + CreateBudget.execute(%{name: "Groceries", funding_amount: "250.00"}, frame) assert [%{name: "Groceries"}] = Budgets.list_budgets(scope) end diff --git a/lib/spendable_web/mcp/tools/fund_budget.ex b/lib/spendable_web/mcp/tools/fund_budget.ex deleted file mode 100644 index 085b6ed8..00000000 --- a/lib/spendable_web/mcp/tools/fund_budget.ex +++ /dev/null @@ -1,52 +0,0 @@ -defmodule SpendableWeb.MCP.Tools.FundBudget do - @moduledoc """ - Sets what one month puts into one budget, overriding the amount it usually funds itself with. - - Use this to deviate for a single month - "only 200 into groceries this time" - or to skip a - month by funding it with zero. Every other month keeps the budget's usual amount. This moves - money into the budget, which is what makes it different from `update_budget`'s `balance`: that - records an adjustment to correct a figure, this records the month's funding. - """ - use Anubis.Server.Component, type: :tool, annotations: %{readOnlyHint: false} - - import SpendableWeb.Utils.ToolReply - - alias Spendable.Budgets - - schema do - field :budget_id, {:required, :string}, description: "The id of the budget to fund." - - field :amount, {:required, :string}, - description: - "What this month should put into the budget. Zero skips the month, and the skip is recorded " <> - "rather than left blank. Decimal string, e.g. \"200.00\"." - - field :month, :string, - description: "Any date inside the month to fund, as YYYY-MM-DD. Defaults to the current month." - end - - @impl true - def execute(params, frame) do - scope = frame.assigns.current_scope - - with {:ok, month} <- parse_month(params[:month]), - {:ok, budget} <- Budgets.get_budget(scope, id: params.budget_id), - {:ok, funding} <- Budgets.update_funding(scope, budget, month, params.amount), - {:ok, funded} <- Budgets.get_budget(scope, id: budget.id) do - reply(frame, %{ - funding: %{ - budget_id: funded.id, - name: funded.name, - month: Date.to_string(funding.month), - amount: Decimal.to_string(funding.amount), - balance: Decimal.to_string(funded.balance) - } - }) - else - {:error, reason} -> reply_error(frame, reason) - end - end - - defp parse_month(nil), do: {:ok, Date.utc_today()} - defp parse_month(month), do: Date.from_iso8601(month) -end diff --git a/lib/spendable_web/mcp/tools/fund_budget_test.exs b/lib/spendable_web/mcp/tools/fund_budget_test.exs deleted file mode 100644 index 9a59f9f5..00000000 --- a/lib/spendable_web/mcp/tools/fund_budget_test.exs +++ /dev/null @@ -1,72 +0,0 @@ -defmodule SpendableWeb.MCP.Tools.FundBudgetTest do - use Spendable.DataCase, async: true - - alias Anubis.Server.Frame - alias Anubis.Server.Response - alias Spendable.Accounts - alias Spendable.Budgets - alias Spendable.Scope - alias SpendableWeb.MCP.Tools.FundBudget - - 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"}) - - %{frame: Frame.new(%{current_scope: scope}), scope: scope, budget: budget, budget_id: budget_id} - end - - test "puts money into a named month", %{frame: frame, budget: budget} do - assert {:reply, - %Response{ - structured_content: %{ - funding: %{name: "Groceries", month: "2020-05-01", amount: "200.00", balance: "200.00"} - } - }, ^frame} = - FundBudget.execute( - %{budget_id: budget.id, amount: "200.00", month: "2020-05-15"}, - frame - ) - end - - test "funds the current month when none is given", %{ - frame: frame, - scope: scope, - budget: budget, - budget_id: budget_id - } do - assert {:reply, %Response{isError: false}, ^frame} = - FundBudget.execute(%{budget_id: budget_id, amount: "200.00"}, frame) - - month = Date.beginning_of_month(Date.utc_today()) - - assert %{^budget_id => funded} = Budgets.calculate_funded(scope, [budget], month) - assert Decimal.eq?(funded, "200.00") - end - - test "replaces what a month was funded with rather than adding to it", %{ - frame: frame, - budget: budget - } do - {:reply, %Response{isError: false}, ^frame} = - FundBudget.execute(%{budget_id: budget.id, amount: "300.00", month: "2020-05-01"}, frame) - - assert {:reply, %Response{structured_content: %{funding: %{balance: "200.00"}}}, ^frame} = - FundBudget.execute( - %{budget_id: budget.id, amount: "200.00", month: "2020-05-01"}, - frame - ) - end - - test "reports a month it cannot read", %{frame: frame, budget: budget} do - assert {:reply, %Response{isError: true}, ^frame} = - FundBudget.execute(%{budget_id: budget.id, amount: "200.00", month: "May"}, frame) - end - - test "reports a budget that is not the user's", %{frame: frame} do - assert {:reply, %Response{isError: true}, ^frame} = - FundBudget.execute(%{budget_id: "bgt_nope", amount: "200.00"}, frame) - end -end diff --git a/lib/spendable_web/mcp/tools/list_budgets.ex b/lib/spendable_web/mcp/tools/list_budgets.ex index db69f2c2..384313c0 100644 --- a/lib/spendable_web/mcp/tools/list_budgets.ex +++ b/lib/spendable_web/mcp/tools/list_budgets.ex @@ -24,7 +24,8 @@ defmodule SpendableWeb.MCP.Tools.ListBudgets do name: &1.name, type: &1.type, balance: Decimal.to_string(&1.balance), - budgeted_amount: &1.budgeted_amount && Decimal.to_string(&1.budgeted_amount) + budgeted_amount: &1.budgeted_amount && Decimal.to_string(&1.budgeted_amount), + funding_amount: &1.funding_amount && Decimal.to_string(&1.funding_amount) } ) diff --git a/lib/spendable_web/mcp/tools/list_budgets_test.exs b/lib/spendable_web/mcp/tools/list_budgets_test.exs index f1ffe733..1144116d 100644 --- a/lib/spendable_web/mcp/tools/list_budgets_test.exs +++ b/lib/spendable_web/mcp/tools/list_budgets_test.exs @@ -18,13 +18,13 @@ defmodule SpendableWeb.MCP.Tools.ListBudgetsTest do end test "lists the budgets of the user the frame acts as", %{frame: frame, scope: scope} do - {:ok, _budget} = Budgets.create_budget(scope, %{"name" => "Groceries", "budgeted_amount" => "250.00"}) + {:ok, _budget} = Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "250.00"}) assert {:reply, %Response{ structured_content: %{ budgets: [ - %{name: "Groceries", type: :envelope, balance: "0.00", budgeted_amount: "250.00"} + %{name: "Groceries", type: :envelope, balance: "250.00", funding_amount: "250.00"} ] } }, ^frame} = ListBudgets.execute(%{}, frame) @@ -34,7 +34,7 @@ defmodule SpendableWeb.MCP.Tools.ListBudgetsTest do {:ok, _groceries} = Budgets.create_budget(scope, %{"name" => "Groceries"}) {:ok, _rent} = Budgets.create_budget(scope, %{"name" => "Rent"}) - assert {:reply, %Response{structured_content: %{budgets: [%{name: "Rent", budgeted_amount: nil}]}}, ^frame} = + assert {:reply, %Response{structured_content: %{budgets: [%{name: "Rent", funding_amount: nil}]}}, ^frame} = ListBudgets.execute(%{search: "Ren"}, frame) end diff --git a/lib/spendable_web/mcp/tools/update_budget.ex b/lib/spendable_web/mcp/tools/update_budget.ex index 0d2838ac..c60da3c1 100644 --- a/lib/spendable_web/mcp/tools/update_budget.ex +++ b/lib/spendable_web/mcp/tools/update_budget.ex @@ -23,12 +23,14 @@ defmodule SpendableWeb.MCP.Tools.UpdateBudget do field :budgeted_amount, :string, description: - "What the user intends this budget to hold - the figure its balance is read against. It is a " <> - "target on its own; use funding_amount to actually put money in. Decimal string, e.g. \"250.00\"." + "The figure a month is measured against, for a budget that holds nothing - a tracking limit, or " <> + "what an income budget expects to take in. A goal uses it for the target it saves toward. An " <> + "envelope has no budgeted amount; its one figure is funding_amount. Decimal string." field :funding_amount, :string, description: - "What this budget puts into itself at the start of every month, drawn from what is spendable. " <> + "What this budget puts into itself at the start of every month, drawn from what is spendable. For " <> + "an envelope this is its only amount, and it is what its spending is read against too. " <> "Setting it makes the budget fill on its own rather than being fed from a paycheck by hand, " <> "and funds the current month straight away. Only an envelope or a goal can hold money, so it " <> "is ignored on tracking and income. Decimal string, e.g. \"250.00\"." diff --git a/lib/spendable_web/mcp/tools/update_budget_test.exs b/lib/spendable_web/mcp/tools/update_budget_test.exs index bb618df2..62fa3fac 100644 --- a/lib/spendable_web/mcp/tools/update_budget_test.exs +++ b/lib/spendable_web/mcp/tools/update_budget_test.exs @@ -20,9 +20,9 @@ defmodule SpendableWeb.MCP.Tools.UpdateBudgetTest do test "changes only the fields it is given", %{budget: budget, frame: frame} do assert {:reply, - %Response{structured_content: %{budget: %{name: "Food", type: :envelope, budgeted_amount: "300.00"}}}, + %Response{structured_content: %{budget: %{name: "Food", type: :envelope, funding_amount: "300.00"}}}, ^frame} = - UpdateBudget.execute(%{budget_id: budget.id, name: "Food", budgeted_amount: "300.00"}, frame) + UpdateBudget.execute(%{budget_id: budget.id, name: "Food", funding_amount: "300.00"}, frame) end test "sets the balance the user asks for", %{budget: budget, frame: frame, scope: scope} do diff --git a/lib/spendable_web/utils/budget_card.ex b/lib/spendable_web/utils/budget_card.ex index 79e14efe..8b192e5f 100644 --- a/lib/spendable_web/utils/budget_card.ex +++ b/lib/spendable_web/utils/budget_card.ex @@ -7,9 +7,8 @@ defmodule SpendableWeb.Utils.BudgetCard do same six answers, and `shared/budget_cards.json` drives a table test on both sides. Colours stay with each client - `bar` says which of the four bars this is, not what it looks like. - The month is passed as one map of what moved - `spent`, `received` and `funded` - because which - of them a card reads depends on what kind of budget it is, and a budget that spends never - receives. + The month is passed as one map of what moved - `spent` and `received` - because which of them a + card reads depends on what kind of budget it is, and a budget that spends never receives. """ import Spendable.Utils @@ -55,18 +54,18 @@ defmodule SpendableWeb.Utils.BudgetCard do } end - def build_budget_card(%Budget{type: :envelope, budgeted_amount: nil} = budget, %{funded: funded}, _current) do - %{held(budget) | percent: nil, bar: nil, footer: funded_footer(funded)} + def build_budget_card(%Budget{type: :envelope, funding_amount: nil} = budget, _month, _current) do + held(budget) end - def build_budget_card(%Budget{type: :envelope} = budget, %{spent: spent, funded: funded}, _current) do - spend_line = "#{format_currency(spent)} of #{format_currency(budget.budgeted_amount)} spent" - + # An envelope has one amount: what a month puts in, which is also what its spending is read + # against. There is nothing to say about funding separately because they are the same figure. + def build_budget_card(%Budget{type: :envelope} = budget, %{spent: spent}, _current) do %{ held(budget) - | percent: percent(spent, budget.budgeted_amount), - bar: spending_bar(spent, budget.budgeted_amount), - footer: prefix_funded(spend_line, funded) + | percent: percent(spent, budget.funding_amount), + bar: spending_bar(spent, budget.funding_amount), + footer: "#{format_currency(spent)} of #{format_currency(budget.funding_amount)} spent" } end @@ -94,7 +93,7 @@ defmodule SpendableWeb.Utils.BudgetCard do if Decimal.negative?(balance) do %{amount: Decimal.abs(balance), label: "OVERSPENT", percent: nil, bar: nil, footer: nil} else - %{amount: budget.balance, label: "LEFT", percent: nil, bar: nil, footer: nil} + %{amount: budget.balance, label: "REMAINING", percent: nil, bar: nil, footer: nil} end end @@ -102,14 +101,6 @@ defmodule SpendableWeb.Utils.BudgetCard do if Decimal.compare(spent, budgeted_amount) == :gt, do: "over", else: "under" end - # What the month put in is only worth saying when a month put something in, so a budget the user - # fills by hand reads exactly as it did before funding existed. - defp funded_footer(%Decimal{coef: 0}), do: nil - defp funded_footer(funded), do: "#{format_currency(funded)} funded" - - defp prefix_funded(line, %Decimal{coef: 0}), do: line - defp prefix_funded(line, funded), do: "#{format_currency(funded)} funded · #{line}" - defp suffix_monthly(line, nil), do: line defp suffix_monthly(line, funding_amount), do: "#{line} · #{format_currency(funding_amount)}/mo" diff --git a/mobile/lib/budgets/budget_card.dart b/mobile/lib/budgets/budget_card.dart index f83a140c..f596dcfc 100644 --- a/mobile/lib/budgets/budget_card.dart +++ b/mobile/lib/budgets/budget_card.dart @@ -9,11 +9,10 @@ enum CardBar { under, over, goal, income } /// What one month moved through a budget. Which figure a card reads depends on what kind of /// budget it is, and a budget that spends never receives. class BudgetMonth { - const BudgetMonth({required this.spent, required this.received, required this.funded}); + const BudgetMonth({required this.spent, required this.received}); final Decimal spent; final Decimal received; - final Decimal funded; } /// What a budget reads as, mirroring `SpendableWeb.Utils.BudgetCard`. Both are driven by @@ -68,37 +67,37 @@ class BudgetCard { final goal = budget.type == BudgetTypeEnum.goal; final balance = money(budget.balance); final fundsItself = budget.fundingAmount == null ? null : money(budget.fundingAmount!); - final funded = month.funded; - - if (budgeted == null) { - if (goal) return BudgetCard(amount: balance, label: 'SAVED', footer: 'No goal set'); + // An envelope has one amount: what a month puts in, which is also what its spending is read + // against. There is nothing to say about funding separately because they are the same figure. + if (!goal) { final held = _held(balance); - return BudgetCard(amount: held.amount, label: held.label, footer: _fundedFooter(funded)); - } - - if (goal) { - final saved = '${formatCurrency(balance)} of ${formatCurrency(budgeted)} saved'; + if (fundsItself == null) return BudgetCard(amount: held.amount, label: held.label); return BudgetCard( - amount: budgeted - balance, - label: 'TO GO', - percent: _percent(balance, budgeted), - bar: CardBar.goal, - footer: fundsItself == null ? saved : '$saved · ${formatCurrency(fundsItself)}/mo', + amount: held.amount, + label: held.label, + percent: _percent(spent, fundsItself), + bar: spent > fundsItself ? CardBar.over : CardBar.under, + footer: '${formatCurrency(spent)} of ${formatCurrency(fundsItself)} spent', ); } - final spend = '${formatCurrency(spent)} of ${formatCurrency(budgeted)} spent'; - final held = _held(balance); + // A goal is the one kind with two amounts: the target it is saving toward, and the smaller + // figure it puts away each month. + if (budgeted == null) { + return BudgetCard(amount: balance, label: 'SAVED', footer: 'No goal set'); + } + + final saved = '${formatCurrency(balance)} of ${formatCurrency(budgeted)} saved'; return BudgetCard( - amount: held.amount, - label: held.label, - percent: _percent(spent, budgeted), - bar: spent > budgeted ? CardBar.over : CardBar.under, - footer: funded == Decimal.zero ? spend : '${formatCurrency(funded)} funded · $spend', + amount: budgeted - balance, + label: 'TO GO', + percent: _percent(balance, budgeted), + bar: CardBar.goal, + footer: fundsItself == null ? saved : '$saved · ${formatCurrency(fundsItself)}/mo', ); } @@ -113,12 +112,7 @@ class BudgetCard { /// colours the label, since the figure no longer carries a minus sign to key off. static ({Decimal amount, String label}) _held(Decimal balance) => balance < Decimal.zero ? (amount: -balance, label: 'OVERSPENT') - : (amount: balance, label: 'LEFT'); - - /// What the month put in is only worth saying when a month put something in, so a budget the - /// user fills by hand reads exactly as it did before funding existed. - static String? _fundedFooter(Decimal funded) => - funded == Decimal.zero ? null : '${formatCurrency(funded)} funded'; + : (amount: balance, label: 'REMAINING'); static double _percent(Decimal part, Decimal whole) { if (whole == Decimal.zero) return 0; diff --git a/mobile/lib/budgets/budget_form.dart b/mobile/lib/budgets/budget_form.dart index cda1c178..914e1875 100644 --- a/mobile/lib/budgets/budget_form.dart +++ b/mobile/lib/budgets/budget_form.dart @@ -101,31 +101,31 @@ class _BudgetFormState extends ConsumerState { onValueChanged: (value) => setState(() => _type = value ?? _type), ), const SizedBox(height: SpendableSpace.tight), - TextField( - key: const Key('budget-amount'), - controller: _budgetedAmount, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - style: SpendableType.moneyInline.copyWith(color: colors.primary), - decoration: InputDecoration( - labelText: _amountLabel, - errorText: errors['/budgeted_amount'], - ), - ), - if (_type == BudgetRequestTypeEnum.envelope) ...[ - const SizedBox(height: SpendableSpace.gutter), - // Its own amount rather than a switch tied to the budgeted one: a user can measure - // spending against 400 while only being able to put 300 in. Blank means it does not - // fund itself and the user fills it. + // An envelope has one amount: what a month puts in, which is also what its spending is + // read against. Two numbers for one idea is what confused the card. + if (_type == BudgetRequestTypeEnum.envelope) TextField( key: const Key('budget-funding'), controller: _fundingAmount, keyboardType: const TextInputType.numberWithOptions(decimal: true), style: SpendableType.moneyInline.copyWith(color: colors.primary), decoration: InputDecoration( - labelText: 'Fund each month', + labelText: 'Budget per month', errorText: errors['/funding_amount'], ), + ) + else + TextField( + key: const Key('budget-amount'), + controller: _budgetedAmount, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: SpendableType.moneyInline.copyWith(color: colors.primary), + decoration: InputDecoration( + labelText: _amountLabel, + errorText: errors['/budgeted_amount'], + ), ), + if (_type == BudgetRequestTypeEnum.envelope) ...[ const SizedBox(height: SpendableSpace.tight), Row( children: [ @@ -163,7 +163,12 @@ class _BudgetFormState extends ConsumerState { controller: _balance, keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), style: SpendableType.moneyInline.copyWith(color: colors.primary), - decoration: InputDecoration(labelText: 'Allocated', errorText: errors['/balance']), + // Named for what the card calls it, so the figure the user reads and the figure + // they correct are plainly the same one. + decoration: InputDecoration( + labelText: _type == BudgetRequestTypeEnum.envelope ? 'Remaining' : 'Allocated', + errorText: errors['/balance'], + ), ), ], const SizedBox(height: SpendableSpace.block), @@ -194,7 +199,8 @@ class _BudgetFormState extends ConsumerState { (builder) => builder ..name = _name.text ..type = _type - ..budgetedAmount = _blankToNull(_budgetedAmount.text) + ..budgetedAmount = + _type == BudgetRequestTypeEnum.envelope ? null : _blankToNull(_budgetedAmount.text) ..fundingAmount = _fundsItself() ..rollover = _type == BudgetRequestTypeEnum.envelope ? _rollover : true ..balance = holdsMoney ? _blankToNull(_balance.text) : null, @@ -215,10 +221,9 @@ class _BudgetFormState extends ConsumerState { String? _blankToNull(String value) => value.trim().isEmpty ? null : value.trim(); String get _amountLabel => switch (_type) { - BudgetRequestTypeEnum.goal => 'Goal amount', BudgetRequestTypeEnum.income => 'Expected each month', BudgetRequestTypeEnum.tracking => 'Monthly limit', - _ => 'Budgeted amount', + _ => 'Goal amount', }; /// Only a budget that holds money can fund itself. Blank means it does not, and the user fills diff --git a/mobile/lib/budgets/budgets_screen.dart b/mobile/lib/budgets/budgets_screen.dart index 440a9fd9..d200b476 100644 --- a/mobile/lib/budgets/budgets_screen.dart +++ b/mobile/lib/budgets/budgets_screen.dart @@ -245,7 +245,6 @@ class _Row extends StatelessWidget { month: BudgetMonth( spent: money(summary.spent[budget.id] ?? '0'), received: money(summary.received[budget.id] ?? '0'), - funded: money(summary.funded[budget.id] ?? '0'), ), currentMonth: summary.currentMonth, ); diff --git a/mobile/test/budgets/budget_card_test.dart b/mobile/test/budgets/budget_card_test.dart index 0a3965fa..67c768b9 100644 --- a/mobile/test/budgets/budget_card_test.dart +++ b/mobile/test/budgets/budget_card_test.dart @@ -43,7 +43,6 @@ void main() { month: BudgetMonth( spent: money(figures['spent'] as String), received: money(figures['received'] as String), - funded: money(figures['funded'] as String), ), currentMonth: fixture['current_month'] as bool, ); diff --git a/mobile/test/budgets/budgets_screen_test.dart b/mobile/test/budgets/budgets_screen_test.dart index f8237eb7..4c539ef1 100644 --- a/mobile/test/budgets/budgets_screen_test.dart +++ b/mobile/test/budgets/budgets_screen_test.dart @@ -13,6 +13,7 @@ Map _budget( String type = 'envelope', String balance = '0.00', String? budgetedAmount, + String? fundingAmount, bool rollover = true, }) => { 'id': id, @@ -20,6 +21,7 @@ Map _budget( 'type': type, 'balance': balance, 'budgeted_amount': budgetedAmount, + 'funding_amount': fundingAmount, 'rollover': rollover, 'archived_at': null, }; @@ -44,7 +46,7 @@ Map _summary({ budgets ?? [ _budget('bgt_spendable', 'Spendable'), - _budget('bgt_food', 'Food', balance: '50.00', budgetedAmount: '200.00'), + _budget('bgt_food', 'Food', balance: '50.00', fundingAmount: '200.00'), ], 'spent': spent ?? {'bgt_spendable': '0.00', 'bgt_food': '150.00'}, 'funded': funded ?? {'bgt_spendable': '0.00', 'bgt_food': '0.00'}, @@ -84,7 +86,7 @@ void main() { await _pump(tester); expect(find.text('Food'), findsOneWidget); - expect(find.text('LEFT'), findsWidgets); + expect(find.text('REMAINING'), findsWidgets); expect(find.text(r'$150.00 of $200.00 spent'), findsOneWidget); }); @@ -132,7 +134,7 @@ void main() { 'GET /api/budgets/summary': (status: 200, body: _summary()), 'PATCH /api/budgets/bgt_food': ( status: 200, - body: _budget('bgt_food', 'Groceries', balance: '50.00', budgetedAmount: '200.00'), + body: _budget('bgt_food', 'Groceries', balance: '50.00', fundingAmount: '200.00'), ), }, ); @@ -275,7 +277,7 @@ void main() { 'GET /api/budgets/summary': (status: 200, body: _summary()), 'PATCH /api/budgets/bgt_food': ( status: 200, - body: _budget('bgt_food', 'Food', balance: '200.00', budgetedAmount: '200.00'), + body: _budget('bgt_food', 'Food', balance: '200.00', fundingAmount: '200.00'), ), }, ); @@ -289,9 +291,9 @@ void main() { final sent = api.requests.firstWhere((request) => request.method == 'PATCH').data as Map; - // The two amounts are separate questions: measure against 200, put 150 in. - expect(sent['budgeted_amount'], '200.00'); + // One amount for an envelope, and it is the one a month puts in. expect(sent['funding_amount'], '150.00'); + expect(sent['budgeted_amount'], isNull); }); testWidgets('an income budget is asked what it expects, not what it allocates', (tester) async { @@ -328,7 +330,7 @@ void main() { 'GET /api/budgets/summary': (status: 200, body: _summary()), 'PATCH /api/budgets/bgt_food': ( status: 200, - body: _budget('bgt_food', 'Food', balance: '50.00', budgetedAmount: '200.00'), + body: _budget('bgt_food', 'Food', balance: '50.00', fundingAmount: '200.00'), ), }, ); diff --git a/priv/repo/migrations/20260817041037_fold_envelope_amounts_into_one.exs b/priv/repo/migrations/20260817041037_fold_envelope_amounts_into_one.exs new file mode 100644 index 00000000..7f021d2e --- /dev/null +++ b/priv/repo/migrations/20260817041037_fold_envelope_amounts_into_one.exs @@ -0,0 +1,27 @@ +defmodule Spendable.Repo.Migrations.FoldEnvelopeAmountsIntoOne do + use Ecto.Migration + + @moduledoc """ + An envelope had two amounts that were the same number wearing two hats: what its spending was + measured against, and what a month put into it. It keeps one, and having one is what makes it + fund itself. + """ + + def up do + execute """ + UPDATE budgets + SET funding_amount = budgeted_amount + WHERE type = 'envelope' AND funding_amount IS NULL AND budgeted_amount IS NOT NULL + """ + + execute "UPDATE budgets SET budgeted_amount = NULL WHERE type = 'envelope'" + end + + def down do + execute """ + UPDATE budgets + SET budgeted_amount = funding_amount, funding_amount = NULL + WHERE type = 'envelope' + """ + end +end diff --git a/shared/budget_cards.json b/shared/budget_cards.json index 7b4df27d..69b15884 100644 --- a/shared/budget_cards.json +++ b/shared/budget_cards.json @@ -8,32 +8,32 @@ { "name": "a past month is a record of what was spent", "current_month": false, - "budget": { "type": "envelope", "balance": "50.00", "budgeted_amount": "200.00", "funding_amount": null }, - "month": { "spent": "45.50", "received": "0.00", "funded": "0.00" }, + "budget": { "type": "envelope", "balance": "50.00", "budgeted_amount": null, "funding_amount": "200.00" }, + "month": { "spent": "45.50", "received": "0.00" }, "card": { "amount": "45.50", "label": "SPENT", "percent": null, "bar": null, "footer": null } }, { "name": "a tracking budget only ever reports the spend", "current_month": true, "budget": { "type": "tracking", "balance": "0.00", "budgeted_amount": null, "funding_amount": null }, - "month": { "spent": "12.34", "received": "0.00", "funded": "0.00" }, + "month": { "spent": "12.34", "received": "0.00" }, "card": { "amount": "12.34", "label": "SPENT", "percent": null, "bar": null, "footer": null } }, { "name": "an envelope with no amount budgeted has nothing to be a fraction of", "current_month": true, "budget": { "type": "envelope", "balance": "80.00", "budgeted_amount": null, "funding_amount": null }, - "month": { "spent": "10.00", "received": "0.00", "funded": "0.00" }, - "card": { "amount": "80.00", "label": "LEFT", "percent": null, "bar": null, "footer": null } + "month": { "spent": "10.00", "received": "0.00" }, + "card": { "amount": "80.00", "label": "REMAINING", "percent": null, "bar": null, "footer": null } }, { "name": "an envelope under budget", "current_month": true, - "budget": { "type": "envelope", "balance": "50.00", "budgeted_amount": "200.00", "funding_amount": null }, - "month": { "spent": "150.00", "received": "0.00", "funded": "0.00" }, + "budget": { "type": "envelope", "balance": "50.00", "budgeted_amount": null, "funding_amount": "200.00" }, + "month": { "spent": "150.00", "received": "0.00" }, "card": { "amount": "50.00", - "label": "LEFT", + "label": "REMAINING", "percent": 75.0, "bar": "under", "footer": "$150.00 of $200.00 spent" @@ -42,11 +42,11 @@ { "name": "spending exactly the budget is not over it", "current_month": true, - "budget": { "type": "envelope", "balance": "0.00", "budgeted_amount": "200.00", "funding_amount": null }, - "month": { "spent": "200.00", "received": "0.00", "funded": "0.00" }, + "budget": { "type": "envelope", "balance": "0.00", "budgeted_amount": null, "funding_amount": "200.00" }, + "month": { "spent": "200.00", "received": "0.00" }, "card": { "amount": "0.00", - "label": "LEFT", + "label": "REMAINING", "percent": 100.0, "bar": "under", "footer": "$200.00 of $200.00 spent" @@ -55,8 +55,8 @@ { "name": "an envelope over budget keeps the bar full", "current_month": true, - "budget": { "type": "envelope", "balance": "-25.00", "budgeted_amount": "200.00", "funding_amount": null }, - "month": { "spent": "225.00", "received": "0.00", "funded": "0.00" }, + "budget": { "type": "envelope", "balance": "-25.00", "budgeted_amount": null, "funding_amount": "200.00" }, + "month": { "spent": "225.00", "received": "0.00" }, "card": { "amount": "25.00", "label": "OVERSPENT", @@ -68,11 +68,11 @@ { "name": "a budget of zero divides by nothing and is over the moment anything is spent", "current_month": true, - "budget": { "type": "envelope", "balance": "10.00", "budgeted_amount": "0.00", "funding_amount": null }, - "month": { "spent": "5.00", "received": "0.00", "funded": "0.00" }, + "budget": { "type": "envelope", "balance": "10.00", "budgeted_amount": null, "funding_amount": "0.00" }, + "month": { "spent": "5.00", "received": "0.00" }, "card": { "amount": "10.00", - "label": "LEFT", + "label": "REMAINING", "percent": 0.0, "bar": "over", "footer": "$5.00 of $0.00 spent" @@ -81,11 +81,11 @@ { "name": "a percentage that does not divide evenly rounds to one place", "current_month": true, - "budget": { "type": "envelope", "balance": "100.00", "budgeted_amount": "300.00", "funding_amount": null }, - "month": { "spent": "200.00", "received": "0.00", "funded": "0.00" }, + "budget": { "type": "envelope", "balance": "100.00", "budgeted_amount": null, "funding_amount": "300.00" }, + "month": { "spent": "200.00", "received": "0.00" }, "card": { "amount": "100.00", - "label": "LEFT", + "label": "REMAINING", "percent": 66.7, "bar": "under", "footer": "$200.00 of $300.00 spent" @@ -94,8 +94,8 @@ { "name": "thousands are grouped on both sides of the footer", "current_month": true, - "budget": { "type": "envelope", "balance": "-1500.75", "budgeted_amount": "1000.00", "funding_amount": null }, - "month": { "spent": "2500.75", "received": "0.00", "funded": "0.00" }, + "budget": { "type": "envelope", "balance": "-1500.75", "budgeted_amount": null, "funding_amount": "1000.00" }, + "month": { "spent": "2500.75", "received": "0.00" }, "card": { "amount": "1500.75", "label": "OVERSPENT", @@ -108,14 +108,14 @@ "name": "a goal with no target has nothing to go", "current_month": true, "budget": { "type": "goal", "balance": "500.00", "budgeted_amount": null, "funding_amount": null }, - "month": { "spent": "0.00", "received": "0.00", "funded": "0.00" }, + "month": { "spent": "0.00", "received": "0.00" }, "card": { "amount": "500.00", "label": "SAVED", "percent": null, "bar": null, "footer": "No goal set" } }, { "name": "a goal counts down to the target rather than up from zero", "current_month": true, "budget": { "type": "goal", "balance": "250.00", "budgeted_amount": "1000.00", "funding_amount": null }, - "month": { "spent": "0.00", "received": "0.00", "funded": "0.00" }, + "month": { "spent": "0.00", "received": "0.00" }, "card": { "amount": "750.00", "label": "TO GO", @@ -128,7 +128,7 @@ "name": "saving past the goal goes negative and the bar stops at full", "current_month": true, "budget": { "type": "goal", "balance": "1200.00", "budgeted_amount": "1000.00", "funding_amount": null }, - "month": { "spent": "0.00", "received": "0.00", "funded": "0.00" }, + "month": { "spent": "0.00", "received": "0.00" }, "card": { "amount": "-200.00", "label": "TO GO", @@ -141,7 +141,7 @@ "name": "a tracking budget with a monthly amount reads like an envelope's bar", "current_month": true, "budget": { "type": "tracking", "balance": "0.00", "budgeted_amount": "200.00", "funding_amount": null }, - "month": { "spent": "150.00", "received": "0.00", "funded": "0.00" }, + "month": { "spent": "150.00", "received": "0.00" }, "card": { "amount": "150.00", "label": "SPENT", @@ -154,7 +154,7 @@ "name": "a tracking budget over its monthly amount", "current_month": true, "budget": { "type": "tracking", "balance": "0.00", "budgeted_amount": "200.00", "funding_amount": null }, - "month": { "spent": "225.00", "received": "0.00", "funded": "0.00" }, + "month": { "spent": "225.00", "received": "0.00" }, "card": { "amount": "225.00", "label": "SPENT", @@ -167,14 +167,14 @@ "name": "an income budget reads as what it took in", "current_month": true, "budget": { "type": "income", "balance": "0.00", "budgeted_amount": null, "funding_amount": null }, - "month": { "spent": "0.00", "received": "4200.00", "funded": "0.00" }, + "month": { "spent": "0.00", "received": "4200.00" }, "card": { "amount": "4200.00", "label": "EARNED", "percent": null, "bar": null, "footer": null } }, { "name": "income short of what the month expected", "current_month": true, "budget": { "type": "income", "balance": "0.00", "budgeted_amount": "4200.00", "funding_amount": null }, - "month": { "spent": "0.00", "received": "3150.00", "funded": "0.00" }, + "month": { "spent": "0.00", "received": "3150.00" }, "card": { "amount": "3150.00", "label": "EARNED", @@ -187,7 +187,7 @@ "name": "earning past what the month expected stops the bar at full", "current_month": true, "budget": { "type": "income", "balance": "0.00", "budgeted_amount": "4200.00", "funding_amount": null }, - "month": { "spent": "0.00", "received": "5000.00", "funded": "0.00" }, + "month": { "spent": "0.00", "received": "5000.00" }, "card": { "amount": "5000.00", "label": "EARNED", @@ -200,53 +200,27 @@ "name": "a past month reports what an income budget took in, not what it spent", "current_month": false, "budget": { "type": "income", "balance": "0.00", "budgeted_amount": "4200.00", "funding_amount": null }, - "month": { "spent": "0.00", "received": "4200.00", "funded": "0.00" }, + "month": { "spent": "0.00", "received": "4200.00" }, "card": { "amount": "4200.00", "label": "EARNED", "percent": null, "bar": null, "footer": null } }, { "name": "an envelope says what the month funded it with", "current_month": true, - "budget": { "type": "envelope", "balance": "160.00", "budgeted_amount": "300.00", "funding_amount": "300.00" }, - "month": { "spent": "140.00", "received": "0.00", "funded": "300.00" }, + "budget": { "type": "envelope", "balance": "160.00", "budgeted_amount": null, "funding_amount": "300.00" }, + "month": { "spent": "140.00", "received": "0.00" }, "card": { "amount": "160.00", - "label": "LEFT", + "label": "REMAINING", "percent": 46.7, "bar": "under", - "footer": "$300.00 funded \u00b7 $140.00 of $300.00 spent" - } - }, - { - "name": "a month funded below the usual amount says so", - "current_month": true, - "budget": { "type": "envelope", "balance": "60.00", "budgeted_amount": "300.00", "funding_amount": "300.00" }, - "month": { "spent": "140.00", "received": "0.00", "funded": "200.00" }, - "card": { - "amount": "60.00", - "label": "LEFT", - "percent": 46.7, - "bar": "under", - "footer": "$200.00 funded \u00b7 $140.00 of $300.00 spent" - } - }, - { - "name": "an envelope with no amount budgeted still says what went in", - "current_month": true, - "budget": { "type": "envelope", "balance": "300.00", "budgeted_amount": null, "funding_amount": "300.00" }, - "month": { "spent": "0.00", "received": "0.00", "funded": "300.00" }, - "card": { - "amount": "300.00", - "label": "LEFT", - "percent": null, - "bar": null, - "footer": "$300.00 funded" + "footer": "$140.00 of $300.00 spent" } }, { "name": "a goal saving monthly says what it puts away", "current_month": true, "budget": { "type": "goal", "balance": "250.00", "budgeted_amount": "1000.00", "funding_amount": "50.00" }, - "month": { "spent": "0.00", "received": "0.00", "funded": "50.00" }, + "month": { "spent": "0.00", "received": "0.00" }, "card": { "amount": "750.00", "label": "TO GO", @@ -255,24 +229,11 @@ "footer": "$250.00 of $1,000.00 saved \u00b7 $50.00/mo" } }, - { - "name": "an envelope that does not roll over is topped back up rather than left short", - "current_month": true, - "budget": { "type": "envelope", "balance": "300.00", "budgeted_amount": "300.00", "funding_amount": "300.00" }, - "month": { "spent": "0.00", "received": "0.00", "funded": "350.00" }, - "card": { - "amount": "300.00", - "label": "LEFT", - "percent": 0.0, - "bar": "under", - "footer": "$350.00 funded \u00b7 $0.00 of $300.00 spent" - } - }, { "name": "an envelope with no budgeted amount still reads as overspent", "current_month": true, "budget": { "type": "envelope", "balance": "-15.00", "budgeted_amount": null, "funding_amount": null }, - "month": { "spent": "15.00", "received": "0.00", "funded": "0.00" }, + "month": { "spent": "15.00", "received": "0.00" }, "card": { "amount": "15.00", "label": "OVERSPENT", "percent": null, "bar": null, "footer": null } } ], From 100382bde37c8fec3999f49951ada84d259a4811 Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 22:42:50 -0600 Subject: [PATCH 3/3] fix form --- lib/spendable_web/live/budgets.ex | 23 +++++++++++++-------- lib/spendable_web/live/budgets_test.exs | 27 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/lib/spendable_web/live/budgets.ex b/lib/spendable_web/live/budgets.ex index fbbdbe59..4b0ce229 100644 --- a/lib/spendable_web/live/budgets.ex +++ b/lib/spendable_web/live/budgets.ex @@ -177,19 +177,19 @@ defmodule SpendableWeb.Live.Budgets do <.input - :if={f[:type].value == :envelope} + :if={budget_type(f) == :envelope} type="text" label="Budget Per Month" field={f[:funding_amount]} /> <.input - :if={f[:type].value != :envelope} + :if={budget_type(f) != :envelope} type="text" - label={amount_label(f[:type].value)} + label={amount_label(budget_type(f))} field={f[:budgeted_amount]} /> <.input - :if={f[:type].value == :goal} + :if={budget_type(f) == :goal} type="text" label="Monthly Contribution" field={f[:funding_amount]} @@ -197,15 +197,15 @@ defmodule SpendableWeb.Live.Budgets do <.input - :if={f[:type].value in [:envelope, :goal]} + :if={budget_type(f) in [:envelope, :goal]} type="text" - label={if f[:type].value == :envelope, do: "Remaining", else: "Allocated"} + label={if budget_type(f) == :envelope, do: "Remaining", else: "Allocated"} field={f[:balance]} /> <.input - :if={f[:type].value == :envelope} + :if={budget_type(f) == :envelope} type="checkbox" label="Carry the balance into next month" field={f[:rollover]} @@ -387,10 +387,17 @@ defmodule SpendableWeb.Live.Budgets do if Decimal.negative?(amount), do: "text-red-400", else: "text-white" end + # The select posts a string, and Ecto records no change when it matches what is stored, so the + # form falls back to that raw string rather than the cast atom. Comparing the two hid every field. + defp budget_type(form) do + Ecto.Enum.values(Budget, :type) + |> Enum.find(:envelope, &(to_string(&1) == to_string(form[:type].value))) + end + + # Only reached by the types that keep a budgeted amount; an envelope has none to label. defp amount_label(:goal), do: "Goal Amount" defp amount_label(:income), do: "Expected Each Month" defp amount_label(:tracking), do: "Monthly Limit" - defp amount_label(_envelope), do: "Budgeted Amount" defp bar_class("over"), do: "bg-red-500" defp bar_class("under"), do: "bg-blue-500" diff --git a/lib/spendable_web/live/budgets_test.exs b/lib/spendable_web/live/budgets_test.exs index 1af11d21..536f3106 100644 --- a/lib/spendable_web/live/budgets_test.exs +++ b/lib/spendable_web/live/budgets_test.exs @@ -465,4 +465,31 @@ defmodule SpendableWeb.Live.BudgetsTest do refute html =~ "Fund Each Month" refute html =~ "Funded This Month" end + + # Ecto records no change when the posted type matches what is stored, so the form falls back to + # the raw param - a string. Comparing that to an atom hid every envelope field. + test "keeps the envelope fields while the form is being typed into", %{conn: conn, scope: scope} do + {:ok, budget} = + Budgets.create_budget(scope, %{"name" => "Groceries", "funding_amount" => "300.00"}) + + {:ok, view, _html} = live(conn, ~p"/budgets") + + view |> element(~s(button[phx-value-id="#{budget.id}"])) |> render_click() + + html = + view + |> element(~s(form[phx-submit="submit"])) + |> render_change(%{ + budget: %{ + "name" => "Groceries", + "type" => "envelope", + "funding_amount" => "300.00", + "balance" => "-19" + } + }) + + assert html =~ "Budget Per Month" + assert html =~ "Remaining" + refute html =~ "Budgeted Amount" + end end