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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions lib/spendable/budgets/actions/calculate_spent_by_month_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,28 @@ defmodule Spendable.Budgets.Actions.CalculateSpentByMonthTest do
assert [%{month: ^current_month, spent: spent}] = Budgets.calculate_spent_by_month(scope)
assert Decimal.eq?(spent, "-25.00")
end

# A transfer moves money between the user's own accounts, so neither side is spending.
test "leaves a transfer out of the month", %{scope: scope} do
current_month = Date.beginning_of_month(Date.utc_today())

{:ok, out} =
Transactions.create_transaction(scope, %{
"amount" => "-500.00",
"date" => Date.utc_today(),
"name" => "Transfer to savings"
})

{:ok, into} =
Transactions.create_transaction(scope, %{
"amount" => "500.00",
"date" => Date.utc_today(),
"name" => "Transfer from checking"
})

{:ok, _pair} = Transactions.mark_as_transfer(scope, out, into)

assert [%{month: ^current_month, spent: spent}] = Budgets.calculate_spent_by_month(scope)
assert Decimal.eq?(spent, 0)
end
end
9 changes: 7 additions & 2 deletions lib/spendable/transactions/actions/mark_as_transfer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ defmodule Spendable.Transactions.Actions.MarkAsTransfer do

The pair has to be one transaction going out and one coming in, since a transfer moves money
rather than spending it. Clearing their allocations parks the whole of each amount on
Spendable, where the two opposite signs cancel.
Spendable, where the two opposite signs cancel, and both sides are marked reviewed because
saying what a pair is leaves nothing else to decide about it.
"""
def mark_as_transfer(
%Scope{user: %{id: user_id}},
Expand Down Expand Up @@ -39,7 +40,11 @@ defmodule Spendable.Transactions.Actions.MarkAsTransfer do
defp link(transaction, transfer_id) do
transaction
|> Repo.preload(:budget_allocations)
|> Transaction.changeset(%{"transfer_id" => transfer_id, "budget_allocations" => []})
|> Transaction.changeset(%{
"transfer_id" => transfer_id,
"budget_allocations" => [],
"reviewed" => true
})
|> Repo.update!()
end
end
8 changes: 8 additions & 0 deletions lib/spendable/transactions/actions/mark_as_transfer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ defmodule Spendable.Transactions.Actions.MarkAsTransferTest do
Transactions.get_transaction(scope, id: in_id)
end

# Saying what a pair is leaves nothing else to decide, so neither side stays in the review queue.
test "marks both sides reviewed", %{scope: scope, out: out, out_id: out_id, in: in_, in_id: in_id} do
assert {:ok, _pair} = Transactions.mark_as_transfer(scope, out, in_)

assert {:ok, %Transaction{reviewed: true}} = Transactions.get_transaction(scope, id: out_id)
assert {:ok, %Transaction{reviewed: true}} = Transactions.get_transaction(scope, id: in_id)
end

# A transfer moves money rather than spending it, so an envelope it was assigned to gets it back.
test "moves the whole amount to Spendable", %{scope: scope, out: out, in: in_} do
{:ok, budget} = Budgets.create_budget(scope, %{"name" => "Groceries"})
Expand Down
2 changes: 1 addition & 1 deletion lib/spendable_web/live/budgets_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ defmodule SpendableWeb.Live.BudgetsTest do
{:ok, _view, html} = live(conn, ~p"/budgets")

assert html =~ "SPENT"
assert html =~ "No limit set"
refute html =~ "No limit set"
end

# Dividing by the budgeted amount has to survive a budget set to nothing.
Expand Down
1 change: 1 addition & 0 deletions lib/spendable_web/mcp/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ defmodule SpendableWeb.MCP.Server do
component(SpendableWeb.MCP.Tools.ListBudgets)
component(SpendableWeb.MCP.Tools.ListSplits)
component(SpendableWeb.MCP.Tools.ListTransactions)
component(SpendableWeb.MCP.Tools.MarkTransfer)
component(SpendableWeb.MCP.Tools.UpdateBudget)
component(SpendableWeb.MCP.Tools.UpdateSplit)
end
43 changes: 37 additions & 6 deletions lib/spendable_web/mcp/server_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@ defmodule SpendableWeb.MCP.ServerTest do
|> put_req_header("content-type", "application/json")
|> put_req_header("accept", "application/json")

%{conn: conn, scope: scope}
end

test "runs a tool as the user the token was issued to", %{conn: conn, scope: scope} do
{:ok, _budget} = Budgets.create_budget(scope, %{"name" => "Groceries"})

initialized =
post(conn, ~p"/mcp", %{
"jsonrpc" => "2.0",
Expand All @@ -74,6 +68,12 @@ defmodule SpendableWeb.MCP.ServerTest do

post(conn, ~p"/mcp", %{"jsonrpc" => "2.0", "method" => "notifications/initialized"})

%{conn: conn, scope: scope}
end

test "runs a tool as the user the token was issued to", %{conn: conn, scope: scope} do
{:ok, _budget} = Budgets.create_budget(scope, %{"name" => "Groceries"})

called =
post(conn, ~p"/mcp", %{
"jsonrpc" => "2.0",
Expand All @@ -86,6 +86,37 @@ defmodule SpendableWeb.MCP.ServerTest do
json_response(called, 200)
end

# The type arrives as a JSON string and is validated before anything casts it, so an enum of
# atoms rejected every call that named one.
test "takes a budget type as the JSON string a client sends", %{conn: conn} do
called =
post(conn, ~p"/mcp", %{
"jsonrpc" => "2.0",
"id" => 2,
"method" => "tools/call",
"params" => %{
"name" => "create_budget",
"arguments" => %{"name" => "Home Renovation", "type" => "tracking"}
}
})

assert %{"result" => %{"structuredContent" => %{"budget" => %{"type" => "tracking"}}}} =
json_response(called, 200)
end

test "keeps an ampersand in a name it is given", %{conn: conn} do
called =
post(conn, ~p"/mcp", %{
"jsonrpc" => "2.0",
"id" => 2,
"method" => "tools/call",
"params" => %{"name" => "create_budget", "arguments" => %{"name" => "Auto Insurance & Fees"}}
})

assert %{"result" => %{"structuredContent" => %{"budget" => %{"name" => "Auto Insurance & Fees"}}}} =
json_response(called, 200)
end

test "refuses a call with no bearer token" do
assert %{status: 401} =
build_conn()
Expand Down
3 changes: 2 additions & 1 deletion lib/spendable_web/mcp/tools/create_budget.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ defmodule SpendableWeb.MCP.Tools.CreateBudget do
schema do
field :name, {:required, :string}, description: "What the budget is called, e.g. \"Groceries\"."

field :type, {:enum, [:envelope, :goal, :tracking]},
# Strings, not atoms: the value arrives from JSON and is compared before anything casts it.
field :type, {:enum, ["envelope", "goal", "tracking"]},
description:
"envelope reserves money for a purpose, goal saves toward a target, tracking records spending " <>
"without reserving anything. Defaults to envelope."
Expand Down
2 changes: 1 addition & 1 deletion lib/spendable_web/mcp/tools/create_budget_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ defmodule SpendableWeb.MCP.Tools.CreateBudgetTest do

test "records an adjustment when a starting balance is given", %{frame: frame, scope: scope} do
assert {:reply, %Response{isError: false}, ^frame} =
CreateBudget.execute(%{name: "Groceries", type: :goal, balance: "40.00"}, frame)
CreateBudget.execute(%{name: "Groceries", type: "goal", balance: "40.00"}, frame)

assert [%{type: :goal, balance: balance}] = Budgets.list_budgets(scope)
assert Decimal.eq?(balance, "40.00")
Expand Down
5 changes: 4 additions & 1 deletion lib/spendable_web/mcp/tools/list_transactions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ defmodule SpendableWeb.MCP.Tools.ListTransactions do
Lists the user's transactions newest first, with how each one is allocated across budgets. A
transaction is negative when money left the user and positive when it arrived, and is always
fully allocated - whatever is not assigned elsewhere sits in the Spendable budget. Transactions
the user has already reviewed, or excluded from spending, are left out unless asked for.
the user has already reviewed, or excluded from spending, are left out unless asked for. A
`transfer_id` is the other side of a move between the user's own accounts, and counts as neither
spending nor income.
"""
use Anubis.Server.Component, type: :tool, annotations: %{readOnlyHint: true}

Expand Down Expand Up @@ -47,6 +49,7 @@ defmodule SpendableWeb.MCP.Tools.ListTransactions do
note: &1.note,
reviewed: &1.reviewed,
excluded: &1.excluded,
transfer_id: &1.transfer_id,
allocations:
Enum.map(
&1.budget_allocations,
Expand Down
46 changes: 46 additions & 0 deletions lib/spendable_web/mcp/tools/mark_transfer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
defmodule SpendableWeb.MCP.Tools.MarkTransfer do
@moduledoc """
Links two transactions as the two sides of a move between the user's own accounts. The pair has
to be one transaction leaving an account and one arriving in another, since a transfer moves
money rather than spending it. Both sides stop counting toward spending, their allocations are
cleared onto Spendable where the opposite signs cancel, and both are marked reviewed.
"""
use Anubis.Server.Component, type: :tool, annotations: %{readOnlyHint: false}

import SpendableWeb.Utils.ToolReply

alias Spendable.Transactions

schema do
field :from_transaction_id, {:required, :string},
description: "The id of the transaction the money left, whose amount is negative."

field :to_transaction_id, {:required, :string},
description: "The id of the transaction the money arrived in, whose amount is positive."
end

@impl true
def execute(params, frame) do
scope = frame.assigns.current_scope

with {:ok, from} <- Transactions.get_transaction(scope, id: params.from_transaction_id),
{:ok, to} <- Transactions.get_transaction(scope, id: params.to_transaction_id),
{:ok, {linked_from, linked_to}} <- Transactions.mark_as_transfer(scope, from, to) do
reply(frame, %{
transactions:
Enum.map(
[linked_from, linked_to],
&%{
id: &1.id,
name: &1.name,
amount: Decimal.to_string(&1.amount),
transfer_id: &1.transfer_id,
reviewed: &1.reviewed
}
)
})
else
{:error, reason} -> reply_error(frame, reason)
end
end
end
86 changes: 86 additions & 0 deletions lib/spendable_web/mcp/tools/mark_transfer_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule SpendableWeb.MCP.Tools.MarkTransferTest do
use Spendable.DataCase, async: true

alias Anubis.Server.Frame
alias Anubis.Server.Response
alias Spendable.Accounts
alias Spendable.Scope
alias Spendable.Transactions
alias SpendableWeb.MCP.Tools.MarkTransfer

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

scope = Scope.for_user(user)

{:ok, %{id: from_id} = from} =
Transactions.create_transaction(scope, %{
"name" => "Transfer to savings",
"amount" => "-500.00",
"date" => "2026-08-15"
})

{:ok, %{id: to_id} = to} =
Transactions.create_transaction(scope, %{
"name" => "Transfer from checking",
"amount" => "500.00",
"date" => "2026-08-15"
})

%{
frame: Frame.new(%{current_scope: scope}),
from: from,
from_id: from_id,
to: to,
to_id: to_id,
scope: scope
}
end

test "links both sides and marks them reviewed", %{frame: frame, from_id: from_id, to_id: to_id} do
assert {:reply,
%Response{
structured_content: %{
transactions: [
%{id: ^from_id, transfer_id: ^to_id, reviewed: true},
%{id: ^to_id, transfer_id: ^from_id, reviewed: true}
]
}
}, ^frame} =
MarkTransfer.execute(%{from_transaction_id: from_id, to_transaction_id: to_id}, frame)
end

test "refuses a pair moving the same way", %{frame: frame, from: from, scope: scope} do
{:ok, other} =
Transactions.create_transaction(scope, %{
"name" => "Coffee",
"amount" => "-5.00",
"date" => "2026-08-15"
})

assert {:reply, %Response{isError: true, content: [%{"text" => "transfer not allowed"}]}, ^frame} =
MarkTransfer.execute(
%{from_transaction_id: from.id, to_transaction_id: other.id},
frame
)
end

test "cannot reach a transaction belonging to another user", %{frame: frame, to: to} do
{:ok, other_user} =
Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})

{:ok, theirs} =
Transactions.create_transaction(Scope.for_user(other_user), %{
"name" => "Transfer to savings",
"amount" => "-500.00",
"date" => "2026-08-15"
})

assert {:reply, %Response{isError: true, content: [%{"text" => "transaction not found"}]}, ^frame} =
MarkTransfer.execute(
%{from_transaction_id: theirs.id, to_transaction_id: to.id},
frame
)
end
end
3 changes: 2 additions & 1 deletion lib/spendable_web/mcp/tools/update_budget.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ defmodule SpendableWeb.MCP.Tools.UpdateBudget do
field :budget_id, {:required, :string}, description: "The id of the budget to change."
field :name, :string, description: "What the budget is called, e.g. \"Groceries\"."

field :type, {:enum, [:envelope, :goal, :tracking]},
# Strings, not atoms: the value arrives from JSON and is compared before anything casts it.
field :type, {:enum, ["envelope", "goal", "tracking"]},
description:
"envelope reserves money for a purpose, goal saves toward a target, tracking records spending " <>
"without reserving anything."
Expand Down
7 changes: 7 additions & 0 deletions lib/spendable_web/mcp/tools/update_budget_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ defmodule SpendableWeb.MCP.Tools.UpdateBudgetTest do
assert Decimal.eq?(balance, "40.00")
end

test "turns an envelope into a tracking budget", %{budget: budget, frame: frame, scope: scope} do
assert {:reply, %Response{structured_content: %{budget: %{type: :tracking}}}, ^frame} =
UpdateBudget.execute(%{budget_id: budget.id, type: "tracking"}, frame)

assert [%{type: :tracking}] = Budgets.list_budgets(scope)
end

test "cannot reach a budget belonging to another user", %{frame: frame} do
{:ok, other_user} =
Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})
Expand Down
4 changes: 2 additions & 2 deletions lib/spendable_web/utils/budget_card.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ defmodule SpendableWeb.Utils.BudgetCard do
end

def build_budget_card(%Budget{type: :tracking}, spent, _current_month_is_selected) do
%{amount: spent, label: "SPENT", percent: nil, bar: nil, footer: "No limit set"}
%{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: "No limit set"}
%{amount: budget.balance, label: "LEFT", percent: nil, bar: nil, footer: nil}
end

def build_budget_card(%Budget{type: :envelope} = budget, spent, _current_month_is_selected) do
Expand Down
18 changes: 16 additions & 2 deletions mobile/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,31 @@ import 'banks/plaid_oauth_links.dart';
import 'design/theme.dart';
import 'shell.dart';

class SpendableApp extends ConsumerWidget {
class SpendableApp extends ConsumerStatefulWidget {
const SpendableApp({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<SpendableApp> createState() => _SpendableAppState();
}

class _SpendableAppState extends ConsumerState<SpendableApp> {
final _navigator = GlobalKey<NavigatorState>();

@override
Widget build(BuildContext context) {
final auth = ref.watch(authStateProvider);

// Nothing renders it; it just has to be alive to catch a bank's OAuth redirect on launch.
ref.watch(plaidOAuthResumeProvider);

// Signing out swaps the home screen underneath whatever was pushed on top of it, so the
// account screen would otherwise stay up over the sign-in screen.
ref.listen(authStateProvider, (_, next) {
if (next.value == false) _navigator.currentState?.popUntil((route) => route.isFirst);
});

return MaterialApp(
navigatorKey: _navigator,
title: 'Spendable',
theme: spendableTheme(Brightness.light),
darkTheme: spendableTheme(Brightness.dark),
Expand Down
5 changes: 5 additions & 0 deletions mobile/lib/banks/account_label.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// An account reads as its name and the last few digits of its number. Not every account has one -
/// an Apple Cash balance has nothing to print - and dots with nothing after them say less than the
/// name on its own.
String accountLabel(String name, String? number) =>
number == null || number.isEmpty ? name : '$name ••••$number';
Loading
Loading