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

Some accounts are shared. A joint account funded 50/50 with a partner
holds money that is only half yours, so its expenses and income should
only count towards your figures by half — otherwise the dashboard and
the cashflow screen overstate both sides of every month.

## What

An account can now be configured with **the share of it you actually
own**, in Settings → Accounts → Edit account.

- **Transactions are always weighted** by that percentage. This covers
the cashflow screen (summary, sankey, trend, category breakdown) and the
dashboard (cashflow summary, monthly spending, top categories).
- **Balances stay at face value by default**, so an account keeps
matching what the bank shows. An opt-in checkbox — only offered below
100% — applies the same percentage to the balance as well, which then
flows through net worth, its evolution, and the account cards.

Defaults are 100% / opt-in off, so existing users see no change at all.

## How

The share is computed at exactly two choke points, one per code path:

- **PHP** — `ConvertsTransactionCurrency::convertTransactionAmount()`
applies `Account::shareOfAmount()` after the currency conversion,
covering every row-by-row analytics consumer.
- **SQL** — `Transaction::OWNED_AMOUNT_SQL` plus the
`joinOwningAccount()` scope weigh the four aggregate queries that never
hydrate models.

Balances go through `BalanceLookup::forAccounts()`, the single point
every net-worth, evolution and account-metrics reader already shares —
so they all stay consistent for free, while the surfaces that must show
the real bank figure (the balance editor, imports, bank sync) read
`account_balances` directly and are untouched.

`ownership_percentage` is a **signed** `tinyInteger` on purpose: MySQL
promotes `signed * unsigned` to `BIGINT UNSIGNED`, which overflows on
the negative amount of an expense. A test caught this.

## Deliberate boundaries

- **Transaction rows keep showing the real amount.** A row should say
what the bank actually charged; only totals are your share.
- **The balance editor writes the full amount**, and now says so when
the account is shared — that is what stops a shared balance being halved
again on every correction.
- **Percentages are whole numbers, 1–100.** A three-way split (33.33%)
is not expressible yet.
- **Configurable on edit only.** `StoreAccountRequest` and the create
form are unchanged, so a new joint account counts at 100% until you edit
it.

## Known gap (follow-up)

**Budgets are not weighted.** `budget_transactions.amount` is a snapshot
written at assignment time, so a 50% account still counts at 100% in
budget spend, carry-over and threshold alerts. Fixing it properly needs
the write path weighted, a re-snapshot when the percentage changes, and
a backfill of existing rows — a separate PR rather than a line in this
one. Until then a shared account can read €400 on the dashboard and €800
in Budgets for the same category.

Loan/mortgage *projections* also seed off the raw latest balance, so a
shared **loan** with the balance opt-in on draws a step at today's date.
Narrow, and follow-up material.

## Testing

`tests/Feature/SharedAccountOwnershipTest.php` covers every weighted
endpoint, net worth with and without the balance opt-in, the 1–100
validation, and pins the PHP and SQL rounding to the same answer on an
uneven share (33% of an odd amount).

Manual QA on real local data (June 2026, account "Daily" set to 50%):

| | Before | After |
|---|---|---|
| Cashflow income | €4,460 | €4,057 (−€403 = half of the account's
€805.80) |
| Cashflow expenses | €4,122 | €3,217 (−€905 = half of the account's
€1,809.85) |
| Net worth (opt-in off) | 30,516,453 | 30,516,453 — unchanged |
| Net worth (opt-in on) | 30,516,453 | 30,470,797 (−45,656 = half of the
€913.12 balance) |

Also verified: the balance editor still prefills the real €244,310.08
and shows the shared-account notice; an empty percentage field leaves
the setting untouched instead of resetting to 100%; out-of-range values
are rejected.

## Demo


https://github.com/user-attachments/assets/de47e41a-ff72-4b2b-8e5b-51076e048504


<!-- PLACEHOLDER: drag the QA video here -->
2026-08-11 13:26:54 +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
Api
…
Auth
…
Console fix(demo): stop demo:reset from colliding on the fake Stripe subscription id (#756) 2026-08-10 12:48:13 +00:00
Events
…
Jobs
…
Listeners
…
Mcp refactor(mcp): dedupe record resolution and field updates in the write tools (#767) 2026-08-11 14:35:01 +02: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 fix(binance): value past days from Binance's own snapshot total (#763) 2026-08-11 11:04:06 +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
…
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
…
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(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +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
…
LocalizationTest.php feat(currencies): add the Danish Krone (DKK) (#754) 2026-08-10 12:08:56 +02:00
LoggingConfigTest.php
…
MailSenderTest.php
…
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
…
RouteNotificationForMailTest.php
…
RuleEngineParityTest.php
…
SavedFilterTest.php
…
SelfServeRefundTest.php fix(demo): stop demo:reset from colliding on the fake Stripe subscription id (#756) 2026-08-10 12:48:13 +00:00
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
SendExperimentFunnelReportCommandTest.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(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SentryConfigTest.php
…
SentryUserMiddlewareTest.php
…
SetLocaleTest.php
…
SharedAccountOwnershipTest.php feat(accounts): count shared accounts at the owner's percentage (#750) 2026-08-11 13:26:54 +00:00
SitemapTest.php
…
StrayHttpRequestGuardTest.php
…
StripeSubscriptionStatsCommandTest.php
…
SubscriptionExperimentTest.php chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
SubscriptionTest.php
…
SuggestionPersistenceTest.php
…
SyncBankingConnectionsCommandTest.php
…
SyncStripePricesCommandTest.php
…
TrackLastActiveAtTest.php
…
TransactionAnalysisTest.php
…
TransactionFilterTest.php
…
TransactionSideClassificationTest.php
…
TransactionTest.php
…
WelcomeBanksOrderingTest.php
…