whisper-money/app/Services
Víctor Falcón ab3902af39
fix(budgets): keep labeled expenses out of the catch-all budget (#781)
## Problem

A catch-all budget ("Not budgeted") is supposed to absorb every expense
no other
budget covers. It decided that by looking at the **categories** other
budgets
track — labels were never considered. So for a user whose other budgets
track
spending **by label**, nothing was ever "claimed" and the catch-all
absorbed
everything, double counting it.

Found in production: a user with three label-only budgets (Padel, Yearly
Padel,
Miami Flight) had every labeled expense sitting in their catch-all as
well. Their
current catch-all period read **289,136 / 170,000 (170%, over budget)**
where the
right figure is **75,281 / 170,000 (44%)**. 185 assignment rows are
wrong across
2 users.

## Fix

Precedence is now decided by the budget periods that actually match the
transaction: if any budget already counts it — by category **or** by
label, in a
period covering its date — the catch-all stays out. The historical
backfill
mirrors that rule in SQL, and only treats a category or label as claimed
when the
claiming budget has a period overlapping the range being backfilled.

That last part matters: keying purely on "some budget tracks this label"
would
have dropped expenses whose label budget has no period covering their
date,
leaving them in **no** budget at all (23 rows of one production user,
~2,204 of
spend that would have silently disappeared from their budget view). Both
review
passes flagged it; there are now tests for it on both paths.

## Repairing existing data

```bash
php artisan budgets:reassign-labeled --user=<email> --dry-run
php artisan budgets:reassign-labeled --user=<email>
```

It re-derives every budget assignment of the labeled transactions
currently
sitting in a catch-all budget, with notifications suppressed — these are
historical rows, so a limit email would announce a threshold crossed
weeks ago.
Reassignment (rather than deleting the bad rows) is deliberate: 44 of
the
affected transactions are not in their label budget either, so a plain
delete
would have left them nowhere.

## Known follow-ups (not in this PR)

- **The stale state can reappear.** Catch-all membership now depends on
labels,
but three paths mutate labels without firing `TransactionUpdated`, so
nothing
  reassigns: `AutomationRuleService::applyActions` (`saveQuietly` +
  `syncWithoutDetaching`), its bulk `applyRuleActionsToTransactions`
(`LabelTransaction::insertOrIgnore`), and the `LabelTransaction` MCP
tool. This
is what left the 44 Miami rows out of their budget — the label was
attached ~10
  minutes after the transaction's last save. The web paths are fine.
- Creating a label budget next to an existing catch-all does not release
the
  catch-all's rows, and deleting one does not hand them back.
- The repaired catch-all periods keep their `over_limit_notified` flag
until the
next expense lands in them (the flag reset lives in the notification
path we
  skip). Self-heals on the next assignment.

## Testing

`tests/Feature/CatchAllBudgetTest.php` — 11 tests: label claimed by
another
budget, label no budget tracks (with a *different* label claimed, so
"any claim"
is not enough), claiming budget with no covering period on both the
per-transaction and historical paths, and the repair command end to end
including `Mail::assertNothingSent()`.
2026-08-11 15:45:19 +00:00
..
Ai feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
Banking fix(binance): value past days from Binance's own snapshot total (#763) 2026-08-11 11:04:06 +00:00
Demo Remove encryption from browser tests and demo user (#129) 2026-02-17 11:45:27 +01:00
Discord feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
Stats fix(stats): correct the trial/pricing experiment funnel report (#679) 2026-07-15 09:17:32 +02:00
Stripe feat(discord): enrich Stripe event messages and dedupe deliveries (#460) 2026-05-31 12:50:37 +02:00
Subscriptions fix(demo): stop demo:reset from colliding on the fake Stripe subscription id (#756) 2026-08-10 12:48:13 +00:00
AccountMetricsService.php feat(dashboard): add accounts manager dialog with visibility toggle and reorder (#604) 2026-06-27 16:11:25 +00:00
AccountUserCurrencyService.php fix(open-banking): stop storing the XXX no-currency placeholder on accounts (#602) 2026-06-27 16:01:21 +00:00
AuthEntryPointService.php fix(auth): make build deterministic when REGISTRATION_ENABLED=false (#720) 2026-07-22 07:04:20 +00:00
AutomationRuleService.php refactor: consolidate duplicated financial calculations (#643) 2026-07-04 22:26:44 +02:00
BalanceLookup.php feat(accounts): count shared accounts at the owner's percentage (#750) 2026-08-11 13:26:54 +00:00
BudgetNotificationService.php feat(budgets): enable email notifications by default and coalesce alerts per transaction (#733) 2026-07-24 15:33:14 +02:00
BudgetPeriodService.php feat(mcp): budget tools — read, create, edit and delete (#779) 2026-08-11 14:28:53 +00:00
BudgetService.php feat(mcp): budget tools — read, create, edit and delete (#779) 2026-08-11 14:28:53 +00:00
BudgetTransactionService.php fix(budgets): keep labeled expenses out of the catch-all budget (#781) 2026-08-11 15:45:19 +00:00
CashflowSummaryService.php refactor: consolidate duplicated financial calculations (#643) 2026-07-04 22:26:44 +02:00
CategorySpendingService.php feat(accounts): count shared accounts at the owner's percentage (#750) 2026-08-11 13:26:54 +00:00
CategoryTree.php feat(analysis): break down spending by sub-category (#508) 2026-06-08 14:53:36 +02:00
CurrencyConversionService.php fix(currency): make rate fetching resilient to slow CDN (#502) 2026-06-08 09:10:38 +02:00
CurrencyOptions.php feat(settings): centralize currency options and split profile/account support (#256) 2026-04-02 19:23:10 +02:00
ExchangeRateService.php Fix PHP-LARAVEL-1V exchange rate cache race (#383) 2026-05-12 12:45:45 +02:00
LoanAmortizationService.php fix(dashboard): avoid month overflow in real estate projection (#340) 2026-04-30 13:53:01 +00:00
LoanBalanceGeneratorService.php fix(balances): stop historical-balance generator OOM on ancient purchase/loan dates (PHP-LARAVEL-49) (#661) 2026-07-08 21:34:43 +00:00
ManualBalanceAdjuster.php feat(transactions): allow manual transactions on bank-connected accounts (#747) 2026-08-10 05:32:40 +02:00
PeriodComparator.php feat(cashflow): add savings and period views (#424) 2026-05-25 16:41:00 +02:00
RealEstateBalanceGeneratorService.php fix(balances): stop historical-balance generator OOM on ancient purchase/loan dates (PHP-LARAVEL-49) (#661) 2026-07-08 21:34:43 +00:00
ResendService.php refactor: remove HIDE_AUTH_BUTTONS launch gate and waitlist apparatus (#717) 2026-07-22 08:51:48 +02:00