Budgets were the one part of the app a connected agent could not see. It
could
list transactions, categories and labels, but "am I still inside my food
budget
this month" had no answer, and setting a budget up meant leaving the
conversation. This adds the four tools that close that gap.
## Tools
| Tool | What it does |
| --- | --- |
| `list_budgets` | Every budget with the period in progress: allocated,
carried over, spent, remaining, plus the categories and labels it tracks
|
| `create_budget` | A limit per period over categories and/or labels, or
the single catch-all budget |
| `update_budget` | Rename, or change the limit |
| `delete_budget` | Remove a budget; the transactions it was watching
are untouched |
Budgets hang off the user rather than off a space — that is how the app
already
decides which budgets a transaction feeds — so these tools take no
`space`
argument and cover the whole account, like the cashflow and net-worth
ones.
`update_budget` deliberately only takes `name` and `allocated_amount`.
The web
edit dialog locks the period length, start day and rollover behind an
explicit
message ("budgets are calculated historically"), and for good reason:
changing
them afterwards lets the next generated period overlap the one in
progress, so
the same transaction gets counted twice. To change those, or the tracked
categories, the agent deletes the budget and creates it again.
## Shared service, and one web behaviour change
Creating a budget seeds two periods and dispatches the historical
backfill for
both; that logic now lives in `BudgetService`, called by
`BudgetController` and
by the tools, instead of being copied into the MCP layer.
**This changes one thing on the web:** editing a budget's amount now
applies to
the period in progress as well as future ones. The old filter was
`start_date >= today`, which skipped a period that had already started —
so on
any day but the first of the period, saving a new amount appeared to do
nothing.
The edit dialog already promised the new behaviour ("This will update
the
allocated amount for the current and future periods") and prefills the
current
period's amount, so the code was the side that was wrong. Covered by a
new test
in `BudgetTest`.
## Fix: a start day that could hang period generation
`period_start_day` means a day of the month for monthly budgets and a
day of the
week for weekly ones. A weekly budget carrying a value above 6 sent
`calculatePeriodDates()` into `while ($date->dayOfWeek !== $dayOfWeek)`,
walking
backwards forever looking for a day that cannot exist. The web form caps
the
input at 6, but the server-side rule never did, so the tools were the
first
surface that could reach it — and once such a row exists, the daily
`budgets:generate-periods` command hits it and stops generating periods
**for
every user**.
Two changes: the tools validate the range against `period_type`, and the
generator takes the value modulo 7 so no caller can spin. Verified
against a row
doctored to `weekly` + `15`: it now resolves to a normal week instead of
hanging.
## Reviews
Both review passes ran; the notable ones applied beyond the two above:
- `remaining_amount` no longer adds the carried-over amount. The budget
cards,
the spending chart and the limit emails all measure against the
allocated
amount alone, so the old formula meant an agent quoting a number no
screen
shows. `carried_over_amount` is still reported as its own field.
- The period reports `processing_historical`, so an agent can tell
"nothing
spent" from "backfill still running" instead of trusting a prose caveat.
- The category/label hints say the ids must be ones the user owns —
`list_categories` is space-scoped and can return a co-member's, which a
budget
cannot track.
- Request id normalisation moved onto `McpTool` as `requestedIds()`,
shared with
`labelsInSpace`, which no longer runs a query just to build an empty
result.
Deliberately not applied: the "at least one category or label" and "only
one
catch-all" rules stay duplicated between `StoreBudgetRequest` and
`create_budget`. Unifying them would change the web error key
`selection`, which
the create dialog renders, and the two audiences want different wording
— the
agent gets told what to send next.
## QA
Driven over the real `/mcp` endpoint with a Sanctum token, since there
is no UI
surface:
- `tools/list` advertises all four with the right annotations and
schemas
(`delete_budget` destructive, `list_budgets` read-only).
- `create_budget` → the response comes back with `processing_historical:
true`;
after draining the queue, the €120 transaction already on the account is
attached and `spent_amount` reads 12000, `remaining_amount` 38000.
- `update_budget` to 60000 → the August period moves to 60000, the
closed July
period keeps 50000.
- `delete_budget` → gone from `list_budgets`, transaction still there.
- Refused as expected: a read-only token, a weekly budget with
`period_start_day`
15, a budget tracking nothing, and an unknown budget id.
- `mcp_tool_calls` recorded only the four calls that did something, not
the
rejections.
`chatgpt-app-submission.json` gains the four tools with their hint
justifications, plus a budget test case, so the submitted manifest keeps
matching
what the server serves (`ToolAnnotationsTest` enforces the count).
## What
Adds opt-in **email notifications per budget** for three events, plus a
dedicated **Notifications** settings page.
Each budget can independently enable:
- **New transaction** — a transaction was assigned to the budget
- **Close to limit** — spending crossed 90% of the limit
- **Over limit** — spending reached/passed the limit
A **Default (new budgets)** row lets you set the toggles that newly
created budgets inherit.
The existing *bank-transactions-synced* email toggle was **moved** from
the account page into this new Notifications page, so all email
preferences live in one place.
## UI
`Settings → Notifications`: one "Email notifications" section containing
the bank-sync toggle and a "Budgets" table (rows = Default + each
budget, columns = the three events).
<img width="1265" height="638" alt="budget-notifications-settings"
src="https://github.com/user-attachments/assets/b8386410-98dd-4a5e-9dee-6e5bf7effa31"
/>
## How it works
- Notifications fire from the **live transaction-assignment path**
(`BudgetTransactionService::assignTransaction`, already a queued
listener), never from historical backfill when a budget is created.
- Only the budget's **current period** is considered (the emails
describe the live "available before the limit" state).
- **New transaction** fires for a genuinely newly-assigned transaction;
the email includes the transaction.
- **Close/over** use two atomic per-period flags
(`close_to_limit_notified` / `over_limit_notified`) flipped with a
compare-and-set `UPDATE`, so exactly one email is sent per crossing even
under concurrent queue workers. The flags reset once spending drops back
below the close threshold, so a later crossing can notify again.
- Budgets with no limit (`allocated_amount <= 0`) never send close/over
emails; the "new transaction" email for such a budget omits the
limit/available rows.
- Every email shows the budget's current status (period, spent, limit,
available or over-by). Subcopy links back to the Notifications page.
- New budgets inherit the user's default toggles at creation time.
## Data model
- `budgets`: `notify_on_new_transaction`, `notify_on_close_to_limit`,
`notify_on_over_limit` (default `false`)
- `user_settings`: `budget_notify_on_*` defaults for new budgets
(default `false`)
- `budget_periods`: `close_to_limit_notified`, `over_limit_notified`
(dedup flags)
## Deliberate decisions
- **Threshold is a fixed 90%** for "close to limit" (not configurable) —
kept simple; easy to make per-budget later if requested.
- **"New transaction" sends one email per assigned transaction.** It is
opt-in and defaults to off. A bank sync importing many matching
transactions into an opted-in budget will therefore send several emails;
if this proves noisy we can batch per sync run (mirroring the daily
bank-sync digest). Flagged here rather than pre-building batching.
- **Limit basis is `allocated_amount`** (matches the budget
cards/spending chart), carry-over excluded.
## Tests
- Preference endpoints: page renders budgets + defaults, per-budget
toggle update, cross-user update forbidden, user-settings defaults
update, new budget inherits defaults.
- Sending: new-transaction opt-in/out, over-limit send, close-limit at
90%, no-resend dedup, re-notify after dropping below and crossing again,
no-limit budget skips close/over, historical backfill sends nothing,
notify after enabling the preference while already over. Plus a render
test covering all three email variants.
## Notes
- CI-enforced `es.json` keys for the new page and emails are included.
- All new PHP follows existing mail/queue conventions (`ShouldQueue`
mailable, `emails` queue, `RateLimited` middleware).
## 🚪 Why?
### Problem
PHPStan was running with a baseline of 56 suppressed errors, meaning
static analysis was not enforcing type safety across a significant
portion of the codebase. These errors were real type mismatches,
redundant null-safety operators, and incorrect PHPDoc annotations that
could mask bugs and make the code harder to reason about.
## 🔑 What?
### Changes
- Add `@property` PHPDoc annotations to `Account`, `BankingConnection`,
`ExchangeRate`, and `Transaction` models so Enum casts and typed columns
are visible to PHPStan
- Add `instanceof User` guards in `ScheduleDripEmailsListener`,
`SyncUserToResendListener`, and `FortifyServiceProvider` to properly
narrow `Authenticatable` to `App\Models\User`
- Remove redundant `?? false` and unnecessary nullsafe `?->value` in
`HandleInertiaRequests`
- Fix `SyncBankingConnectionJob`: use `->name` instead of `?->name` on
an always-loaded `bank` relation
- Remove `is_countable()` guard in `BalanceLookup` (parameter is always
`Collection|array`, both countable)
- Remove `?? []` / `?? default` fallbacks on fully-typed array keys
across `BalanceSyncService`, `BinanceBalanceSyncService`,
`BinanceClient`, `BitpandaBalanceSyncService`, `BitpandaClient`,
`IndexaCapitalClient`, `IndexaCapitalBalanceSyncService`, and
`AuthorizationController`
- Fix `BinanceClient::publicClient()` `retry()` call: use `when:` named
argument and `\Throwable` type hint to match `PendingRequest::retry()`
signature
- Update `IndexaCapitalClient::getPerformance()` `@return` to include
`portfolios` and `net_amounts` keys; simplify sync service to remove
dead null checks
- Replace nullsafe chain with ternary in `BudgetPeriodService`
- Replace `match` statement in `SetupMainUser` with `if/else` to
eliminate always-true comparison
- Clear `phpstan-baseline.neon` entirely (was 56 suppressed errors, now
0)
## ✅ Verification
### Tests
- Existing tests pass: PHPStan level 5 reports 0 errors with empty
baseline
## Overview
We're excited to introduce budgeting capabilities to Whisper Money! This
feature helps you take control of your finances by setting spending
limits and tracking your progress over time.
## Screenshots
<img width="1316" height="793" alt="image"
src="https://github.com/user-attachments/assets/ac394d36-cded-4ea4-9883-120785e260f1"
/>
<img width="1315" height="907" alt="image"
src="https://github.com/user-attachments/assets/7c682474-5aa7-4388-b626-29b56f5ebbef"
/>
<img width="1315" height="992" alt="image"
src="https://github.com/user-attachments/assets/21eace45-23c6-472d-9aa0-0feb6db3fba4"
/>
## What's New
### Create Flexible Budgets
- Set budgets for specific categories or labels
- Choose from monthly, weekly, bi-weekly, or custom periods
- Set your own budget start date for better alignment with your pay
schedule
### Track Your Spending
- Visual spending charts show how much you've spent vs. your budget
- See at a glance which budgets are on track and which need attention
- View all transactions that count toward each budget
### Smart Budget Management
- **Carry Over**: Unused budget amounts automatically roll into the next
period
- **Reset**: Unused amounts return to your available money pool
- Edit or delete budgets anytime as your needs change
### Easy Access
- New Budgets section in the main navigation
- Quick overview cards showing budget status
- Detailed budget pages with spending history and transaction lists
## How It Works
1. Create a budget by selecting a category or label and setting your
spending limit
2. Your transactions are automatically matched to relevant budgets
3. Track your progress with visual charts and spending summaries
4. Adjust your budgets as needed to stay on track with your financial
goals
This feature is now available behind a feature flag and can be enabled
for users who want to start budgeting their expenses.