whisper-money/app/Console/Commands
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
..
Concerns
…
AgentDatabaseCommand.php
…
ApplyRealEstateRevaluationCommand.php
…
BackfillAccountIbans.php
…
BackfillSpaces.php
…
BackfillTransactionDescriptions.php
…
BackfillUserCurrencyCode.php
…
CancelFreeEnableBankingConnectionsCommand.php
…
CategorizeBackfillCommand.php
…
CheckBankLogosCommand.php
…
CrapCommand.php ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
DeleteAiConsentCommand.php
…
DeleteEncryptedDataAccountsCommand.php
…
DeleteManualAccountDataCommand.php
…
DeleteUserCommand.php
…
DisconnectBankingConnectionsCommand.php
…
E2eBankingFixtureCommand.php
…
EnsureLaunchCouponsCommand.php
…
FeatureDisableCommand.php
…
FeatureEnableCommand.php
…
GenerateBudgetPeriods.php
…
GenerateMonthlyLoanBalances.php
…
GenerateStripePromotionCodesCommand.php chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
McpUsageStatsCommand.php feat(mcp): record MCP tool usage and report it with stats:mcp-usage (#760) 2026-08-11 10:27:32 +02:00
NotifyEncryptedDataRemovalCommand.php
…
ReassignLabeledBudgetTransactions.php fix(budgets): keep labeled expenses out of the catch-all budget (#781) 2026-08-11 15:45:19 +00:00
ResendSyncCommand.php
…
ResetDemoAccountCommand.php fix(demo): stop demo:reset from colliding on the fake Stripe subscription id (#756) 2026-08-10 12:48:13 +00:00
ResetUserCategories.php
…
ReviewIntegrationRequestsCommand.php
…
SendAiCohortReportCommand.php
…
SendAiConsentFollowUpEmailsCommand.php
…
SendDailyStatsReportCommand.php
…
SendExperimentFunnelReportCommand.php
…
SendPaywallFollowUpEmailsCommand.php
…
SendStuckCohortReportCommand.php
…
SendSubscriptionCancelledEmailCommand.php
…
SendSubscriptionFunnelReportCommand.php
…
SendTestEmails.php
…
SendUpdateEmailCommand.php
…
SetBankLogoCommand.php chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
SetupMainUser.php
…
StripeSubscriptionStatsCommand.php
…
SuggestRulesCommand.php
…
SyncBankingConnections.php
…
SyncStripePricesCommand.php
…
TestScheduledDripEmailCommand.php
…
VerifyRefundFlowCommand.php
…