whisper-money/tests/Feature
Víctor Falcón f4c21147f1
feat(budgets): count shared accounts at the owner's percentage (#786)
## Why

#750 made a shared account count only your share of every transaction —
on the
dashboard and on the cashflow screen. Budgets were left out and shipped
as a
known gap: `budget_transactions.amount` is a snapshot written when a
transaction
is assigned, so a 50% joint account still spent **100%** of every
expense against
its budget. The same category could read €400 on the dashboard and €800
in
Budgets.

This closes that gap.

## What

A €100 expense on an account you own 50% of now counts €50 towards your
budgets.

Because every budget reader funnels through
`BudgetPeriod::spentAmount()` — a sum
of those snapshots — weighing the snapshot covers the budget cards, the
detail
page, the spending chart, carry-over, the limit alert emails and the MCP
tools in
one move.

- **Assignment writes the owner's share.** Both paths (the
per-transaction
listener and the historical backfill) go through one `recordSnapshot()`.
- **Changing an account's share rewrites its history.** A single SQL
`UPDATE`
  re-weighs every budget row of that account, in every period, past ones
  included.
- **A migration re-weighs the rows written before this**, so existing
shared
  accounts are correct on deploy instead of on the next edit.

## How

The share is computed at the same two choke points #750 established:

- **PHP** — `Transaction::ownerShareOf()` (extracted from
`ConvertsTransactionCurrency`, which was doing the same null dance
inline)
  feeds `BudgetTransactionService::recordSnapshot()`.
- **SQL** — `BudgetTransactionService::reweighAccountSnapshots()` reuses
`Transaction::OWNED_AMOUNT_SQL`, so the rounding matches what PHP would
have
  written. A test pins both to the same answer on an uneven share.

The re-weigh hangs off `Account::booted()` rather than
`AccountController`, so a
seeder, an artisan command or a future MCP write tool cannot silently
skip it.
It also clears the period's `close_to_limit_notified` /
`over_limit_notified`
flags, the same way a refund that drops a budget back under its limit
does —
otherwise a budget that fell out of "over limit" would stay claimed and
never
alert on the next real crossing.

The owning account is eager loaded **withTrashed** everywhere the
snapshot is
written, because `OWNED_AMOUNT_SQL` joins `accounts` without the
soft-delete
scope; without it a transaction whose account was deleted would snapshot
at 100%
in PHP and at the real share in SQL.

## Deliberate boundaries

- **Transaction rows still show the real bank amount** — #750's rule.
The budget
detail page is the one screen where a weighted total sits directly above
its
own itemised list, so it now says so in a line under the chart. The
alert email
  had the same mismatch inside one message and now quotes your share.
- **`carried_over_amount` is not re-derived.** It is a second snapshot
taken when
a period closes. `remainingAmount()` deliberately ignores it and the UI
only
types the field; it surfaces solely through MCP. Left alone rather than
adding
  a second re-derivation path.
- The migration's `down()` restores the full transaction amount, which
is what
  those rows held — it cannot know a share an account no longer has.

## Testing

`tests/Feature/SharedAccountOwnershipTest.php` gains the budget cases:
assignment,
the historical backfill, the re-weigh through the settings screen,
PHP/SQL
rounding parity on 33% of 3333, and the alert flags being cleared.
`tests/Feature/WeighBudgetTransactionsMigrationTest.php` covers the
backfill,
including that it is idempotent and that it keeps refunds negative (the
fix from
`2026_02_24_193117`). Fixing that test needed the `BudgetTransaction`
factory,
which had been pointing at a `BudgetPeriodAllocation` model that no
longer exists.

Manual QA on real local data — budget "Miami Flight", account "Daily"
set to 50%:

| | Before | At 50% | Back at 100% |
|---|---|---|---|
| Miami Flight spent | €3,229.40 | **€1,955.79** | €3,229.40 |
| Yearly Padel spent | €1,514.91 | €785.49 | €1,514.91 |

€1,955.79 is €3,229.40 − €1,273.61, exactly half of the €2,547.24 that
account
had in the budget. The round trip lands back on the original figure to
the cent,
so the re-weigh is idempotent on real data too.

## Demo

<!-- PLACEHOLDER: drag the QA video here -->


https://github.com/user-attachments/assets/1fa5b98a-2a11-4cad-b927-496b434d3295
2026-08-12 12:47:43 +02: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
Api
Auth
Console feat(reports): email a monthly CSV of active user emails to the owners (#783) 2026-08-12 11:29:28 +02:00
Events
Jobs
Listeners
Mcp feat(mcp): budget tools — read, create, edit and delete (#779) 2026-08-11 14:28:53 +00:00
Onboarding fix(onboarding): don't trap users on the syncing step when a bank sync fails (#745) 2026-08-09 18:40:49 +02:00
OpenBanking refactor(open-banking): bring the authorization callback back under the complexity threshold (#784) 2026-08-12 09:47:22 +00:00
Services
Settings
Spaces
Sync
AccountBalanceControllerTest.php
AccountControllerTest.php feat(accounts): count shared accounts at the owner's percentage (#750) 2026-08-11 13:26:54 +00:00
AccountImportConfigTest.php
AccountUserCurrencyServiceTest.php
AiConsentSettingsTest.php
AiConsentTest.php
AlignAccountsEncryptedFlagMigrationTest.php
ApplyRealEstateRevaluationTest.php
AuthenticatedLayoutSafeAreaTest.php
AutomationRuleApplicationTest.php
AutomationRuleEvaluationTest.php
AutomationRuleTest.php
BackfillAccountIbansCommandTest.php
BackfillXxxAccountCurrenciesTest.php
BalanceLookupTest.php
BudgetHistoricalAssignmentTest.php
BudgetNotificationTest.php
BudgetPeriodDateTest.php
BudgetPeriodServiceTest.php
BudgetTest.php feat(mcp): budget tools — read, create, edit and delete (#779) 2026-08-11 14:28:53 +00:00
BudgetTransactionServiceTest.php
BulkUpdateTransactionsTest.php refactor(transactions): build the bulk selection once in bulkUpdate (#774) 2026-08-11 13:14:19 +00:00
CancelFreeEnableBankingConnectionsCommandTest.php
CashflowAnalyticsTest.php
CashflowPageTest.php
CatchAllBudgetTest.php fix(budgets): keep labeled expenses out of the catch-all budget (#781) 2026-08-11 15:45:19 +00:00
CategoryMonthlyBreakdownTest.php
CrapCommandTest.php ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
CurrencyConversionServiceTest.php
DashboardAnalyticsTest.php
DashboardTest.php
DecryptTransactionsTest.php
DeleteUserCommandTest.php
DemoAccountRestrictionsTest.php fix(demo): stop demo:reset from colliding on the fake Stripe subscription id (#756) 2026-08-10 12:48:13 +00:00
DisconnectBankingConnectionsCommandTest.php
DiscordReportEmbedLimitsTest.php feat(subscriptions): end the trial experiment and make the trial length per plan (#762) 2026-08-12 10:59:55 +02:00
DiscordWebhookTest.php
EncryptionTest.php
ExampleTest.php
ExchangeRateServiceTest.php
GenerateStripePromotionCodesCommandTest.php chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
IdorVulnerabilityTest.php
ImportDataTest.php
InertiaSharedDataTest.php
IntegrationRequestTest.php
LabelTest.php
LoanTest.php refactor(accounts): split store/update in the account controller (#776) 2026-08-11 13:58:47 +00:00
LocalizationTest.php feat(currencies): add the Danish Krone (DKK) (#754) 2026-08-10 12:08:56 +02:00
LoggingConfigTest.php
MailSenderTest.php feat(reports): email a monthly CSV of active user emails to the owners (#783) 2026-08-12 11:29:28 +02:00
NewTransactionsMarkerTest.php
OpenAiAppsChallengeTest.php feat(mcp): serve the ChatGPT app directory domain challenge (#749) 2026-08-10 07:46:49 +00:00
PersistUpsellSourceFromStripeTest.php
PlaintextTransactionsTest.php
PlanFeatureTest.php
PopoverSafeAreaTest.php
PostStripeEventToDiscordTest.php
PurgeResidualEncryptionArtifactsJobTest.php
PwaTest.php
QueueConfigTest.php
ReEvaluateTransactionRulesTest.php
RealEstateAvailabilityTest.php
RealEstateTest.php
ResendSyncCommandTest.php
RoadmapTest.php feat(roadmap): add a public roadmap page mirrored from UserJot (#778) 2026-08-11 14:14:59 +00:00
RouteNotificationForMailTest.php
RuleEngineParityTest.php
SavedFilterTest.php
SendAiCohortReportCommandTest.php feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SendAiConsentFollowUpEmailsCommandTest.php
SendDailyStatsReportCommandTest.php feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SendStuckCohortReportCommandTest.php
SendSubscriptionFunnelReportCommandTest.php feat(subscriptions): end the trial experiment and make the trial length per plan (#762) 2026-08-12 10:59:55 +02:00
SentryConfigTest.php
SentryUserMiddlewareTest.php
SetLocaleTest.php
SharedAccountOwnershipTest.php feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
SitemapTest.php
StrayHttpRequestGuardTest.php
StripeSubscriptionStatsCommandTest.php
SubscriptionTest.php feat(subscriptions): end the trial experiment and make the trial length per plan (#762) 2026-08-12 10:59:55 +02:00
SuggestionPersistenceTest.php
SyncBankingConnectionsCommandTest.php
SyncStripePricesCommandTest.php
TrackLastActiveAtTest.php
TransactionAnalysisTest.php
TransactionFilterTest.php
TransactionSideClassificationTest.php
TransactionTest.php
WeighBudgetTransactionsMigrationTest.php feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
WelcomeBanksOrderingTest.php