`EnableBankingSyncer::sync` wrapped the transaction call but only caught
`InaccessibleBankAccountException` and `WrongTransactionsPeriodException`. A
`TransientBankingProviderException` — what EnableBanking's HTTP 400
`{"error":"ASPSP_ERROR"}` becomes, i.e. "the bank's connector failed" — propagated
out and abandoned the loop, so every account behind the failing one was skipped
along with its balance, cycle after cycle.
Verified in production on a CaixaBank connection with three accounts: account 1
kept importing transactions until 2026-07-18 (618 rows, 231 balance days), while
accounts 2 and 3 sat at zero transactions with their balances frozen at
2026-06-12 — the date of the last complete run. Five weeks of it. That user has
since deleted their account, so this ships as a latent fix rather than a rescue;
84 of the 260 live EnableBanking connections have two or more accounts.
Deliberately conservative about everything else. The failure is still raised once
every account has had its turn, so the connection keeps its Error state, its
retries and its unset `last_synced_at` exactly as before. Recording a partial run
as a success would have shown an Active badge and a fresh timestamp over an
account that had stopped updating, and nothing in the product distinguishes a
stale account from a fresh one — a quieter dead end than the one being fixed. It
would also have stamped `bank_transactions_email_cutoff_at` and consumed the
connection-level first sync, permanently losing the failing account's derived
balance history and reporting its eventual backfill as "new transactions today".
A provider that never answered is rethrown immediately instead: `statusCode` is
null only on the `ConnectionException` path, and carrying on there would spend the
client's timeout per account against the job's 120s — a 26-account connection
already takes 62s when everything works.
## What
The Roadmap item pushed the landing header past the viewport on narrow
laptops: the logo wrapped onto two lines and, in Spanish, the register
button was clipped off screen (at 640px the nav needed 670px).
Between `sm` and `lg` the nav is now icon-only and tighter:
- Roadmap / Github / Discord keep their labels only from `lg` up. The
label stays `sr-only` instead of `display:none`, so the icons keep their
accessible name — before this they announced as "link", "link 1096",
"link". The GitHub star count stays visible at every width.
- Button padding, nav gap and the wordmark size all step down below
`lg`, which is what buys back the room Spanish needs. At 640px the nav
now ends on the 24px page padding instead of 10px past it, with 22px of
air between logo and nav.
- The logo gets `shrink-0` + `whitespace-nowrap`, so it can no longer
wrap.
## Also
- The logo links back to the landing page on every page but the landing
itself (the page is detected by URL, so `?ref=` links behave). Before
this, a phone visitor on `/roadmap` had no way back other than the
browser's back button.
- The mobile pill header drops its Github/Discord buttons and separator:
they were `hidden … sm:flex` inside a `sm:hidden` header, so they could
never render.
- The three nav buttons collapse into a local `NavButton`, so the
breakpoint lives in one place instead of three.
## QA
Browser QA against the dev server on `/` and `/roadmap`, in Spanish (the
longest of our strings) and English, at 390 / 640 / 672 / 1024 / 1280:
- 640px es: no horizontal overflow, right padding intact, nothing
clipped.
- 1024px+: labels are back, layout unchanged from today.
- 390px: the mobile pill is untouched.
- Logo click on `/roadmap` navigates to `/`; on `/` it renders as a
plain `div` in both headers.
Unit tests cover the logo routing branch. The responsive band itself is
visual-QA only — jsdom cannot evaluate Tailwind breakpoints, and a
className assertion would be brittle.
## Demo
<!-- PLACEHOLDER: drag the screenshots here (390 / 640 / 672 / 1280,
Spanish) -->
Patch release cut with release-it.
## What changed
- `package.json` / `package-lock.json`: `0.2.7` → `0.2.8`
- `CHANGELOG.md`: regenerated (conventional-changelog, angular preset)
and enriched by `scripts/enrich-changelog.js`
No application code is touched.
## After the merge
The tag `v0.2.8` and the GitHub release are created on `main` once this
PR is merged — they are intentionally disabled during this run because
`main` is protected and requires a PR.
## What
An A/B price experiment to find the price that **maximizes contribution
margin per new user**, not just conversion. **Inert until
`PRICE_EXPERIMENT_STARTED_AT` is set** — merging changes nothing in
production.
| Arm | Monthly | Annual (= monthly × 6) |
|-----|---------|------------------------|
| A · control | €3.99 | €23.88 *(unchanged)* |
| B · high | €8.99 | €53.94 |
New signups only; earlier users stay `legacy`/control. Two arms rather
than the originally designed A/B/C: at current signup volume three arms
leave the CM metric underpowered, and the wide €3.99↔€8.99 gap maximizes
the detectable signal.
## Rebuilt on top of #762
This branch was written against the #600 trial experiment. #762 then
ended that experiment and deleted `ExperimentOffer`,
`SubscriptionExperiment`, `ExperimentFunnelCollector`,
`ProportionSignificance` and `BinomialProportion` — the exact foundation
this extended.
Rather than resurrect them, the branch was reset onto `main` and
rewritten: **1478 → 401 insertions, 19 → 8 files.** The previous version
is preserved at the tag
[`price-experiment-full`](https://github.com/whisper-money/whisper-money/tree/price-experiment-full).
## How it works
- **`App\Services\Subscriptions\PriceExperiment`** — `variantFor` /
`plansFor` / `lookupKeyFor`, gated by `PRICE_EXPERIMENT_STARTED_AT`,
winner pinned via `PRICE_EXPERIMENT_FORCE_VARIANT` (env-only, no
deploy).
- Assignment is a **salted hash**, `crc32('price:'.$id) % 2` —
deliberately *not* a stored Pennant feature. Nothing needs persisting,
reading back, or purging when the experiment ends (#762 needed a
migration to delete 1,890 stored assignments). It also costs no query on
the render path, and a report can reproduce the split in SQL with
`CRC32(CONCAT('price:', id))`. The salt keeps the split independent from
any other crc32-based one on the same ids.
- Checkout resolves the lookup key **server-side only**, so a client
can't self-select the cheaper price. `HandleInertiaRequests` makes the
shared `pricing.plans` prop variant-aware, so the paywall and the
upgrade dialogs show exactly what will be charged — no frontend change
needed.
- **`stripe:sync-prices`** now also creates the variant tiers.
## Measurement is deliberately not in this PR
The previous version shipped ~1100 lines of measurement:
`stats:price-experiment-funnel`, its collector, `WelchTTest`, `Normal`,
`SampleRatioMismatch`, `MonthlyEquivalentPrices`, a weekly schedule
entry and their tests. All of it is cut here.
Every input is reconstructible at any time — the bucket is
deterministic, and `created_at`, subscriptions and connections are all
stored — so no data is lost by not capturing it weekly. And the design
calls for deciding **only at a pre-registered horizon**, which made a
weekly Discord report whose largest field read *"⚠️ MONITORING ONLY — do
not call a winner from this"* mostly ceremony.
It gets rebuilt from the tag when there is data worth reading. Checking
mid-flight that the split is really 50/50 needs no code:
```sql
SELECT CRC32(CONCAT('price:', id)) % 2 AS arm, COUNT(*)
FROM users WHERE created_at >= '<started_at>' GROUP BY arm;
```
The analysis design itself still stands and is recorded for the horizon:
CM/user primary via Welch (the €8.99 arm carries far more revenue
variance, so pooled-variance is invalid), conversion as a Fisher-exact
guardrail against control, SRM chi-square on assigned and matured
counts, cost from *currently-active* connections, and a guard that every
arm price id resolves in the Stripe product map.
## Incidental
`SyncStripePricesCommand::handle` was already at cyclomatic complexity
11 on `main`; touching one line surfaced it in the `crap` check. The
per-plan body moved into `syncPlan()` — pure extraction, same output.
## Before launching (in order)
1. `php artisan stripe:sync-prices` — creates the €8.99 / €53.94 tiers
under the lookup keys `whisper_pro_monthly_high` and
`whisper_pro_yearly_high`. **Must run before step 2.**
2. Set `PRICE_EXPERIMENT_STARTED_AT` to the launch date.
3. Compute and pre-register the horizon N from the real signup rate — no
peeking before it.
The #600 trial experiment is over, so price is automatically the only
moving variable; there is nothing to pin.
## Tests
Pest coverage for the gate, the forced-variant pin, the salt, split
stability per user, the variant prices and lookup keys, the paywall
prop, server-side checkout resolution, and variant syncing in
`stripe:sync-prices`. Affected suites green locally; `pint` and `php
artisan crap` clean.
> Sentry's MCP token is still expired, so this came from the production
DB and `failed_jobs` again — the follow-up I flagged in #782.
## The bug
A user connected Wise on 2026-07-29 and **has never received a completed
sync in 14 days**. Their wallet holds 110 transactions but **zero rows
in `account_balances`**, so it contributes nothing to their net worth,
and the connection shows a red Error badge with no notification ever
sent.
## Root cause: one word
`WiseClient::getActivities` sent the pagination cursor as `cursor`. Wise
*returns* it as `cursor` but only *reads* it as `nextCursor` — the docs
say it outright ("Pass this value as the `nextCursor` query parameter").
We sent the wrong name, Wise ignored it, and **every request returned
page one again**. The walk could never terminate.
The production data says the same thing without the docs:
| created_at | rows | transaction_date range |
|---|---|---|
| 2026-07-29 06:44:49 | 87 | 2025-12-23 → 2026-07-20 |
| 2026-07-29 06:44:50 | 10 | 2025-12-19 → 2025-12-23 |
| every run since | 1/day | that day only |
97 rows in two consecutive seconds — one `size=100` page after
`CARD_CHECK`/non-EUR filtering — and in ~50 runs over 14 days **never a
row older than 2025-12-19**. Page two has never been fetched.
The timeouts were a symptom, not the cause: the loop hammered one
endpoint for 120s straight, four times a day, until Wise started
answering with cURL-28s and a 500. 65 of the 66
`TimeoutExceededException` job failures in `failed_jobs` over 14 days
are this one connection, which also burns three 120s attempts plus three
worker kills per cycle on the single `default` worker, delaying everyone
else's jobs.
**I had this wrong.** My first pass diagnosed "a year of history is too
much to paginate" and capped the walk at 60s. That would have converted
an infinite loop into a permanent 100-activity ceiling on every Wise
account — masking the bug while looking like a fix. The product review
caught it; I verified it against both the Wise docs and the import
timestamps before rewriting.
## The commits
1. **`nextCursor`.** The root cause. The test keys its fake off
`nextCursor`, so the old name looks like what it was — a one-page
history that never ends. With the wrong name the test does not terminate
(verified under an alarm); with the right one it pages twice and stops.
2. **A time budget, as a safety net rather than the fix.** With
pagination working a normal wallet finishes in two requests, but a long
history or a slow provider would still get the job killed, and
`last_synced_at` is only written on success — which is exactly the
never-converges state. The deadline is the *caller's*, passed in: Wise
creates one account per currency per profile, so a budget per wallet
multiplies straight past the job's 120s (a three-wallet connection
reproduced the original bug verbatim — there is a test). Null lets
`banking:sync --sync`, which runs in-process without the worker timeout,
walk as far as it likes. `WiseClient` now owns 15s/5s timeouts instead
of inheriting the framework's 30s, so "budget plus one in-flight
request" is a bound this code can actually state. Matches the two
sibling clients.
3. **Balance first, and not load-bearing.** The balance ran after the
walk, which never returned — hence zero balance rows. Ordering it first
is only half the fix: `getBorderlessAccount` throws on a 5xx and nothing
caught it, so done naively it just swaps which half the user loses. It
is wrapped, counted into the returned metadata like
`EnableBankingSyncer` does, and skipped wallets are reported too.
## Verification
`tests/Feature/OpenBanking`: 354 tests, 344 pass, and the **same 10
failures as clean main** (Inertia page-render tests hitting the SSR
`/render` endpoint with no local server — baseline confirmed). 4 new
tests, each verified to fail with only its own change reverted:
per-wallet budget → the multi-wallet test; no try/catch → the balance
test; wrong cursor name → non-termination. `pint`, `crap` (0 methods
over 10 — `importPage` is extracted because the deadline pushed `sync`
to 11) and `dry` all green.
## Not done, deliberately
- **Wise has no historical-balance backfill**, unlike
Coinbase/IBKR/EnableBanking — `WiseBalanceSyncService` only ever writes
*today's* balance, and `BalanceLookup::getBalanceAt` returns 0 with no
earlier row. So this wallet will read €0 across the whole 12-month
sparkline and step to its real value the day this ships, next to 110
transactions going back to December. Pre-existing and true of any new
Wise connection, but this fix is what makes it visible. Its own PR.
- **N wallets on one profile each walk the identical list** — the
activities endpoint is per profile, and `parseActivity` filters by
currency afterwards. Fetch once per profile and fan out; real cost and
rate-limit win, bigger change.
- **Backfilling older history.** The next sync starts from the
connection-level `last_synced_at`, so pages left behind on a budget stop
are not revisited. `EnableBankingSyncer::resolveDateFrom` already has
the cheap pattern (derive the window from the imported rows, no schema
change) — for Wise's newest→oldest walk that means setting `until` to
the oldest imported row. Noted as the upgrade path in the code rather
than "persist a cursor", which needs a migration.
- **14 days broken and silent.** No notification exists for a connection
stuck in Error or never-synced, and since #757 correctly stopped
counting transient failures there is no escalation either. Worth an
alert on days-since-last-success; called out as a follow-up in #782 too.
## Auto-merge
Enabled. The root cause is a one-word parameter name confirmed against
the vendor docs and independently against production data; the other two
commits are additive safety with tests that each fail without them. No
migration, no data writes, no schema change, and the affected code path
serves one production connection that is currently completely broken —
the downside of being wrong is bounded by that, and the upside is a user
who gets their account back.
## Problem
Since #781 a catch-all budget ("Not budgeted") yields to any budget that
tracks a
transaction by category **or** label in a period covering its date.
Reassignment is
driven by `TransactionCreated` / `TransactionUpdated`, whose listener is
`AssignTransactionToBudget`.
Pivot writes and query-builder mass updates fire no model event. So
every path that
attaches a label without a real `save()` left the transaction assigned
to whatever it
was before — typically stuck in the catch-all and missing from the
budget that tracks
its label.
In production, 44 expenses carrying one label never entered their label
budget:
`label_transaction.created_at` is ~10 minutes later than
`transactions.updated_at` on
each of them, i.e. the listener ran before the label existed and nothing
ran after.
## The paths that were broken
| Path | Write | Event |
|---|---|---|
| `AutomationRuleService::applyActions` | `saveQuietly()` +
`syncWithoutDetaching` | none |
| `AutomationRuleService::applyRuleActionsToTransactions` |
`LabelTransaction::insertOrIgnore` + mass category `update()` | none |
| `Mcp\Tools\LabelTransaction` | `syncWithoutDetaching` / `detach` |
none |
| `TransactionController::bulkUpdate` | mass category `update()` +
`labels()->sync()` | none |
The last one was found during review and is the surface users hit most —
the
bulk-actions bar in the transactions table. Its `syncLabels()` helper
does
`sync()` then `save()`, but those models are loaded before the mass
update and
nothing dirties them, so `Model::save()` skips `performUpdate()` and
fires nothing.
The docblock claiming the `save()` bumped `updated_at` was wrong and is
corrected.
## The fix
A dedicated `ReassignTransactionsToBudgets` job, dispatched from each
path, rather
than re-broadcasting `TransactionUpdated` — which would also re-run the
automation
rules that dispatched it. The job takes ids, not models, so a retry
never works from
a stale payload.
Batching, so no path queues one job per row:
- the bulk rule apply dispatches per batch of 500 ids (the AI suggestion
path is the
one caller that can hand it an unbounded list)
- `ReEvaluateTransactionRulesJob` loops `applyRules()` over the user's
whole history,
so `applyRules()` takes `reassignBudgets` and that job batches one job
per chunk
- `TransactionController::bulkUpdate` dispatches once for the whole
selection
Notifications are suppressed (`notify: false`) everywhere the change is
an
administrative edit rather than new spending: bulk rule applies,
re-evaluating rules
over history, the bulk-actions bar, and MCP relabelling. Otherwise
removing a label
would announce a months-old expense as new in the catch-all budget. The
single
transaction matched by a rule at creation time keeps notifications on.
Suppressing
them leaves `close_to_limit_notified` / `over_limit_notified` unclaimed,
so the next
genuine transaction into that budget still alerts.
A note never changes which budget counts a transaction, so a note-only
rule no longer
earns a reassignment on either path.
`applyRuleActionsToTransactions` was doing category, note and label work
inline; the
three branches are extracted so the added dispatch keeps the method
under the
repo's complexity-10 gate.
## Deploy step
This stops the state from going stale again, it does not repair what is
already
stale. After deploy:
```
php artisan budgets:reassign-labeled [--user=<email>] [--dry-run]
```
## Tests
`tests/Feature/LabelBudgetReassignmentTest.php` covers all four paths
plus the
re-evaluate batching, each asserting the transaction actually leaves the
catch-all
and lands in the label budget. All seven fail on `main` and pass here.
`AutomationRuleApplicationTest` now pins that the bulk apply queues
exactly one
reassignment job and that it is silent.
## QA
Verified in the browser against the running app with a throwaway
account: a
catch-all budget and a label budget over the same period, four expenses
starting in
the catch-all.
- Selecting all four in the transactions table and applying the label
moves
**Not budgeted $540.00 → $0.00** and **Miami 26 $0.00 → $540.00**,
confirmed in
`budget_transactions`.
- "Remove all labels" hands them back to the catch-all.
## Demo
<!-- PLACEHOLDER: drag the QA video here -->
https://github.com/user-attachments/assets/5a135257-42cf-42a7-a66a-3f76bf773e0c
## Follow-ups (not in this PR)
- Soft-deleting a label leaves its budget still counting the
transactions; the
catch-all never takes them back.
- `CategoryTree::deleteSubtree` mass-nulls `category_id` with no event,
so cascade
category deletion leaves transactions in their old category budget.
- `BudgetService::create` only adds rows for a new budget's historical
transactions;
it never removes their catch-all rows, so creating a label budget next
to an
existing catch-all double-counts until something else reassigns them.
## 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
Follow-up to #781, which landed before this commit made it onto the
branch.
`assignTransaction` carried the whole category/label matching inline.
Once #781
added the precedence branch (a catch-all budget only absorbs what no
other
budget counts), the method crossed the complexity threshold and the
`crap` job
went red:
```
| Cplx | Method |
| 11 | App\Services\BudgetTransactionService::assignTransaction |
```
Moving the matching into `trackedPeriodIds()` brings it back under the
limit.
Pure extraction — no behaviour change, same query, same order.
`AuthorizationController::callback` sat at cyclomatic complexity **16**
against a
repo threshold of **10**, so the `crap` check went red on every PR that
touched
the file regardless of what the diff did — most recently #782, where the
change
was a single array entry.
This is a **pure refactor: no behaviour change.** No test was modified,
and
`.crap-ignore.json` stays empty — the complexity is actually gone, not
exempted.
## What moved
`callback` is a fixed-order OAuth funnel, so the order is preserved
exactly and
each phase became a named private helper:
| New helper | What it holds |
| --- | --- |
| `handleAuthorizationError()` | the `error` query-param branch,
including the pending-connection cleanup |
| `failureRedirect()` | the two `isOnboarded()` ternaries picking the
failure destination |
| `createProviderSession()` | the `createSession` try/catch and its
state-token cleanup |
| `completeReconnect()` | the reconnect terminal branch |
| `completeFirstConnection()` | the first-time-connection terminal
branch |
What is left in `callback` is a linear chain of guards ending in one of
two named
completions. `failureRedirect()` also collapses four repeated
`(route, params, 'error', message)` argument lists into one call each.
## Complexity
| Method | Before | After |
| --- | --- | --- |
| `callback` | 16 | **8** |
| `handleAuthorizationError` | — | 5 |
| `createProviderSession` | — | 3 |
| `completeFirstConnection` | — | 2 |
| `failureRedirect` | — | 2 |
| `completeReconnect` | — | 1 |
8 rather than exactly 10 is deliberate: at 10 the next single added
branch puts
the file straight back over the line, which is the problem this PR
exists to fix.
## One deliberate detail
`createProviderSession()` returns `null` on failure, and the caller
checks
`=== null` rather than falsiness. A session payload that is merely empty
therefore keeps failing downstream exactly as it does today, instead of
becoming an error redirect it never was.
## Verification
- `php artisan crap --base=origin/main --no-coverage` — pass, nothing
above 10
- `php artisan test tests/Feature/OpenBanking` — 341 passed, 1150
assertions (identical to the run on `origin/main` before the change)
- `vendor/bin/pint --test` — pass
- `bun run dry` — pass, and clone counts are byte-identical to
`origin/main` (176 PHP clones, 2163 duplicated lines before and after),
so the split introduced no duplication
- `vendor/bin/phpstan` on the changed file — 0 errors
`findPendingConnectionForSession` in the same class is at 11 and
untouched here;
it is pre-existing and out of scope for this PR.
> The Sentry MCP token is expired, so this cycle worked from the
production database and `failed_jobs` instead. That turned out to
matter: a worker timeout never reaches a job's `try/catch`, so it can
corrupt state while producing **no Sentry issue at all**.
## The bug
`banking_connections.consecutive_sync_failures` is what keeps a
connection in the scheduled rotation. At `MAX_SCHEDULED_RETRIES` both
`SyncAllBankingConnectionsJob` and the `banking:sync` command filter it
out and **nothing ever dispatches it again**. Nobody is told: the bank
consent is still valid, so it never reaches the "reconnect your bank"
notice. The user's data just stops.
Two connections (2 users) are sitting there right now. One has never
completed a single sync since 2026-06-07.
## What I got wrong, and what the reviews found
I opened this branch believing job timeouts were stranding connections —
`TimeoutExceededException` is this job's most common failure by a wide
margin (66 in 14 days vs 37 `RequestException`). **Both reviews
falsified that independently, and they were right.**
`failed()` has an early return when the connection is already in
`Error`, so it could only ever charge **one** increment per connection
lifetime; a second out-of-band death is a no-op. Three slow cycles
cannot reach the ceiling that way. Prod is the natural experiment: **all
66 timeouts belong to one Wise connection, which sits at
`consecutive_sync_failures = 1`.**
What actually stranded the two rows was #757's pre-fix transient
counting, in the hours before it deployed on 2026-08-10. And the
population is 2, not the 4 I first measured — my raw SQL saw two
soft-deleted rows that `BankingConnection::query()` correctly excludes.
The commits and docblocks now say that. The code change stands on its
own smaller merit: an out-of-band death must not be charged to the
connection.
## The commits
1. **`failed()` no longer spends the retry budget.** Scope stated
honestly in the docblock. It closes exactly one route to the ceiling —
see (2).
2. **Reconnect hands back the full budget.** `AuthorizationController`
was the only one of four "try again" paths that didn't clear the counter
(compare `ConnectionController::sync`, `::update`,
`AccountMappingController`, and the job's success path). A user who
reconnected a connection parked at `MAX + 1` came back `Active` still
carrying the count that parked it, so the first failure re-parked it
immediately — none of the three attempts the ceiling grants, right after
paying an SCA redirect to escape that exact state. **Both reviews found
this while checking commit 1's premise; it is the most real bug here.**
3. **Repair migration, 2 rows.** Matched with `=`, not `>=`:
`handlePermanceError` parks auth failures at `MAX + 1` on purpose and
there are **8 such rows in prod**; a `>=` filter would un-park them, 401
on the next cycle and send each user a **second** "authentication
failed" email. `migrate --pretend` output is in the commit.
4. **Out-of-band deaths are recorded.** Every `logSyncAttempt` call
lived inside `handle()` — exactly what these deaths skip. One prod
connection has 66 job failures and 3 sync-log rows; that gap is why I
mis-attributed the cause. `duration_ms` goes null rather than a fake 0.
Copy fixed too: `failed()` said "An unexpected error occurred… please
try again later", handing our infrastructure to the user, while the
transient path already promised we'd retry.
5. **`uniqueFor` on the job.** `ShouldBeUnique` with no expiry means a
lock lost to a hard kill is never released, and `uniqueId()` is the
connection id — so that connection silently stops syncing for good.
Prevention; prod is clean.
6. **The log had to move above the status guard.** As first written, (4)
logged only when the connection was not already in `Error` — and the
connection it was written for is parked in `Error` and stays there.
Re-measured against prod: **68 failed jobs, 3 sync-log rows**, and every
one of the 65 missing deaths would have hit the guard and written
nothing. Logging now happens as soon as the connection row resolves; the
guards still own the status write, which must not clobber an earlier,
more specific error message.
## Verification
`tests/Feature/OpenBanking`: **346 tests, 346 pass** on a freshly
provisioned worktree (the 10 SSR failures reported earlier were a
local-env artifact, not the suite) (Inertia page-render tests hitting
the SSR `/render` endpoint, which has no local server — I ran the
baseline to confirm). 5 new tests; the two load-bearing ones fail with
the change reverted. `pint` and `dry` green.
One existing assertion changed rather than deleted: `failed sync job
marks active connection as error` asserted the increment. Its declared
subject — the status flip that unblocks onboarding — is untouched.
The migration's target set was re-verified against prod on 2026-08-12:
exactly **2 live rows** at `status=error, consecutive_sync_failures=3`,
and every row at `MAX + 1` is soft-deleted or revoked, so the `=` filter
touches precisely the two intended connections.
**The `crap` job will be red** on `AuthorizationController::callback`
(complexity 16). It is pre-existing: my diff there is one array entry
plus a comment, zero cyclomatic complexity added; `crap --base` only
surfaces it because I touched the file. I deliberately did not add a
`.crap-ignore.json` entry — that would paper over someone else's real
complexity problem. `crap` is not a required check.
## Why this is a draft
The migration writes to production data, and a review caught that a
slightly wider filter would have emailed 8 users a second
authentication-failure notice. That is exactly the class of mistake
worth a human glance. My impact story was also wrong twice this cycle
before the reviews corrected it.
Commits 1, 2, 4 and 5 I'd merge without hesitation — 2 in particular is
a clear standalone bug. Commit 3 is the one that touches prod rows.
## Follow-ups I deliberately did not do
- **The mechanism is now mostly bypassed.** 179 of 208 recent job
failures are exempt from the counter, so nothing bounds either dominant
failure mode and nothing tells the user. The right shape is probably a
backoff timestamp like the existing `rate_limited_until`, plus a "this
connection hasn't synced in N days" email — a design change, not a
patch.
- **`019fac9e`** (Wise, never synced in 14 days): 3 × 120s timeouts plus
3 worker SIGALRM kills per cycle, ~24 min/day of the single `default`
worker. `failOnTimeout = true` would cut that to one kill, but it also
removes two retries that might succeed for a merely slow bank. Its own
bug, its own trade-off.
- **Spanish users may get no Reconnect button.** `hasAuthError()` in
`settings/connections.tsx` matches the English substring
`'Authentication failed'` against a *translated* `error_message`. Needs
a machine-readable reason column to fix properly.
- **An `Error` connection whose consent lapsed is never dispatched**, so
it never reaches `markExpired()` and its user never gets the expiry
email (1 row in prod). Fixing it changes who receives outbound email, so
it wants its own PR.
## What
Once a month, email a CSV with the email address of every non-deleted
user to the owners.
- New `email:user-emails-report` command builds the CSV and sends
`UserEmailsReportEmail` with it attached as `text/csv`.
- Scheduled `monthlyOn(1, '09:05')` in `Europe/Madrid`, next to the
other `email:*` jobs.
- Recipients come from a comma-separated `REPORT_RECIPIENTS` env var.
The command fails loudly (exit 1, nothing sent) when it is unset, rather
than silently skipping.
The `SoftDeletes` global scope on `User` already excludes deleted users,
so no extra `whereNull` is needed.
## ⚠️ Required before this ships
Set `REPORT_RECIPIENTS` in the production environment, or the scheduled
command will fail every month:
```
REPORT_RECIPIENTS=first@example.com,second@example.com
```
Values are trimmed and empty entries dropped, so trailing commas and
spaces are safe.
## QA
No UI surface, so this was QA'd the way it is actually used: running the
command against the real local database (2520 users, 85 soft-deleted)
with mail captured by Mailhog.
| Check | Result |
| --- | --- |
| Command output | `Sent 2435 user email(s) as
user-emails-2026-08-12.csv.` (2520 − 85) |
| Message | 1 email, both recipients on a single `To` |
| Subject | `Monthly user emails export: 2435 users` |
| Attachment | one `text/csv` part,
`filename=user-emails-2026-08-12.csv`, 58 KB |
| CSV contents | `email` header + 2435 rows |
| Soft-deleted leakage | 0 overlap with the 85 soft-deleted addresses |
| Set equality | 0 rows in the CSV missing from the active set, 0 active
users missing from the CSV |
| Body | renders correctly in both the text and HTML parts |
| Schedule | `schedule:list` → `5 7 1 * *` (07:05 UTC = 09:05 CEST),
next due Sept 1 |
| Missing `REPORT_RECIPIENTS` | errors, exit 1, nothing sent |
| Recipient parsing | `" one@example.com , ,two@example.com,"` → two
clean recipients |
Tests: 2136 pass. `pint`, `phpstan`, `jscpd`, `crap`, `prettier` and
`eslint` all clean.
## Review notes
Two findings from review were raised rather than coded, since they are
product calls:
- **The export is not filtered by verification or consent.** It contains
every active address, including ~5.8% unverified ones. Literal "all
users", but those would bounce if the list is imported into a mail tool.
There is no marketing-consent flag anywhere in the schema, so nothing is
being ignored — just don't assume the list is filtered.
- **Privacy posture.** This puts the full user-email list into two
mailboxes every month, indefinitely, with no retention control. Worth a
conscious decision for an app positioned on not sharing user data.
`demo@whisper.money` is intentionally **not** excluded: the request was
every non-deleted user, and the existing exclusion precedent protects
the demo account from deletion, which is a different motive.
CSV formula injection (`=`, `+`, `-`, `@` local parts evaluating on
import into Sheets) was considered and skipped: zero such addresses
exist today and the only sensitive payload is the list itself, which the
recipients already own.
Ends the trial/pricing A/B/C experiment. Everyone gets the control offer
— a free trial — and the trial length becomes a per-plan setting.
## Trial length
| Plan | Before | Now | Env override |
|---|---|---|---|
| Yearly | 15 days | **15 days** | `STRIPE_PRO_YEARLY_TRIAL_DAYS` |
| Monthly | 15 days | **7 days** | `STRIPE_PRO_MONTHLY_TRIAL_DAYS` |
**Note that monthly 15 → 7 is a new bet, not a rollback.** The control
arm was 15 days on both plans, and 7 days on monthly is a value the
experiment never tested (`reduced_trial` was monthly 3 / yearly 7). The
rationale is that the longer commitment earns the longer trial; it ships
here at the same time as the instrument that could measure it is
removed, so it will not be measurable as an isolated effect.
## Final experiment numbers
Archived here because `stats:experiment-funnel` and its collector are
deleted by this PR and the purge migration's `down()` is a no-op.
| Variant | Assigned | Subscribed | Active | Refunded |
|---|---|---|---|---|
| control | 597 | 46 | 13 | 0 |
| reduced_trial | 590 | 50 | 10 | 0 |
| pay_now | 609 | 41 | 21 | 18 |
| legacy | 94 | 55 | 31 | 0 |
## What is deleted
- `App\Features\SubscriptionExperiment` (the Pennant A/B/C assignment)
and the `ExperimentOffer` service.
- The `pay_now` self-service refund: `RefundSelfServe`, the
`settings.billing.refund` route, the controller actions and Discord
embeds, the money-back card in billing settings, and the
`stripe:verify-refund` sandbox command.
- The weekly `stats:experiment-funnel` report, its collector, and the
`ProportionSignificance` / `BinomialProportion` helpers it was the only
caller of, plus its schedule entry.
- The `subscriptions.experiment.*` config block and the orphaned
`es`/`fr` translation strings.
- A data migration purges the ~1,890 stored Pennant assignments.
`subscriptions.refunded_at` is deliberately **kept**: nothing reads it
anymore, but it is the only record of the 18 refunds the experiment
issued. The migration carries a comment saying so.
## Fixes found in review
- **The surviving funnel report was mis-scoring conversions.**
`SubscriptionFunnelCollector` compared every cancellation to one global
trial length. With trials now diverging per plan, a monthly subscriber
who was billed and cancelled on day 10 was scored as never having paid.
It now reads each subscription's own `trial_ends_at`, and the longest
plan trial is used only for deciding when a cohort is old enough to
score. Covered by two new tests.
- **The trial length swapped silently.** It lived on a single line under
the plan selector, which rewrote itself when the user switched plan. Now
that the plans genuinely differ, each plan card shows its own length.
- The report legend no longer quotes a single trial length for both
plans, and warns that the experiment weeks are still inside its window.
## Before merging
- [x] **Unset `SUBSCRIPTION_EXPERIMENT_STARTED_AT` in production** so no
new `pay_now` assignment happens while this waits. Anyone who checks out
under `pay_now` between now and the deploy is charged upfront and then
loses the one-tap refund they were promised at the point of payment.
Checked just before opening this PR: **0 `pay_now` subscriptions
currently inside the 3-day window**, so nobody is stranded today.
- [x] Drop the now-orphaned `SUBSCRIPTION_EXPERIMENT_*` variables from
the production env with the deploy.
- [x] If old containers are still serving while the purge migration
runs, a few assignments can be re-resolved and reappear. Harmless —
re-run `php artisan pennant:purge "App\Features\SubscriptionExperiment"`
once the deploy settles if you want the table clean.
Support note: a manual Stripe refund for a former `pay_now` user will
not disconnect their bank connections, which the automated flow used to
do.
## Demo
https://github.com/user-attachments/assets/3614d488-05c6-405d-a687-bbf45746879a
<!-- PLACEHOLDER: drag the QA video here -->
## QA
Browser-tested against the running app:
- Paywall: annual card shows "15 days free", monthly card "7 days free";
the terms line under the selector follows the selected plan (15 ↔ 7);
mobile viewport renders fine.
- Billing settings: no money-back card for a free user or an active
subscriber; `POST /settings/billing/refund` returns 404.
- No console or network errors on any screen.
- `stats:subscription-funnel` still renders and posts.
- The purge migration leaves 0 `SubscriptionExperiment` rows.
Full suite green (2045 tests) apart from the known local-only
`DashboardTest` 409; `pint`, `lint`, `format` and `build` all clean.
## The issue
`AxiosError: Network Error` (PHP-LARAVEL-28) is the noisiest issue in
this project — 192 events / 75 users, `handled: no` — and it has been
archived as ambient connectivity noise several times, including by me.
The event distribution says otherwise:
| URL | events (last 24) |
|---|---|
| `/onboarding` | 15 |
| `/register` | 3 |
| `/dashboard`, `/accounts`, `/accounts/{id}`, `/settings/connections`,
`/` | 1 each |
Every one of those is a page that triggers a full-page navigation out of
the SPA, and the events skew heavily to Safari/macOS.
## The mechanism
Assigning `window.location` aborts every request still in flight.
Browsers report that abort to XHR through `onerror`, as a transport
failure rather than a cancellation — which is why it arrives as `Network
Error` and not `Request aborted`. Inertia rethrows it, so it lands as an
unhandled rejection.
The onboarding bank-connection step is the perfect generator: it polls
every 4s (`usePoll` in `pages/onboarding/index.tsx:124`) and *then*
sends the user to their bank with `window.location.href`
(`hooks/use-connect-flow.ts:219`). A poll dying mid-flight is close to
guaranteed.
So this is a bug report for a request that never failed. The point isn't
the volume — it's that until now a real "the user's connection dropped
and their action silently did nothing" was indistinguishable from our
own navigations.
## The change
`leavePage()` / `reloadPage()` record the departure; a `beforeSend`
predicate drops transport-level failures while that flag is set. It
follows the five sibling noise predicates already in `lib/sentry.ts`, so
Inertia's internals stay untouched. 13 call sites converted.
Commits are one-per-finding from the two reviews, and the two
interesting ones are corrections to my own first attempt:
- **`b7dc8dc6` — the first version was inert.** `HttpError`'s
constructor appends the request URL (`super(url ? \`${message}
(${url})\` : message)`), so Inertia's XHR client — the default in
v3.6.1, which we moved to in #769 — rejects with `Network error
(https://whisper.money/onboarding?step=create-account)`, not `Network
error`. My anchored regex matched neither. The 192 sampled events read
`AxiosError: Network Error` only because they were produced by **v2**,
which went through axios. Verified with a runtime probe against the
installed package, and the test row now carries the real string (it
fails against the previous pattern).
- **`09ea3b01` — the flag outlived the navigation.** I had it one-way on
the assumption it dies with the document. Two flows here keep the
document alive, both in the Safari/iOS population this issue skews to:
an **iOS PWA** hands the bank redirect to Safari and stays alive polling
(`pages/onboarding/index.tsx:121` documents this — it's why the poll
exists), and **bfcache** restores the heap when the user presses Back
from the bank or Stripe. Either way the user carries on in a live page
with reporting silenced for the rest of the session. Now cleared by a
persisted `pageshow` or by becoming visible again; neither can clear a
departure genuinely in progress, since a same-window redirect never
hides the page.
## Why `beforeSend` and not `router.on('networkError')`
Inertia does expose a cancellable event, and `preventDefault()` would
stop the rejection at the source. Don't — it also skips
`onPrefetchError`, which is where the cleanup lives:
```js
onPrefetchError(error) { prefetchedRequests.removeFromInFlight(params); reject(error) }
```
A stale `inFlightRequests` entry isn't cosmetic: `add()` early-returns
whenever `findInFlight` hits, and `get()` returns it with a promise that
never settles — so that URL becomes un-prefetchable for the session and
a `Link` consuming it hangs. We have `prefetch` on the sidebar and user
menu. It would also suppress genuine errors unconditionally, which is
the opposite of the goal.
## Deliberately not done
**Feedback on a real network failure.** When an Inertia visit genuinely
fails, the user still gets nothing — they click and nothing happens. A
toast needs to exclude background prefetch and poll requests; v3 does
make that possible (`visit.prefetch` / `visit.poll` on
`router.on('start')`), so this is a scope call, not an impossibility.
Worth its own PR.
**A bad bank `redirect_url`.** Fails at the browser level, so it was
never a JS event — we lose nothing here, but we also have no in-app
feedback and the only trace is a connection stranded in `Pending`.
Separate ticket.
## Verification
369/369 JS tests, lint, format, `dry` (4.81% vs 5.40% threshold),
`build` and `build:ssr` all green. Each behavioural test was confirmed
to **fail** with its fix reverted — including the stale-count one, which
reproduces the wrong number on screen.
SSR needed care: `config/inertia.php` has it enabled and `ssr.tsx` globs
pages that import this module, so the listeners sit behind `typeof
window` and I verified the module imports cleanly in a node environment.
## Why this is a draft
Two things I'd rather you weighed:
1. **Suppressing errors fails silently.** If the gate is ever wrong we
go blind to a class of real failures, and no error tells us we stopped
getting errors — the same shape as the observability gap noted on #723.
The risk is bounded by the resets and the narrow message pattern, but
the direction of failure is under-reporting.
2. **My confidence here was already miscalibrated once.** The first
version passed every local check and one reviewer's independent
verification, and was still completely inert for the target traffic.
That argues for a human look rather than auto-merge.
The parts I'd merge without hesitation are `6a7059d4` (the onboarding
wrong-count fix, independent of all this) and `b7dc8dc6`. If you want
the observability change split from the onboarding one, say so and I'll
separate them.
Prod verification after deploy: PHP-LARAVEL-28 should stop accruing new
events on `/onboarding`, while `Network Error` events from users who
*stayed* on a page should keep arriving. If both go quiet, the gate is
too wide.
## 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()`.
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
A public `/roadmap` page in the landing's own style, listing the UserJot
board so visitors can see what is being built, and links to it from the
landing header (before GitHub and Discord) and footer.
<!-- Drop pr-roadmap-light.png and pr-roadmap-dark.png here -->
## How
UserJot has no public API, so the page reads the same tRPC endpoints its
own frontend calls (`x1.roadmap.getPage`, `x1.submission.getPage`, both
keyed by an `x-host` header) and caches the whole result for a day.
- **Dates.** The roadmap listing only knows when a submission was
*opened*, which for shipped work is months before it moved — nearly
every item would read "Jan 2026". The date a submission last changed
status lives on its detail endpoint, so those are fetched concurrently
with `Http::pool` and fall back to the creation date when a submission
has never moved.
- **Order.** Planned → in progress → shipped, and within a status the
most recently moved item first.
- **Bodies.** Submissions are written in Markdown by whoever opened
them. Rather than pull in a renderer for text we only show three lines
of, the emphasis markers are stripped and the body is collapsed into a
single paragraph, clamped with `line-clamp-3`. Each title links to its
ticket (new tab), so the full text is one click away.
- **Failure.** An unreachable UserJot renders the empty state with a
link to the board, not a 500, and the failure is not cached.
The page is also in `sitemap.xml`.
## Notes
- The mobile pill header hides its GitHub and Discord buttons (`hidden
sm:flex` inside an `sm:hidden` container), so a link there would never
be visible. The footer link is how mobile visitors reach the page; its
row got `flex-wrap` since it now holds five items.
- No pagination: there are 20 submissions and the request limit is 50.
## Testing
<img width="1280" height="1100" alt="pr-roadmap-dark"
src="https://github.com/user-attachments/assets/db07cb16-7e0f-4de8-960b-464bf0419d59"
/>
<img width="1280" height="1100" alt="pr-roadmap-light"
src="https://github.com/user-attachments/assets/a107e5bb-2deb-4f37-b511-7c188552c1e1"
/>
- `tests/Feature/RoadmapTest.php` covers status grouping, the
status-change date and its fallback, the ordering, the one-fetch-per-day
cache, and the unreachable-UserJot empty state.
- QA'd in Chrome: desktop light and dark, and mobile.
- `pint`, `prettier`, `eslint`, `jscpd` and `crap` all clean.
## Why
Landing on `/` while signed in mounted a dialog that counted down from 3
seconds
and then navigated to the dashboard. It had no close button, no
`onOpenChange`
and no overlay dismissal, so the only way to read the public page was to
catch
the `Cancel` button in time — an involuntary navigation and a keyboard
trap.
`/` is now a normal public page: it behaves like `/privacy` and
`/terms`, which
never mounted the dialog.
## What changed
- Deleted `authenticated-redirect-dialog.tsx` and its vitest file, its
usage in
`welcome.tsx`, and the `auth` prop read that had no other consumer.
- Pruned the 4 orphaned translation keys from `lang/es.json` and
`lang/fr.json`
(`Redirect progress`, `Redirecting to your dashboard`,
`You are being redirected in 3 seconds.`, `Go now`). `lang/en.json`
never had
them, and `Redirecting...` was kept — the onboarding step still uses it.
- Pointed the in-page CTAs at the dashboard for a signed-in visitor,
labelled
`Go to Dashboard`, and hid the demo link. `/register` and `/login` sit
behind
Fortify's guest middleware, so those CTAs were the remaining trampoline:
clicking `Get Started Free` bounced you to the dashboard under copy
promising
a signup, and `Check Demo` (`/login?demo=1`) silently did nothing
because the
demo credentials are prefilled on a page a signed-in user never reaches.
This
follows the pattern already in `components/partials/header.tsx`.
- Added `tests/Browser/LandingPageTest.php`. The redirect was
client-side, so a
Feature test asserting `GET /` renders `welcome` would have passed
before this
change too — the browser test waits past the old 3s timer and asserts
the path
is still `/`.
## Deliberately out of scope
- **The PWA still redirects.** `welcome.tsx` sends anyone in standalone
display-mode to `/dashboard`, guests included. That one is auth-agnostic
and
serves a different purpose — the installed app should not open on the
marketing page, and `site.webmanifest` pins `start_url` to `/dashboard`.
Left
as is.
- **The error boundary still targets `dashboard()`, not `/`.** The
comment
explaining "not `/`" is gone because its premise was this dialog, but
the
target is unchanged: `/` is the marketing page, the dashboard is the app
entry
point. Note the PWA trampoline above before anyone "simplifies" it to
`/`.
- **Plan-card CTAs for a signed-in visitor without a subscription** land
on
`/dashboard` and are then bounced to the paywall by the `subscribed`
middleware — same destination as before, one hop fewer. Routing them
straight
to `/subscribe` with an upsell source would be a separate change.
## QA
Real browser run against the dev server, signed in as a verified,
onboarded,
subscribed user — 14/14 checks:
- Guest: hero and plan CTAs still point at `/register`, `Get Started`
copy
intact, `Check Demo` present, no `Go to Dashboard` in the body.
- Signed in: no dialog appears, still on `/` after ~8 seconds, page
content
readable with no modal overlay.
- Signed in: every body CTA points at `/dashboard` and reads `Go to
Dashboard`,
no `/register` or `/login` link left in the body, `Check Demo` hidden.
- Clicking a CTA lands on the dashboard. No uncaught JS errors.
`tests/Browser/LandingPageTest.php` passes locally (1 test, 3
assertions).
`pint --test`, `format:check`, `lint`, `dry` and `crap
--base=origin/main` all
pass.
## Demo
<!-- PLACEHOLDER: drag the QA video here -->
## Why
`Settings\AccountController` held two of the remaining complexity
offenders — `store` at **16** and `update` at **11** — and the two
clones jscpd reports inside the file:
```
AccountController.php [75:9 - 83:47] ↔ [201:9 - 209:47] (the nine real-estate fields)
AccountController.php [115:9 - 125:35] ↔ [217:9 - 227:35] (the three loan fields)
```
Both actions were doing four jobs at once: map the request onto columns,
create or update the type-specific detail row, backfill balance history,
and answer.
## What changed
**The shared mapping** — `accountAttributes()`, `realEstateAttributes()`
and `loanAttributes()`, used by both actions. That is both clones gone.
**`store`'s type-specific work** moves out:
| new method | job |
|---|---|
| `createRealEstateDetail()` | the detail row + its balance history |
| `createLoanDetail()` | the detail row + its balance history |
| `linkToRealEstateAccount()` | points the property back at its new
mortgage |
| `backfillHistoricalBalances()` | "last twelve months now, older on the
queue" — both paths ended in this same dance |
**`update`'s loan branch** (`syncLoanDetail()`) returns the missing
fields instead of `return to_route(...)->withErrors(...)` from inside a
nested `else`, so the redirect decision stays in the action.
## Metrics
| | before | after |
|---|---|---|
| methods over complexity 10 | 30 | 28 |
| `store` | 16 | 5 |
| `update` | 11 | 4 |
| clones in this file | 2 | 0 |
## Testing
`AccountControllerTest`, `LoanTest`, `RealEstateTest`,
`RealEstateAvailabilityTest`, `AccountBalanceControllerTest`,
`AccountUserCurrencyServiceTest` — 148 tests green. PHPStan clean.
Three of the branches I moved had no coverage, so this adds it:
- creating a loan that started four years ago **queues**
`GenerateHistoricalLoanBalancesJob` and still writes the recent balances
inline (so the chart is not empty while the queue catches up)
- one that started two months ago queues **nothing** — the `isBefore`
condition that is the whole point of `backfillHistoricalBalances`
- adding loan details with only one of the three required fields comes
back with errors on the other two and writes no row. Worth noting these
errors come from the controller, not the request: `loanDetailRules()`
marks all three `nullable`.
The mortgage back-link was already covered by `LoanTest`.
## Why
#770 extracted `SettingsTable` for the categories and labels pages and
noted that `accounts.tsx` and `automation-rules.tsx` repeat the same
markup. This is that follow-up.
Two clusters remained across the four settings pages:
1. **The table markup** — 30 lines of header groups, body, and empty
row, still copied in `accounts` and `automation-rules`.
2. **The table setup** — all four declared the same four pieces of state
and the same `useReactTable` call with the same six options, differing
only in `data` and (for labels) the initial sort.
## What changed
**`useSettingsTable(data, columns, initialSorting?)`** — a new hook
owning the sorting / filtering / column-visibility state and the
tanstack setup. All four pages use it, so the setup exists once:
```tsx
const table = useSettingsTable(accounts, columns);
const table = useSettingsTable(labels, columns, [{ id: 'name', desc: false }]);
```
**`SettingsTable`** now renders `accounts` and `automation-rules` too,
each passing its own row component through `renderRow`.
## Behaviour
One visual change: `SettingsTable`'s container goes from
`overflow-hidden` to `overflow-x-auto` — which is what `accounts.tsx`
already used, being the widest of the four tables. Keeping
`overflow-hidden` would have clipped it on narrow screens; the other
three pages only notice when their table outgrows the container, and
scrolling is the better answer there than cutting content off.
Everything else is a move: same columns, same rows, same empty messages,
same client-side sorting and filtering.
## Metrics
Measured against `main` (which already has #766–#770):
| | before | after |
|---|---|---|
| duplicated lines (total) | 4.83% | 4.71% |
| duplicated lines (tsx) | 5.03% | 4.82% |
| clones | 302 | 297 |
Since the start of this series, total duplication has gone 5.34% →
4.71%.
## Testing
ESLint and `tsc --noEmit` clean. These pages are covered by the browser
suite (`AccountsPageTest`, `AutomationRuleBuilderTest`,
`CategoriesTest`), which needs a production asset build I don't run
locally — so CI's `browser-tests-matrix` is the verification, as in
#770.
## 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 -->
Follow-up to the Inertia v3 upgrade (#769). While auditing whether the
frontend used Wayfinder's current API, I found **32 HTTP call sites
across 16 files that built their URL by hand** — every one of which
already had a generated Wayfinder action sitting unused.
## Why this matters
Wayfinder exists so a route rename fails at **compile** time. A
hand-written `'/api/transactions/bulk'` fails at **runtime**, in
production, on a path a unit test can't catch because the tests mock
`axios`. The worst offender was `services/transaction-sync.ts` — the
offline sync service — which had five of them.
I verified each URL against `php artisan route:list` before changing it;
there were no missing routes, only unused generated ones.
## What changed
| File | Sites | Now uses |
| --- | --- | --- |
| `services/transaction-sync.ts` | 6 | `TransactionController` +
`Sync/TransactionSyncController` |
| `components/transactions/saved-filters.tsx` | 4 |
`Api/SavedFilterController` |
| `hooks/use-cashflow-data.ts` | 5 | `Api/CashflowAnalyticsController` |
| `components/transactions/import-transactions-drawer.tsx` | 3 |
`TransactionController@categorize` |
| `components/accounts/account-balance-chart.tsx` | 2 |
`Api/DashboardAnalyticsController` |
| `hooks/use-decrypt-account-names.ts` | 2 | `Api/AccountController` |
| `components/dashboard/net-worth-chart.tsx` | 2 | the two net-worth
preference controllers |
| `pages/transactions/index.tsx` | 2 |
`TransactionController@bulkUpdate` |
| `lib/import-config-storage.ts` | 2 |
`Api/AccountImportConfigController` |
| `app.tsx`, `use-decrypt-transactions.ts`,
`encryption-key-context.tsx`, `import-step-preview.tsx`,
`import-transactions-button.tsx`, `category-analysis-drawer.tsx`,
`settings/appearance.tsx` | 1 each | respective controllers |
## Query strings got simpler
The endpoints with parameters were assembling `URLSearchParams` by hand.
The generated `.url({ query })` helper does it, so that scaffolding is
gone:
```diff
-const periodParams = new URLSearchParams({ from: fromStr, to: toStr });
-const periodQuery = `?${periodParams.toString()}`;
-fetch(`/api/cashflow/breakdown${periodQuery}&type=income`)
+const periodQuery = { from: fromStr, to: toStr };
+fetch(cashflowBreakdown.url({ query: { ...periodQuery, type: 'income' } }))
```
`lib/import-config-storage.ts` also loses its local `configUrl()`
helper, which only existed to interpolate an account id.
## Two aliases, on purpose
`transaction-sync.ts` and `import-transactions-button.tsx` import with
`as` aliases because the plain names collide with a method (`update`,
`store`, `destroy`) and a `useState` variable (`importData`) already in
those files. Named imports are kept everywhere so tree-shaking still
works.
## Verification
- `bun run test` — **356/356, with zero test changes.** That is the
useful signal here: `transaction-sync.test.ts` asserts `axios.delete`
was called with the literal `'/transactions/txn-1'`, and it still
passes, so the generated URLs are byte-identical to the strings they
replaced.
- `bun run types` — no new errors. (`transaction-sync.ts:45` and the
`.test.tsx` matcher errors are pre-existing; the former just shifted
line number as imports were added.)
- `bun run build`, `bun run lint`, `bun run format`, `bun run dry` —
green.
### Browser check
Tests mock `axios`, so a wrong URL would still pass them. I exercised
the rewritten endpoints in a real browser and captured the actual
network traffic — all **200**, query strings identical to what the
manual code produced:
```
200 /api/cashflow/summary?from=2026-08-01&to=2026-08-31
200 /api/cashflow/trend?months=12&to=2026-08-31
200 /api/cashflow/breakdown?from=2026-08-01&to=2026-08-31&type=income
200 /api/cashflow/breakdown?from=2026-08-01&to=2026-08-31&type=expense
200 /api/dashboard/account/{id}/balance-evolution?from=2025-08-11&to=2026-08-11
200 /api/saved-filters
```
0 failed requests, 0 console errors across cashflow, transactions,
accounts, account detail and appearance.
## Out of scope
11 hardcoded **navigation** URLs remain (`href="/register"`,
`href="/privacy"`, `router.visit('/dashboard')`), mostly on the
marketing pages. They are static routes with a much lower rename risk,
so I left them for a separate pass rather than widen this diff.
## Why
`TransactionController::bulkUpdate` sat at cyclomatic complexity **18**,
and the cause was a duplicated query. "Which transactions does this
apply to?" — explicit ids, else the active filters, else everything —
was written twice: once to load the rows, once to run the mass update.
```php
// to load them
if ($transactionIds && count($transactionIds) > 0) { $query->whereIn('id', $transactionIds); … }
elseif ($filters !== null) { $query->applyFilters($filters); … }
else { … }
// …and again, 40 lines later, to update them
$updateQuery = Transaction::query()->where('user_id', $user->id);
if ($transactionIds && count($transactionIds) > 0) { $updateQuery->whereIn('id', $transactionIds); }
elseif ($filters !== null) { $updateQuery->applyFilters($filters); }
```
Two copies of the selection rule is exactly the kind of thing that
drifts: change one and the update touches a different set of rows than
the check validated.
## What changed
| new method | job |
|---|---|
| `bulkSelection()` | the selection rule, in one place, called for both
the read and the update |
| `bulkUpdateAttributes()` | the columns to write; records the category
override first, while the rows still hold the old value |
| `syncLabels()` | replaces the label relations row by row |
`bulkUpdate` itself is now the flow: select, authorize, collect, update,
respond.
## Behaviour
No change. One thing the refactor surfaced: the old code guarded with
`$transactionIds && count($transactionIds) > 0`, treating an empty array
as "no selection". That branch is unreachable —
`BulkUpdateTransactionsRequest` validates `transaction_ids` as
`['array', 'min:1']`, so an empty array is a 422 before the controller
runs. The guard is now a plain `!== null` and a comment names the rule
it leans on.
## Metrics
| | before | after |
|---|---|---|
| `bulkUpdate` | 18 | 8 |
| `bulkSelection` / `bulkUpdateAttributes` / `syncLabels` (new) | — | 3
/ 6 / 2 |
## Testing
`BulkUpdateTransactionsTest`, `TransactionTest`, `TransactionFilterTest`
— 92 tests green, plus one new. The existing suite covers every
selection path (by ids, by date/amount/category filters, everything when
neither is given) and the 403 for ids the caller does not own, which is
the check most at risk from touching the selection.
The new test pins the empty-array case: the request rules reject it with
422 rather than letting it through as an empty selection, which is what
makes the simplified guard safe. PHPStan clean.
## Why
`HandleInertiaRequests::share` was at cyclomatic complexity **18** — the
highest left after #771. Almost all of it was one shape repeated seven
times:
```php
'bankingConnections' => fn () => $user ? $user->bankingConnections()->get(...)->map(...) : [],
'accounts' => fn () => $user ? $user->accounts()->...->get() : [],
'categories' => fn () => $user ? $user->categories()->forDisplay()->get() : [],
// ...four more
```
Each of those ternaries is a branch, and the guest answer is the same
every time.
## What changed
**`userCollectionProps()`** owns the seven deferred props. The guest
case is answered once, up front:
```php
if ($user === null) {
return array_fill_keys(['expiredBankingConnections', 'bankingConnections', /* ... */], fn () => []);
}
```
…so the seven queries below read as queries rather than as ternaries.
`automationRules` was the odd one out (a full closure with an early
`return []`) and now matches its siblings.
**Three conditions got names** instead of living inline in the props
array:
| method | was |
|---|---|
| `isDemoAccount()` | `$user?->isDemoAccount() && !
app()->environment('local')` |
| `demoCredentials()` | `config(...) && ($isDemoQuery \|\|
$isDemoAccount) ? [...] : null` |
| `hasResidualEncryptionArtifacts()` | the four-way `&&` chain gating
the cleanup job |
The comments now say *why* each one is what it is — that a demo account
is only treated as one outside local, and that the cleanup job fires
when the salt outlived the encrypted rows.
## Behaviour
One deliberate change: `expiredBankingConnections` and
`bankingConnections` end in `->all()`, returning a plain list instead of
a `Collection`. The JSON is identical (a Collection of arrays serializes
to the same array), and it keeps the prop's type expressible outside
`share()` — `Collection`'s `TValue` is invariant, so phpstan cannot
match `Closure(): Collection<int, array{…}>` against itself once the
array literal lives in its own method.
Everything else is a move.
## Metrics
| | before | after |
|---|---|---|
| `share` | 18 | 4 |
| `userCollectionProps` (new) | — | 2 |
| `demoCredentials` / `hasResidualEncryptionArtifacts` / `isDemoAccount`
(new) | — | 3 / 4 / 2 |
## Testing
This middleware runs on every page, so: **the whole Feature suite — 1958
tests, 1957 passed, 1 skipped.** PHPStan clean.
`InertiaSharedDataTest` covers exactly what moved: the guest path, the
authenticated path, both encryption-cleanup branches (queued when the
salt is residual, not queued when there is no salt), the
expired-connection reconnect links and the connections prop — the two
props whose return type changed.
## Why
`Transaction::scopeApplyFilters` was the highest-complexity method left
in the report at **19** (after the trial-experiment code #762 deletes,
and the two already handled in #766 / #767): eighteen `if`s in one body,
thirteen of which are a single comparison each.
## What changed
**The one-comparison filters use `when()`.** Dates, amounts, account
ids, category source, creditor/debtor name and free-text search are now
a declarative chain, so the scope reads as the list of filters it is
instead of eighteen mutations of `$query`.
**The branching moved out.** The only part with real logic is the
category/label pair, now `applyCategoryAndLabelFilters()`, with the
subtree widening in `expandToDescendants()`. Naming them also documents
two things that were previously implicit: that `uncategorized` is a
pseudo id the UI sends for "no category at all", and that picking a
category picks everything under it.
## Behaviour
One structural change worth reviewing: the where-group is flat now. The
old code wrapped the category conditions in their own nested group and
ORed the label condition outside it — `((category IN (...) OR category
IS NULL) OR EXISTS labels)`. All three branches are ORed, so `(category
IN (...) OR category IS NULL OR EXISTS labels)` matches the same rows;
only the parentheses in the generated SQL differ.
Everything else is a straight move.
## Metrics
| | before | after |
|---|---|---|
| methods over complexity 10 | 35 | 34 |
| `scopeApplyFilters` | 19 | 1 |
| `applyCategoryAndLabelFilters` (new) | — | 8 |
| `expandToDescendants` (new) | — | 3 |
Part of that drop is the `when()` chain: the conditions still exist,
they just moved into Laravel's own conditional helper rather than being
counted as branches. The branching that actually needed taming is the
piece that now has its own name and sits at 8.
## Testing
`TransactionFilterTest`, `TransactionTest`, `SavedFilterTest`,
`BulkUpdateTransactionsTest` — 110 tests, all green. The filter
combinations that the flattened group could have broken are covered:
filter by category, by a parent (descendants), by `uncategorized`, by
multiple categories including `uncategorized`, by label, and the
explicit `category and label filters combine with OR`. PHPStan clean.
## Why
`settings/categories.tsx` and `settings/labels.tsx` were the last big
duplication cluster after the landing page (#768): 6 clone pairs between
and within them, ~100 duplicated lines.
They are the same page with a different record type:
- the trailing `...` dropdown with Edit / Delete
- the right-click context menu on each row, with the same two items
- the pair of dialog mounts wired to two `useState` flags — written
**twice per page**, once for the dropdown and once for the context menu
- 39 lines of table markup (header groups, body, empty row) differing
only in the row component and the empty message
## What changed
Three shared pieces:
| component | replaces |
|---|---|
| `RowActionsDropdown` | the `...` cell + its two dialogs, in both pages
|
| `RowWithActionsContextMenu` | the row + context menu + its two
dialogs, in both pages |
| `SettingsTable` | the table markup, in both pages |
The dialogs come in as render props (`renderEditDialog` /
`renderDeleteDialog`), so each page keeps its own dialog components and
their extra props — `categories` needs the full category list, labels
needs nothing — while the menus own the open state.
`categories.tsx` −86 lines, `labels.tsx` −100 lines.
### Why not the existing DataTable
`components/ui/data-table.tsx` already renders a table, but it is
**virtualized**: its `renderRow` hands back a `VirtualItem` and the
virtualizer, and rows are expected to attach `measureElement`. These
settings lists are short and their rows are wrapped in a
`ContextMenuTrigger asChild`, so adopting it would mean threading
measurement through the context menu to gain nothing. `SettingsTable` is
the plain version, and `accounts.tsx` / `automation-rules.tsx` (which
repeat the same markup) can move onto it next.
## Metrics
Cumulative with the other refactor PRs in flight; measured against
`main`:
| | before | after |
|---|---|---|
| duplicated lines (tsx) | 5.82% | 5.40% |
| duplicated lines (total) | 5.34% | 5.11% |
| clones | 322 | 314 |
## Testing
ESLint and `tsc --noEmit` clean. No behaviour change: same markup, same
handlers, same dialogs — only their location moved.
The browser suite is what actually exercises these pages
(`CategoriesTest` covers viewing, creating, filtering, the empty state
and cell alignment). I could not run it locally without a production
asset build, so it is verified here by CI's `browser-tests-matrix`.
## Why
We just landed the CRAP and DRY checks. `DashboardAnalyticsController`
was the worst offender after the trial-experiment code (which #762
deletes anyway):
- `accountBalanceEvolution` — cyclomatic complexity **30**
- `accountDailyBalanceEvolution` — cyclomatic complexity **12**
And the two were near-identical copies of each other, so it was a
duplication problem wearing a complexity costume.
## What changed
The monthly and daily series shared everything except the dates they
iterate and the projections the monthly one appends: authorization,
range validation, per-point assembly (balance, invested amount, linked
mortgage, display-currency equivalents) and the response envelope. Those
are now shared methods:
| new method | what it owns |
|---|---|
| `authorizedDateRange` | 403 guard + `from`/`to` validation + parsing |
| `displayCurrencyFor` | the user currency, or null when it matches the
account |
| `balanceLookupFor` | the `BalanceLookup` over the account and its
linked loan |
| `balancePointAt` | one point: value, invested amount, mortgage,
display fields |
| `projectedPoints` → `loanProjection` / `realEstateProjection` /
`projectedPoint` | the future months |
| `evolutionResponse` | the `data` + `account` + `display_currency_code`
envelope |
Also collapsed the three byte-identical "sum transactions joined to a
category of type X" queries in `calculateSpending`/`calculateCashFlow`
behind `sumByCategoryType`.
## Behaviour
No change. One thing worth naming: the daily series used to read the
balance at **start** of day and the invested amount at **end** of day
(`endOfDay()` mutates the Carbon instance mid-method). It now reads both
at end of day — same result, because `BalanceLookup` compares
`toDateString()`, i.e. by day.
## Metrics
| | before | after |
|---|---|---|
| methods over complexity 10 | 35 | 33 |
| `accountBalanceEvolution` | 30 | 4 |
| `accountDailyBalanceEvolution` | 12 | 4 |
| duplicated lines (php) | 5.92% | 5.68% |
| duplicated lines (total) | 5.34% | 5.25% |
## Testing
`DashboardAnalyticsTest` (41), `LoanTest` + `AccountControllerTest` (68)
— all green, unchanged. Between them they cover both projection paths,
both currency-conversion paths and the mortgage overlay, so the refactor
is verified by the existing suite; no new tests were needed.
## Noticed, not changed
`display_invested_amount` converts **from** the user currency **to** the
account currency, while every sibling field converts the other way.
Looks like an inverted argument pair, but fixing it changes numbers on
the invested-amount chart, so it stays out of a no-behaviour-change PR.
Worth a follow-up.
## Why
Second pass at the complexity report. After the trial-experiment code
(deleted by #762) and the analytics controller (#766), the worst
offenders were the MCP write tools:
- `UpdateTransaction::write` — cyclomatic complexity **20**
- `UpdateAutomationRule::write` — cyclomatic complexity **13**
Both for the same reason: a long wall of `if ($request->has('x')) {
$model->x = ...; }` blocks, one per optional field.
## What changed
Two shapes moved into `WriteTool`:
**`modelInSpace()`** — resolving a record in the space was written five
times (account, transaction, category, label, plus a rule resolver that
existed twice, once in `UpdateAutomationRule` and once in
`DeleteAutomationRule`), each a copy of the same query + null check +
message. The four public helpers are now one-liners over it, and
`ruleInSpace()` is shared instead of duplicated.
**`applyFields()`** — assigns only the fields the request actually
carries, which is the "only what you pass changes" contract these tools
document. Values are closures, so resolving a related model
(`accountInSpace`, `categoryInSpace` — either can throw a validation
error) still happens only when its field is present.
## Metrics
| | before | after |
|---|---|---|
| methods over complexity 10 | 35 | 33 |
| `UpdateTransaction::write` | 20 | 7 |
| `UpdateAutomationRule::write` | 13 | 6 |
| duplicated lines | 5.34% | 5.34% |
Duplication does not move, and it's worth being straight about why: what
jscpd still flags across `app/Mcp/Tools` is **file headers** — the `use`
block, the `#[Description]` attribute, the class line and the opening of
`schema()`. Nine near-identical lines per tool that no extraction can
remove. Deleting the duplicated `ruleInSpace` shortened
`DeleteAutomationRule` enough that its header became a clone pair with
another tool's, so the counter stayed flat while the real duplication
went away.
## Testing
`tests/Feature/Mcp` — 46 tests green, plus a new one. Nothing asserted
the resolver failure messages before, and this PR changes how they are
built (a shared template plus an optional hint), so the new test pins
both branches: the message that points at a listing tool (`Call
search_transactions to find ids.`) and the one that has no hint to
append.
## Why
`welcome.tsx` was the single biggest source of duplication in the repo:
**8 clone pairs, ~200 duplicated lines**, all of them the same thing
copied nine times.
Every scroll-animated preview on the landing page carried its own copy
of:
```tsx
const [scrollProgress, setScrollProgress] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const updateProgress = () => { /* measure, clamp 0..1, setState */ };
updateProgress();
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
return () => { /* remove both */ };
}, []);
```
Two of them also duplicated a second effect that measures how far a list
can travel before its last row clears the container.
## What changed
New `resources/js/hooks/use-scroll-progress.ts`:
- **`useScrollProgress()`** — returns `{ ref, progress }`, where
progress goes 0 → 1 as the element crosses the viewport. Nine call
sites.
- **`useScrollTranslate({ speed, padding })`** — the self-scrolling
lists (banks, transactions): adds the travel measurement and returns
`translateY` derived from progress instead of keeping it in a second
piece of state. Two call sites.
`welcome.tsx` goes from 2912 to 2647 lines. No visual or behavioural
change intended: same listeners, same passive flag, same clamping, same
values.
## Metrics
| | before | after |
|---|---|---|
| duplicated lines (tsx) | 5.82% | 5.45% |
| duplicated lines (total) | 5.34% | 5.13% |
| clones | 322 | 313 |
| clones in `welcome.tsx` | 8 | 0 |
## Testing
**Unit** — `use-scroll-progress.test.ts` pins the clamped progress at
three positions (below the fold → 0, passed above → 1, mid-viewport →
the expected ratio). Nothing covered this before.
**Browser** — loaded the landing locally and scrolled every preview into
view, asserting each one's inline styles actually change between "just
entering" and "in view". All nine still animate: the eight that move
`transform` / `opacity` / `top`, plus the cashflow chart, which animates
bar `height`. No JS console errors (only external gravatar/placeholder
404s, pre-existing).
Upgrades Inertia.js from v2 to v3 on both sides — the major deliberately
left out of #764.
| Package | From | To |
| --- | --- | --- |
| `inertiajs/inertia-laravel` | 2.0.24 | **3.3.1** |
| `@inertiajs/react` | 2.3.17 | **3.6.1** |
| `axios` | transitive | **direct dep** (1.19.0) |
## Why axios becomes a direct dependency
The app imports `axios` in 18 runtime files, but it was never declared —
it arrived transitively through `@inertiajs/core` v2. v3 drops axios in
favour of its own XHR client, so without this the whole app would fail
to resolve the import.
Nothing relied on Inertia configuring axios: v2 never touched
`axios.defaults`, and there are no interceptors. `^1.19.0` also
satisfies core's optional peer range (`^1.15.2`).
## Breaking changes swept
Every v2→v3 breaking change from the [official upgrade
guide](https://inertiajs.com/upgrade-guide) was checked against the
codebase:
| Change | Call sites |
| --- | --- |
| `<title inertia>` → `<title data-inertia>` | fixed (`app.blade.php`) |
| `testing.page_paths/page_extensions` → `pages.paths/extensions` |
fixed (`config/inertia.php`) |
| `ComponentResolver` no longer accepts `Promise<{default}>` | fixed
(`app.tsx`, `ssr.tsx`) |
| `router.on('invalid'/'exception')` → `httpException`/`networkError` |
none — only `'navigate'` is used |
| `router.cancel()` → `cancelAll()` | none |
| `Inertia::lazy()` → `optional()` | none |
| `future` options block | none |
| `hideProgress`/`revealProgress` | none |
| arrow-fn `.layout =` → `[Layout]` | none — no page sets `.layout` |
| deprecated testing traits (`Has`/`Matching`/`Debugging`) | none |
| `qs` / `lodash-es` imports | none |
React 19.2.4, Laravel 13 and PHP 8.4 already satisfy v3's raised floors.
## DevTools kept opt-in
v3 adds a DevTools recorder that writes every request's page props to
`storage/inertia-devtools/` and **defaults to on in local**. On this app
that produced 38 dumps, the largest ~1 MB, holding whole transaction and
balance payloads — redaction only covers credential-shaped keys. It is
gitignored by the package, so it never reaches the repo, but for a
privacy-first product it shouldn't be the default. Gated behind
`INERTIA_DEVTOOLS_ENABLED` (set it to `true` in your own `.env` when you
need it).
## Deliberate behaviour change: `<Deferred>` on partial reloads
v3 no longer resets `<Deferred>` to its `fallback` during a partial
reload — existing content stays visible while fresh data loads. This
affects the dashboard (`onBalanceUpdated`) and account detail
(`handleTransactionCreated`).
**Kept as-is, on purpose.** Two reasons:
1. `dashboard.tsx:131-133` already carried a workaround for exactly this
v2 behaviour — it requests an unrelated cheap prop purely so the
deferred prop isn't refetched into a skeleton. The team already treated
the skeleton flash as a problem and v3 fixes it upstream, so restoring
it via the new `reloading` slot prop would undo an improvement. That
workaround is now redundant and can be simplified in a follow-up.
2. The "user can't tell it worked and re-submits" risk doesn't apply:
`edit-transaction-dialog.tsx:390` fires `toast.success('Transaction
created successfully')` *before* the reload, so the confirmation is
explicit and independent of the list.
## Verification
Full CI set, locally:
- `bun run build` — green
- `bun run test` — **353/353**
- `bun run types` — zero new errors (advisory step; pre-existing backlog
unchanged)
- `bun run format`, `bun run lint`, `bun run dry` — green
- `vendor/bin/pint --test`, `vendor/bin/phpstan analyse` — green
- `php artisan crap --base=origin/main` — 0 methods above complexity 10
- `pest --exclude-testsuite=Browser,Performance` — **2086/2088**
- `pest --testsuite=Browser` — **132/133**
Both test failures are pre-existing and reproduce identically on `main`
with Inertia v2 — I ran each one there to confirm:
- `DashboardTest::dashboard top categories roll child spending up into
the parent` — 409, the known local Inertia asset-version artifact
- `BudgetsFeatureNavigationTest::user cannot access another users
budget` — `Unknown column 'category_id'` on `budgets`, a factory/schema
mismatch
## Browser QA
Real browser sweep against the production build over dashboard,
transactions, accounts, budgets, settings and billing: **0 console
errors, 0 uncaught exceptions, 0 failed requests**. Page titles apply,
deferred props resolve, sidebar `<Link>` navigation stays client-side
with no document reloads, back/forward restores from Inertia history,
and a profile form submit persisted correctly.
## SSR: measured, left alone
v3 routes SSR through the Vite dev server (`/__inertia_ssr`) whenever
Vite is running hot, and we don't use the `@inertiajs/vite` plugin — so
in theory local dev makes a doomed POST per render. Measured it rather
than guessed: with `bun run dev` up, page loads are **16 ms** and the
failed attempt is a connection-refused costing ~0.6 ms, then it falls
back to client rendering. Dev mode renders correctly (React mounts, 0
console errors). Not worth a config knob.
Production is untouched — `Vite::isRunningHot()` is false there, so it
uses the real SSR server from `docker/supervisor/supervisord.conf`
exactly as on v2.
## Notes
- Wayfinder needed nothing: #764 already brought it to v0.1.21 (latest),
and `@laravel/vite-plugin-wayfinder` is on 0.1.7 (latest).
- Pre-existing bug both reviews surfaced, left for its own PR:
`auth/register.tsx:40` passes an `async` handler to `<Form onBefore>`,
which Inertia never awaits (same in v2 and v3), so
`transactionSyncService.clearAll()` is fire-and-forget on registration.
- `config/inertia.php` is hand-patched rather than republished. The v3
stub is ~190 lines; the unset keys resolve to package defaults via
`mergeConfigFrom`. Adopting the whole stub is a separate, reviewable
change.
- Remaining "Inertia v2" strings live inside Boost-generated blocks
(`CLAUDE.md`'s guidelines block and the vendored `SKILL.md` copies).
Hand-edits there get overwritten, so they should come from a Boost
regeneration. The three hand-written references are updated here.
## Demo
https://github.com/user-attachments/assets/3f0e7f6d-621a-4c3a-a2a0-68473ec59f10
<!-- PLACEHOLDER: drag the QA video in here -->
Adds two code-quality signals to CI, with deliberately different
strengths.
## Duplication (blocks merges)
`bun run dry` runs jscpd as a step in the `linter` job, a required
check, so
copy-paste that pushes duplication above the threshold in `.jscpd.json`
blocks
the merge. Baseline today is 5.36% (PHP 6.01%, TSX 5.82%) and the
threshold sits
at 5.4. jscpd is pinned as a devDependency rather than run through
`bunx`: a
version bump changes the reported number and would fail unrelated PRs.
## Complexity (goes red, does not block)
`php artisan crap` reports cyclomatic complexity per method. A separate
`crap`
job reports the methods a PR touched that exceed complexity 10. It is
**not** a
required check: it goes red so the number is visible, but never blocks a
merge.
Folding it into the `linter` job would turn it into a gate, which is not
the
intent — please keep it out of branch protection.
### Why complexity decides the verdict and not CRAP
CRAP is `c² × (1 − coverage)³ + c`, so it is complexity penalised by
missing
tests. Measured here, it is the wrong signal for readability:
| | Methods over 30 | crapLoad | totalCrap |
|---|---:|---:|---:|
| Whole suite with real coverage | 15 (0.99%) | 100 | 5,031 |
Because `app/` is well covered, CRAP mostly ranks what is untested. The
two
rankings barely overlap:
| Rank | By CRAP | By complexity |
|---|---|---|
| 1 | `VerifyRefundFlowCommand::handle` (c=14, 0%) |
`ExperimentFunnelCollector::collect` (c=35, 99%) |
| 2 | `StripeCustomerResolver::label` (c=10, 0%) |
`DashboardAnalyticsController::accountBalanceEvolution` (c=30, 91%) |
| 3 | `WiseTransactionSyncService::parseActivity` (c=8, 0%) |
`UpdateTransaction::write` (c=20, 75%) |
The most complex method in the codebase — 35 branches — is 99% covered,
so it
scores CRAP 35 and lands 11th of 15, below an untested enum `label()` of
complexity 6. An agent guided by CRAP would write tests for a console
command
and leave the 35-branch method alone. So complexity triggers the check,
and CRAP
plus per-method coverage travel in the output as context for *how* to
fix it.
### Threshold
10, McCabe's number, just above this codebase's p95 of 8 (median 1, p99
14,
max 35). It fires only on methods a diff touches, so the 35 existing
offenders
only matter when someone edits them.
## The command
```bash
php artisan crap # whole project, ranked
php artisan crap --base=origin/main --no-coverage # what CI checks, <1s
php artisan crap --base=origin/main --json # for agents
```
- `--no-coverage` skips the coverage report and gives the same verdict,
since
complexity alone decides it. Without it, a crap4j report is required and
the
command refuses to guess when it is missing, printing the exact command
to
generate one.
- Exemptions live in `.crap-ignore.json` keyed by method, with a reason
that is
read in review. Entries that are no longer needed get reported for
deletion.
- Untracked files count whole — a new feature is mostly new files and
would
otherwise sail through unmeasured.
- Exit codes: 0 clean, 1 over threshold, 2 unusable input.
The counter is php-code-coverage's own
`CyclomaticComplexityCalculatingVisitor`,
so the numbers match the CRAP it reports: **1502 of 1503 methods
agree**. Its
wrapping visitor is not reused because that one asserts a method's
parent is a
class or a trait, which fails on enums.
## Notes for review
- `pcov.directory` is set explicitly in the tests job. Left to
autodetect, pcov
picks `src`, which does not exist in a Laravel app, and every method
silently
reports 0% coverage — which is how the first measurements of this metric
came
out wrong, with plausible-looking numbers.
- The `crap` job skips rather than fails when `tests` fails: without the
coverage
artifact, a red `crap` job would say nothing about complexity.
- The two checks cover each other's blind spot. Splitting a complex
method into
near-identical pieces to lower complexity raises duplication, and that
check
does block.
- **Not verified:** that paratest merges coverage across its 4 processes
in CI.
Locally only the serial run works — in parallel each worker starts its
own
MySQL testcontainer and they time out. If it misbehaves, the symptom is
empty
crap/coverage context columns, not a wrong verdict.
- Scope is PHP only. `resources/js` (392 files, 72k lines vs 435/37k in
`app/`)
has no complexity pipeline; the JSON says so explicitly rather than
letting
"whole project" be assumed.
## Testing
10 Pest tests covering the threshold verdict, the CCN counting rules
(including
match arms and closures nested in a method), enum methods, exemptions,
stale
exemptions, the missing-report refusal, the crap4j join, and the
reported scope.
`pint --test` and the full `phpstan` run are clean.
Brings every composer dependency to its latest available version,
Laravel included.
## What moved
Laravel 13.1.1 -> 13.24.0, plus every other in-range minor/patch
(cashier 16.5 -> 16.7, fortify, pennant, sanctum, tinker, sentry,
aws-sdk, resend 1.1 -> 1.7, larastan, pint, sail...).
Four majors that were being held back:
| Package | From | To | Note |
| --- | --- | --- | --- |
| laravel/mcp | 0.6.7 | 0.9.3 | Also unblocks boost 2.5.3 + wayfinder
0.1.21, which both conflict with mcp < 0.7 |
| laravel/ai | 0.7.2 | 0.10.3 | |
| pestphp/pest | 4.7.8 | 5.1.0 | Pulls phpunit 13; suite runs 25% faster
(380s -> 296s) |
| intervention/image | 3.11.8 | 4.2.1 | |
## Code the upgrades required
- **`SetBankLogoCommand`** — intervention v4 dropped
`ImageManager::read()` and `Image::toPng()`; now `decodeBinary()` and
`encode(new PngEncoder)`.
- **`GenerateStripePromotionCodesCommand`** — cashier 16.7 pulls
stripe-php v17.6 -> v20.3.1, which pins the API to `2026-06-24.dahlia`.
Promotion codes there take a nested `promotion: {type, coupon}` instead
of a top-level `coupon`. Caught by phpstan, not by a test.
- **`McpOAuthTest`** — mcp 0.9 returns 201 for dynamic client
registration (RFC 7591 says Created) instead of 200.
- **`SubscriptionExperimentTest`** — phpunit 12.5.33 stopped reindexing
arrays inside `assertEqualsCanonicalizing`, so the gapped keys
`array_unique` leaves behind broke the comparison. No entry in phpunit's
changelog, so this looks unintentional on their side; `array_values`
sidesteps it either way.
## Stripe API version
Worth flagging because it is the money path: the SDK sends
`Stripe-Version` on every request, so our own calls now speak dahlia
while webhooks keep arriving in whatever version the account is pinned
to. All 13 direct call sites were reviewed against the v20 shapes
(prices, promotionCodes, customers, subscriptions, paymentIntents,
coupons) — promotion codes were the only breakage, and phpstan is clean.
Separately, `PostStripeEventToDiscord` reads `current_period_end` off
the subscription object, which recent API versions moved onto
`items.data[]`. Pre-existing and cosmetic (a Discord line is silently
skipped), so it is left alone here.
## Left out
`inertiajs/inertia-laravel` 2.0 -> 3.3. It needs `@inertiajs/react` 3
alongside it plus a full UI pass, so it gets its own PR.
## Verification
- Full suite (Browser excluded): 2102 passed, 1 skipped, 1 incomplete
- Performance suite: 25 passed
- phpstan: clean · pint: clean · prettier/eslint: clean
- Browser suite (pest-plugin-browser 4.3 -> 5.0) and `bun run types` are
left to CI — the worktree has no build, and local `--parallel` can't run
because each paratest worker boots its own testcontainer whose `testing`
user lacks CREATE.
Last of the crypto-pricing family, after #759 and #761. The issues queue
has produced nothing new for four cycles, so this came out of reading
the code the logs pointed at.
## What was wrong
`syncHistoricalBalances` rebuilds past daily rows in `account_balances`
— the data behind the net-worth chart. It priced each holding by handing
its raw ticker to `CurrencyConversionService`:
```php
$converted = $this->currencyConverter->convert($asset, $targetCurrency, $quantity, $date);
if ($converted == 0.0) {
$skippedAssets[$asset] = true; // and the holding leaves the day's total
continue;
}
```
That provider carries fiat currencies and a couple of majors. Everything
it did not recognise was dropped from the day's total, so an altcoin
portfolio produced a chart history far below what the user actually held
— while **today's** balance was correct, because the live path prices
through Binance's own tickers. The new test demonstrates it: a day
holding 10 SOL comes out as **0** on the old code.
I first went looking for the USD hop that fixed the Coinbase equivalent,
and there isn't one to add here — a past day needs a *dated* price, and
both the asset leg and any USD leg would come from the same provider
that does not know the asset.
## What it does instead
The snapshot already carried the answer and the service was throwing it
away. Binance sends `totalAssetOfBtc` — what it valued the whole spot
account at, on that day — in the same `data` object as the balances. One
BTC→fiat conversion, on the one ticker the rate provider always covers,
replaces the per-asset loop and covers every holding including the ones
nothing else can price.
Both are spot-only (`accountSnapshot?type=SPOT` and `api/v3/account`),
so historical and current days stay on the same basis — no discontinuity
at the join.
Second commit is a review follow-up: the day's balance now rests
entirely on that one field, so a response that stops carrying it gets
its own counter in the summary log rather than looking like a day with
no snapshot.
## ⚠️ Correction to the ops advice I gave in #759 and #761
I twice suggested `php artisan banking:sync --full` to repair the
understated Coinbase rows. **Do not run that unscoped.** With no filter
it forces `isFirstSync = true` for *every* active connection, which
would also rewrite up to 180 days of history for all 11 Binance accounts
in one shot as a side effect. `--connection=<id>` already exists — use
it:
```
php artisan banking:sync --full --connection=<coinbase-connection-id>
```
For Binance the same command is the repair, but it should be a
deliberate decision per connection, not collateral damage. **Nothing
repairs the existing 11 accounts automatically**: incremental sync only
fills the gap after `MAX(balance_date)` and never revisits older rows,
so their charts stay understated until someone asks for it.
## Deliberate behaviour changes, called out
- **An empty day now charts as zero.** Previously a snapshot with no
balances was skipped, leaving a gap that the frontend forward-fills — so
a day the account was actually empty showed the *previous* non-zero
value. It now writes 0. More correct, but it is a change beyond the
stated fix.
- **A day whose BTC value is positive but unconvertible is left alone**
rather than written as zero, so it disappears into the frontend's
forward-fill instead of reading as a portfolio that briefly vanished.
## Tests
`historical sync values a holding the rate provider cannot price` — a
day holding SOL with deliberately no `sol` rate available. **Verified to
fail on the old implementation: 0 instead of 100000.**
The three touched historical fixtures gained `totalAssetOfBtc` and keep
their original expected cents, recomputed on the new basis (2.0 BTC ÷
0.000019 = 10526316; 1.0 ÷ 0.000018 = 5555556; 0.02 ÷ 0.00002 = 100000).
`tests/Feature/OpenBanking` green at 341/341 locally.
## Not covered
Coinbase's historical path already prices via dated per-asset candles
with a USD route, so it is not exposed to this at the same scale, and
Coinbase exposes no portfolio-level BTC equivalent to substitute.
Bitpanda has no historical sync at all.
## What
We had no idea whether anyone actually uses the MCP server. This records
one row per tool call and adds a report to read it back: which tools get
used, by which users, and how much.
**Storage** — `mcp_tool_calls`: `user_id`, `tool`, `created_at`. Raw
rows rather than pre-aggregated counters, because the volume is a
handful of calls per Pro user per day and a `GROUP BY` then answers
whatever we want to ask later. No arguments and no financial content are
stored.
**Recording** — one `rescue()`d insert in `McpTool::handle()`, the base
class all 24 tools inherit with no overrides, so coverage is complete by
construction. It sits *after* `respond()` and skips error responses, so
the number means "calls that did something": a plan-gate rejection, a
read-only token rejected on a write, or a `ValidationException` for an
unreachable id is an attempt, not usage. `rescue()` keeps a failed
insert from ever breaking a working tool call while still reporting to
Sentry.
**Reading** — `php artisan stats:mcp-usage [--days=30] [--top=20]`:
```
MCP usage — last 30 days (since 2026-07-13)
Calls: 15 Users: 2
By tool
+---------------------+-------+-------+-------+
| Tool | Calls | % | Users |
+---------------------+-------+-------+-------+
| search_transactions | 10 | 66.7% | 2 |
| get_cashflow | 2 | 13.3% | 1 |
| get_net_worth | 2 | 13.3% | 1 |
| create_transaction | 1 | 6.7% | 1 |
+---------------------+-------+-------+-------+
By user (top 20)
+--------------------------------------------+-------+-------+---------------------+
| User | Calls | Tools | Last call |
+--------------------------------------------+-------+-------+---------------------+
| ana@example.com | 12 | 4 | 2026-08-11 08:06:59 |
| 20260811080659_bruno@example.com (deleted) | 3 | 1 | 2026-08-11 08:06:59 |
+--------------------------------------------+-------+-------+---------------------+
By day
+------------+-------+-------+
| Day | Calls | Users |
+------------+-------+-------+
| 2026-08-08 | 2 | 1 |
| 2026-08-11 | 13 | 2 |
+------------+-------+-------+
```
The per-user table joins `users` instead of eager-loading the relation:
`user:delete` soft-deletes, so the FK cascade never fires and the
relation's `deleted_at is null` scope would silently blank out exactly
the users we most want to see — the ones who churned. They render with a
`(deleted)` marker.
## QA
Driven through the real `/mcp` HTTP endpoint with real Sanctum bearer
tokens, against MySQL:
- 5 successful calls by a read+write user → 5 rows, right user, right
tool names.
- `get_net_worth` with missing arguments and `search_transactions` on an
unreachable space → both rejected, neither recorded.
- A read-only token: `list_accounts` recorded; `create_label` rejected
with "This token is read-only" and not recorded.
- Report checked at `--days` 1 / 30 / 200, with `--top 1` truncation,
with a churned (`markAsDeleted()`) user, and with no data at all.
48 MCP tests green, `pint` and `phpstan` clean.
## Deliberately left out
- **No Discord post or schedule.** The other `stats:*` commands post
weekly; whether MCP usage is worth that noise is a product call, and
it's one `Schedule::command()` line whenever we want it.
- **No client column.** `Auth::getDefaultDriver()` would tell us OAuth
(Claude Desktop / ChatGPT) vs personal token (Claude Code) at the
insert. It's not recoverable after the fact, so it's worth knowing we
skipped it — but it wasn't asked for.
- **No pruning.** Noted in the migration; add it if the table ever gets
big.
- **No collector service.** The sibling report commands extract one
because they feed both the console and Discord. This has one consumer.
Follow-up to #759, which turned out to be **inert in production**.
Confirmed from the logs after it deployed.
## What is actually happening
`fetchPriceMap` builds `{ASSET}-{QUOTE}` product ids for a batched
`best_bid_ask` call. **EURC** — Circle's euro stablecoin — is not an ISO
4217 code, so `isFiatCurrency()` rejects it, and the stablecoin list was
USD-only, so it went out as an ordinary crypto product id.
Coinbase does not list `EURC-EUR`, and it refuses the **entire** request
over one unknown id:
```
invalid product_id provided: "EURC-EUR"
```
Since #759 that happens twice per run — once for the fiat pass, once for
the new USD retry (`invalid product_id provided: "EURC-USD"`). So
`fetchPriceMap` has been returning an empty map on **every** sync,
#759's USD hop never contributed anything, and every holding fell
through to the per-asset `CurrencyConversionService`, which prices
almost no crypto. The post-#759 log signature is 2× `Coinbase API error`
(400) + 2× `Coinbase best_bid_ask failed` per run, and `Could not price
Coinbase asset` never dropped.
The balance is still written, so net worth and the chart have been
showing a badly understated number.
## Three commits
1. **EURC settles at its euro peg.** The USD-only list became a peg map
(`asset => peg currency`), which also collapses three duplicated
`in_array` branches into one lookup that carries the peg.
USDT/USDC/DAI/PYUSD/GUSD are byte-for-byte unchanged.
2. **A rejected batch retries one asset at a time.** Fixing EURC removes
today's bad id, but not the shape of the failure: one unlisted holding
takes the price of every other holding with it, and the next unlisted
token would do it again. The batch is now an optimisation rather than a
single point of failure.
3. **Fan out only when the batch itself was refused.** Both reviews
caught this independently, and it was a real bug I introduced in commit
2: retrying on *any* throwable multiplies `CoinbaseClient`'s own 429
backoff (10s/30s/60s ≈ 100s per call) by the number of holdings, against
`SyncBankingConnectionJob`'s 120s timeout — trading a fast wrong answer
for a sync that hangs and dies. The fan-out now requires a 4xx that is
not 429.
## Tests
Three new, each verified to fail without its fix:
| test | without the fix |
|---|---|
| `settles EURC at its euro peg instead of asking Coinbase to quote it`
| 5 000 000 instead of 5 010 000 — the EURC vanishes |
| `prices the rest of the portfolio when Coinbase rejects one product
id` | **0** — the entire portfolio unpriced, which is what production
does today |
| `gives up on a rate-limited price batch rather than retrying every
asset` | fans out and sends the per-asset requests |
The 429 test fakes `Sleep`; without it the real backoff makes the file
take 209s instead of 10s.
`tests/Feature/OpenBanking` is green at 341/341 locally.
## Deliberately not here
- **Already-written rows stay understated.** `needsHistoricalBackfill`
is already satisfied by the wrong rows, so the next sync only heals
today's. The chart will show a discontinuity on deploy day. No new code
needed to repair it — `SyncBankingConnectionJob` already has a
`fullSync` flag that forces the historical pass, reachable as `php
artisan banking:sync --full`. Replaying a year of pricing on real
accounts is your call, not something this loop should do unattended.
Blast radius: 2 Coinbase connections, both EUR.
- **A USD-currency user's EURC** used to be quoted against the real
`EURC-USD` pair and now goes peg + FX instead. Inert today (all crypto
connections in production are EUR) and consistent with how USDT/USDC are
already handled, but it is a silent pricing change for a future USD
holder.
- **Anything not in the peg map and not quotable is still silently
zero.** This fixes EURC by name; the general "report an incomplete
balance as if it were complete" behaviour needs a product decision.
- **`BinanceBalanceSyncService`'s historical path** still hands a raw
crypto ticker to the fiat converter with no USD hop (its live path is
fine). Same class, own PR — I started on it this cycle and dropped it
when the logs showed this was live and Binance's was not.
Found in the Sentry **logs**, not the issues queue — this class of bug
never throws, it just quietly reports the wrong number. `Could not price
Coinbase asset` has been firing steadily, 24 times in the last 7 days.
## What happens
`CoinbaseBalanceSyncService::fetchPriceMap` only ever asked Coinbase for
`{ASSET}-{USER_CURRENCY}` price books. Coinbase lists most assets
against USD alone, so for a EUR user those come back empty.
`convertCryptoAssets` then falls through to
`CurrencyConversionService::convert('SOL', 'EUR', …)` — a **fiat** FX
service that has no rate for a crypto ticker and returns `0.0`.
The holding contributes nothing. The balance is still written to
`account_balances`, so it feeds net worth and the balance chart as a
confidently wrong, lower number — indistinguishable from the user
genuinely holding less.
The historical backfill never had this problem:
`fetchHistoricalPricesForAsset` already retries against USD and
converts. So the same wallet is priced correctly for every past month
and collapses on today's balance — a cliff at the right-hand edge of the
chart rather than an obviously broken figure.
**All three Coinbase/Bitpanda/Binance users in production are on EUR**,
so the Coinbase half of this is live for both Coinbase connections.
## The commits
1. **Coinbase**: the live path takes the same USD hop the historical
path already takes, for whatever the fiat pricebook did not cover — one
extra batched request, and only when something is actually missing.
2. **Bitpanda**: same defect one service over. `getTickerPrice` read
`$ticker[$symbol][$targetCurrency]` and gave up on a miss, and the
caller logs and `continue`s, dropping the wallet entirely. Bitpanda's
ticker only quotes EUR, USD, CHF, GBP and TRY — so every other supported
currency reads **zero for every crypto wallet**, which now includes DKK
(added in #754). No user is on such a currency with a Bitpanda
connection today, so this one is a landmine rather than a live bug; I
fixed it because it is the same one-line shape and it silently
misreports money.
3. **Review follow-ups** (no behaviour change): stop quoting stablecoins
that `convertCryptoAssets` settles at 1 USD before it ever reads the
map; guard the empty batch inside `fetchBestBidAskPrices` rather than
relying on the caller; drop the now-untrue "falling back to per-asset
USD conversion" tail from the catch message.
4. **Mixed-batch test** — the single-asset test never exercises the
`array_diff` that decides what the USD pass asks for.
## Safety
- USD users short-circuit before any USD hop. Assets that already have a
fiat book are excluded from the retry and never touch the new path.
Stablecoins are intercepted before the map is consulted.
- If the second request throws, or the FX rate for the date is missing,
the `$targetPrice > 0` guard drops it and the asset falls through to
exactly today's behaviour. The change can only add a price, never
replace a good one.
- Money math verified against `CurrencyConversionService::convert`,
which **divides** by a rate keyed to the target currency as base: 100
USD ÷ 2.0 = 50 EUR per unit, then multiplied by quantity.
## Two things this does NOT do
**Already-written rows stay wrong.** The next sync overwrites only
today's `balance_date` row. `needsHistoricalBackfill` re-runs the
historical pass only once, so every daily row written since the account
was connected keeps its understated value — 102 rows on one Coinbase
account, 16 on the other. The existing `php artisan banking:sync --full`
re-runs the historical pass and would repair them; I did not run it,
since replaying a year of pricing for real accounts is an ops call, not
something an autonomous loop should do unattended.
**An asset Coinbase does not quote in USD either still vanishes
silently.** It logs and contributes zero, with nothing surfaced to the
user. Pricing an unquotable asset is not solvable here, but reporting a
balance we know is incomplete as if it were complete is a product
decision worth taking: flagging the account as partially priced would be
the honest behaviour.
## Follow-up found in review, deliberately not here
`BinanceBalanceSyncService`'s **historical** path (`:145`) also hands a
raw ticker to the fiat converter with no USD hop, dropping zeros into a
`skipped_assets` log. Its live path is correct, so today's balance is
right and only the one-time historical anchors are affected — a smaller,
different blast radius that deserves its own change.
## Tests
`prices an asset Coinbase only quotes in USD instead of dropping it from
the balance`, `mixes fiat-quoted and USD-only assets in one balance`
(asserts the second request carries `SOL-USD` and does not re-quote
`BTC`), and `prices a wallet Bitpanda does not quote in the user
currency via USD` (a DKK user).
**I could not run the PHP suite locally — the local Docker daemon is
wedged, so the MySQL testcontainer never starts. Relying on CI.** Pint
and `php -l` are clean.
Relates to
[PHP-LARAVEL-5A](https://whisper-money.sentry.io/issues/PHP-LARAVEL-5A).
## Why
The app has never had a React error boundary. Any throw during render or
in an effect unmounts the whole tree, so the user gets a blank white
page — no message, no way out, and nothing telling them to reload.
That is not hypothetical, it is how **every** frontend crash we have
fixed this year presented itself: #41 (`addEventListener` on old
Safari), #43 (missing `indexedDB`), #47 (recharts render loop), #57 /
#4Y (`localStorage` null in a restricted webview), #675
(`colorClasses.bg`). Each got a targeted fix, and the next unguarded
property access white-screened the app again. PHP-LARAVEL-5A is the
current one: `props.features` arrives undefined on `/dashboard` and
`props.features.cashflow` takes the page down.
**This does not fix 5A's root cause.** I could not confirm why
`props.features` goes missing — the server always shares it, and the
only mechanism I found is Inertia core's `mergeProps` bailing out when a
partial response's component differs from the current page, which I
could not reproduce. What this does is stop that class of bug from
costing the user their whole session.
## What it does
Wraps the tree in `@sentry/react`'s `ErrorBoundary` — already a
dependency, no new packages — with a fallback offering a reload and a
way to the dashboard.
Two things the boundary has to take over, because once React handles an
error it never reaches `window.onerror`:
- **Reporting.** `Sentry.ErrorBoundary` captures on its own via
`captureReactException`, and adds the React component stack. Note this
will fingerprint as a *new* issue rather than continuing 5A, since the
exception now carries a synthetic cause.
- **The stale-chunk reload.** `chunk-load-recovery`'s global listeners
only see what React did not catch, so `onError` re-runs
`reloadOnChunkLoadError`. Its actual reload-once behaviour stays covered
by `chunk-load-recovery.test.ts`.
## The two follow-up commits are the interesting ones
Both came out of review and both were real bugs in the first commit:
**`handled` was silently flipped.** The SDK derives `handled` from
whether a fallback was supplied (`errorboundary.js:51`), so simply
adding one would have reclassified every caught crash as handled — they
would stop counting against crash-free sessions and stop matching any
`error.handled:false` alert. Now passes `handled={false}` explicitly.
**Both escape hatches were dead ends.**
- "Back to home" pointed at `/`, which renders the marketing page — and
that page mounts `AuthenticatedRedirectDialog`, which `router.visit()`s
a signed-in user to the dashboard on a 3s timer. The escape hatch
trampolined the user straight back into whatever crashed. It now goes to
the dashboard directly, as a document load.
- The **back button** was worse. Inertia's popstate listener lives at
module scope and outlives React unmounting the page, so going back
changed the URL and painted nothing — the screen just looked frozen. The
fallback now reloads on `popstate`.
## Scope, honestly
- `initializeTheme()` and `initializeChartColorScheme()` run at module
scope, **before** the boundary mounts, so the boot-crash class (#57 /
#4Y) is still outside its reach. Those are already fixed by #743's
`safe-storage`; worth knowing the boundary is not a second net for them.
- One boundary at the root means a crash anywhere replaces the whole app
rather than just the page. Since React unmounts the entire tree on an
uncaught error anyway, the trade is white screen → error screen, not
working page → error screen. A second boundary around `<App>` only would
let the shell survive and make `resetError()` meaningful — worth doing
if this ever fires often enough to matter.
- `ssr.tsx` keeps its own unwrapped copy of the provider tree.
Pre-existing duplication, now one element wider; extracting a shared
`AppProviders` is the fix.
- No reload-loop guard. "Try again" is user-initiated, not automatic, so
a deterministic crash means the button does nothing rather than looping
— and the dashboard button is the real way out.
## Tests
`app-error-boundary.test.tsx`: children render normally; a throwing
child produces the recovery screen with both actions instead of nothing;
the error is handed to the chunk-load recovery; the popstate listener is
registered and cleaned up. 4/4 locally, plus
`chunk-load-recovery.test.ts` 5/5.
Copy uses existing `lang/es.json` keys except one new line, which is
added.
Fixes
[PHP-LARAVEL-58](https://whisper-money.sentry.io/issues/PHP-LARAVEL-58).
## What production looks like
There is exactly one Wise connection in production and it has **never
synced** — `last_synced_at` is null. Its entire history is three failed
attempts:
| when | error |
|---|---|
| 2026-07-29 | `ConnectionException` — cURL error 28, timed out after
30s |
| 2026-08-03 | `ConnectionException` — cURL error 28, timed out after
30s |
| 2026-08-10 | `RequestException` — HTTP 500 from
`/v1/profiles/{id}/activities`, mid-cursor |
All three are upstream. All three were charged to
`consecutive_sync_failures`, now at **2**. At `MAX_SCHEDULED_RETRIES =
3` both `SyncAllBankingConnectionsJob` and `sync:banking` stop
dispatching the connection — permanently, with no email and no reconnect
notice in the UI. The next Wise outage would have been the last sync
this user ever got.
## Three commits
**1. Classify Wise timeouts and 5xx as transient.** `WiseClient` rethrew
everything raw, so an outage looked like an application bug: reported to
Sentry at error level and logged as `Wise API error`. Timeouts and 5xx
now raise `TransientBankingProviderException`, matching
`EnableBankingProvider` (PR #678). The job already understands that type
— warning instead of error, `ShouldntReport`, and a "temporarily
unavailable" message for the user. **401/403 and 429 deliberately stay
raw**: `isAuthError` and `isRateLimitError` match on `RequestException`,
and `resolveRateLimitBackoffUntil` reads `Retry-After` off
`$e->response`. The three request methods now share one private `get()`,
which is where the classification lives.
**2. Keep an empty body from becoming a TypeError.** Introduced by
commit 1: routing everything through `get(): array` turned a 200 with an
empty body into a `TypeError` — neither transient nor suppressed, i.e.
exactly the noise this branch removes. It used to degrade to `[]` via
`$accounts[0] ?? []`. Caught by review.
**3. Stop provider outages from parking a connection for good.** Without
this the branch is only a log-level change: `handleTemporaryError`
incremented the counter for any non-auth throwable, so the
classification never reached the user. A transient failure now surfaces
on the connection (status and message unchanged) without spending a
scheduled retry. Unclassified failures still count, so a genuine defect
still parks the connection.
No data migration needed — the connection is at 2, still under the cap,
so it stays eligible and the counter resets on its first success.
## Trade-off, stated plainly
A provider that is down forever is now retried forever: one job per
cycle, surfacing as a repeating `Banking sync failed` warning. That is
cheap and visible, and strictly better than silently never syncing a
user's bank. Marked with a `ponytail:` comment naming the ceiling.
The flip side is that a **persistent** Wise breakage (an API contract
change, say) now produces no Sentry issue — only warn-level logs and
`banking_sync_logs` rows. Worth knowing before assuming silence means
health.
## Tests
- `WiseTransientErrorTest`: 500 mid-cursor and a cURL 28 timeout both
become `TransientBankingProviderException`; 401/403/429 (dataset) stay
`RequestException`; an empty body still degrades to `[]`.
- `SyncRetryAndLoggingTest`: a transient failure at
`consecutive_sync_failures = 2` leaves it at 2 and below the cap; a
**non**-transient one still takes it to 3.
Local: 490/490 across `tests/Feature/OpenBanking`, `tests/Feature/Jobs`,
`tests/Unit`.
## Follow-ups (found in review, deliberately not here)
- `WiseClient` sets no timeouts; `EnableBankingProvider` uses
`timeout(20)->connectTimeout(5)`, IB uses 5/15. Two of the three prod
failures were 30s timeouts, so bounding them is worth a look — but
shortening the window on a 12-month first sync needs its own thinking.
- `EnableBankingProvider` logs its 5xx at `error` while raising the same
transient exception. Wise is the consistent one here; EB should follow.
- The `ConnectionException` + 5xx catch skeleton now exists three times.
Rule of three is reached — a shared trait would also let
Binance/Coinbase/Bitpanda/IndexaCapital adopt it.
- Both credential-validation call sites catch `\Throwable`, so
connecting Wise during an outage still tells the user "Invalid API
token". Now cheaply fixable by catching the transient type first.
## Why
Enable Banking connections re-requested **a year of transaction history
every 6 hours**. Trade Republic connections failed ~70% of their syncs
with HTTP 429 (220 of 307 attempts in the last 7 days, against 1–4% for
other ASPSPs), and the cause was ours:
1. `EnableBankingSyncer::sync()` persisted the transactions, then the
balance call threw a 429, the exception bubbled up to
`SyncBankingConnectionJob`, and the whole run was marked failed.
2. `last_synced_at` is only written after a clean `sync()`, so it never
got written. 15 of 16 Trade Republic connections have zero rows in
`account_balances` and `last_synced_at = NULL` weeks after connecting.
3. `$isFirstSync = ! $connection->last_synced_at || $this->fullSync` was
therefore permanently true, so every run used `now()->subYear()` with
`strategy = 'longest'` plus `calculateHistoricalBalances()`. Paginating
a year of history four times a day is what trips the rate limit.
This is **not Trade Republic specific**: CaixaBank and Eurocaja Rural
show the same 429 pattern at lower volume, and the fix applies to every
Enable Banking ASPSP.
## What changed
All in `app/Services/Banking/Sync/EnableBankingSyncer.php`.
**1. The fetch window comes from the transaction watermark, not from
`last_synced_at`.**
The `linked` branch already did this; the lookup is now shared by both
branches:
- **Watermark found** → `date_from` = that transaction's date minus a
3-day overlap (banks post transactions with retroactive value dates, so
the previous no-overlap watermark could silently miss them), no
`longest` strategy.
- **No watermark** → unchanged: one year back with `strategy =
'longest'`. That is the genuine first sync.
This is the fuse: even if everything else fails, a routine sync asks for
a few days instead of a year. The two branches' differing balance
handling (`saveDailyBalances: false` for linked accounts,
`calculateHistoricalBalances()` on first sync for unlinked) is
untouched.
**2. A failing balance call no longer fails the whole sync.** It is
logged, counted, and surfaced as `balance_failed` in the array `sync()`
returns, so it lands in `banking_sync_logs.metadata` instead of being
silently swallowed. The run finishes clean → `last_synced_at` gets
written → `$isFirstSync` stops being permanently true →
`calculateHistoricalBalances()` stops running every 6 hours.
**Two exceptions are deliberately still fatal** (both raised by review,
see below): an expired session, and a **429**.
## Deviation from the brief, worth a look
The brief asked for *every* balance failure to be non-fatal, including
the 429. Both review passes flagged the same problem with that: a 429
escapes `EnableBankingProvider` as a raw `RequestException`, and that is
exactly what `SyncBankingConnectionJob::isRateLimitError()` matches to
set `rate_limited_until`. Swallowing it would have removed the only
backoff — and Enable Banking quotas are **per-consent daily access
counts** (`Maximum daily access exceeded`, `Allowed number of accesses
exceeded for consent`), so a connection that lost its backoff would keep
burning the remaining quota on every 6-hourly cycle and never get its
balances.
So a balance 429 is re-thrown and the existing backoff (untouched, as
the brief required) applies. The transactions from that run are still
persisted, and the connection stays `active`. Every other balance
failure is non-fatal as specified.
Three more findings from review, fixed in the second commit:
- **`--full` still forces the year-wide window.** A first sync on a
connection that has already synced can only come from that flag, so it
beats the watermark. Without this, `banking:sync --full` had become a
no-op for the window — the operator's only remedy for a gap in history.
- **Windows reaching back more than 90 days keep `strategy =
'longest'`.** A dormant account with an old watermark would otherwise be
rejected (422) and walked down the `[90, 30, 7]` narrowing ladder, which
advances the watermark past the span it never fetched — a silent,
permanent gap.
- **The watermark counts trashed rows** (the dedup already uses
`withTrashed()`, so re-fetching them creates nothing), and the
future-date clamp no longer eats the 3-day overlap.
## Out of scope
No migration, no new watermark column, no change to the
`rate_limited_until` backoff, no manual reset of the broken production
connections — the first clean sync clears `error_message` on its own.
Two things worth a follow-up, not fixed here:
`calculateHistoricalBalances()` is still gated on the connection-level
`$isFirstSync` while the window is now per-account, so an account added
to an already-synced connection pulls a year of transactions without a
balance backfill; and 61 accounts have never received a bank transaction
at all, so they stay on the year-wide window until one lands.
## QA
Ran the whole chain with nothing mocked but the network (real
`EnableBankingProvider`, `Http::fake`), checking the request that
actually leaves for the bank:
| Case | Request that goes out |
|---|---|
| Account with a watermark at `2026-08-08`, today `2026-08-10` |
`…/transactions?date_from=2026-08-05&date_to=2026-08-10` — 5 days, no
`strategy` |
| Account with no bank transactions |
`…/transactions?date_from=2025-08-10&date_to=2026-08-10&strategy=longest`
— unchanged |
| Balances returns 429 `Maximum daily access exceeded` | transactions
persisted, `status = active`, `rate_limited_until = 2026-08-11 00:00:00`
(next UTC midnight) |
Against the production database (read-only), for the 475 syncable Enable
Banking accounts:
| After the fix | Accounts | Avg. days requested |
|---|---|---|
| Watermark → short window | 382 | 13.7 |
| No watermark → 1 year + `longest` | 61 | 365 |
| Watermark older than 90d → wide + `longest` | 32 | 173 |
All 12 Trade Republic accounts have a watermark, averaging **7.1 days**
— down from 365 on every run.
## Tests
`tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php`:
- an account with existing Enable Banking transactions asks for
`watermark - 3 days`, not a year (the test that matters)
- an account without them still asks for the year with `longest`
- `--full` beats the watermark
- a non-429 balance failure → connection stays `active`,
`last_synced_at` written, the run's transactions persisted,
`balance_failed: 1` in the sync log metadata
- a 429 balance failure → transactions persisted and the backoff still
applied
- an expired session during the balance call is not swallowed
Full suite green (2080 passed), `pint` and `phpstan` clean.
Fixes
[PHP-LARAVEL-5B](https://whisper-money.sentry.io/issues/PHP-LARAVEL-5B).
## What broke
`demo:reset` seeds a fabricated Stripe subscription so the demo account
gets Pro access without touching Stripe. The id was the hardcoded
literal `sub_demo_free_forever`, and `subscriptions.stripe_id` is
unique.
`--email` (#753, shipped this morning) made a second seeded account
possible. `createSubscription()` deletes only the *current* user's
subscriptions before inserting, so the first `demo:reset --email` run
after the public demo account existed died on:
```
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry
'sub_demo_free_forever' for key 'subscriptions.subscriptions_stripe_id_unique'
```
`createSubscription()` is the last step of `handle()`, so the reviewer
account was left fully seeded but with **no subscription at all** — an
app-store reviewer signing in lands on the paywall instead of the Pro
app.
## The fix
Derive the fake id from the user: `sub_demo_{$user->id}`. Matches what
`E2eBankingFixtureCommand` already does (`sub_e2e_.$user->id`). Nothing
reads the old literal — entitlement goes through Cashier's
`subscribed('default')`, which only looks at `stripe_status`. Production
is self-healing: the next run replaces the old row, and the demo account
keeps working until then.
## Second commit: seeded accounts and the Stripe billing paths
Fixing the collision means reviewer accounts now genuinely have
`hasProPlan() === true`, which switches on paths that were unreachable
while they were paywalled. `isDemoAccount()` is equality against
`config('app.demo.email')`, so an `--email` account is **not** a demo
account and skipped every existing guard:
- `SubscriptionController::billingPortal` would call
`createAsStripeCustomer()` — creating a real Stripe customer in
production — and drop the reviewer on an empty portal.
- Bucketed into `PAY_NOW`, `canSelfRefund()` returns true, so the refund
box renders; `RefundSelfServe::handle` then calls
`$subscription->latestPayment()` on `sub_demo_<uuid>` → Stripe 404,
thrown above the `try`, 500 plus a false `🔴 Self-service refund FAILED —
the user may have been charged without a refund` Discord alert.
`User::hasSeededSubscription()` keys off the fabricated id prefix rather
than one hardcoded email, so it also covers the e2e fixture account.
Real Stripe ids are `sub_` + alphanumerics with no further underscore,
so no paying user can match it.
## Tests
- `demo:reset subscribes a named account even when the public demo
account already exists` — seeds the public demo account, then a named
one. Verified it fails on the pre-fix code with the **identical**
SQLSTATE 1062 message from the Sentry event, and asserts the two ids
differ so a regression back to a shared literal is caught.
- `a seeded reviewer account cannot reach the billing portal`, `blocks a
self-refund on a seeded demo subscription`.
Local: `ResetDemoAccountCommandTest` 7/7, `SelfServeRefundTest` +
`DemoAccountRestrictionsTest` + `SubscriptionTest` 59/59.
## Not fixed here (found during review, out of scope)
- `ResetDemoAccountCommand` falls back to
`Bank::factory()->create(['user_id' => null])` when a named bank is
missing, permanently adding randomly-named **global** banks visible to
every user. `firstOrCreate(['name' => …, 'user_id' => null])` would fix
it.
- Seeded accounts count as paid in `SubscriptionFunnelCollector` /
`ExperimentFunnelCollector` unless their email is in
`AI_SUGGESTIONS_REPORT_EXCLUDED_EMAILS`.
- `handle()` runs without a transaction, so a failure mid-reseed still
leaves a half-seeded account.
## What
Adds the **Danish Krone (DKK)** as both a user primary currency and an
account currency.
Per `docs/adding-a-currency.md` this is a config change — validation
(`ProfileUpdateRequest`, `StoreAccountRequest`, `UpdateAccountRequest`),
the Inertia dropdown props and conversion all derive from
`config/currencies.php`.
Both pre-checks in the doc pass:
- `DKK` is the current ISO 4217 code (no deprecated-code trap like
`GHC`/`GHS`).
- The provider covers it: `EUR→DKK = 7.4754`, consistent with the ERM II
peg (~7.46).
## Closing the translation gap
Currency names are translated in PHP by `CurrencyOptions`, so they never
appear as literal `__()` keys in the TS/TSX source — `LocalizationTest`
never saw them, and a missing Spanish name silently shipped the English
one. The doc even warned about it.
`LocalizationTest` now feeds the configured currency names into its
translatable-key check, so Spanish is enforced and French warns, exactly
like every other key. That surfaced pre-existing gaps, now filled:
- Spanish: PKR, BRL, DOP, SAR
- French: those four plus NGN
## Also
Dropped the docs' "add a symbol" step. `getCurrencySymbol` has no
callers left — every consumer of `utils/currency` imports
`formatCurrency` only — so entries in its map change nothing on screen.
Removing the function itself is a separate cleanup.
## QA
Real browser QA against the running app, in Spanish:
- `DKK - Corona danesa` appears in the profile currency select; saving
persists `users.currency_code = 'DKK'` and survives a reload.
- `DKK - Corona danesa` appears in the create-account currency select;
created a `Danske Bank Private` account and confirmed
`accounts.currency_code = 'DKK'`.
- Conversion both ways: `100 EUR → 747.68 DKK`, `100 DKK → 13.38 EUR`.
- `LocalizationTest` + `CurrencyConversionServiceTest` green; `pint`,
`phpstan`, `prettier`, `eslint` clean.
## Demo
<!-- PLACEHOLDER: drag the video here -->
**⬆️ Attach `~/Downloads/dkk-currency-qa.mp4` here.**
## Follow-up (out of scope)
Prod already holds accounts and users on currencies that aren't in the
config (PLN, MAD, PHP).
`AccountUserCurrencyService::resolveImportedCurrency` accepts whatever a
bank reports and `forceFill`s it onto `users.currency_code`, bypassing
validation — so those users can't save **any** profile change, and their
currency select renders empty. Worth a separate issue: either
validate/fall back on import, or make the selects tolerate an
out-of-list current value.
## Why
The ChatGPT app directory review needs a reviewer account: fully
featured, pre-loaded with sample data, reachable with an email and
password, no 2FA, and on the Pro plan our MCP tools require.
`demo:reset` already builds that dataset — 6 accounts, ~2k transactions,
12 months of balances, categories, labels, rules, budgets — and attaches
an active subscription, so it only needed to target an email other than
the public demo user.
## What
- `--email` / `--password` create or reset an arbitrary account instead
of the configured demo user. An explicit `--email` skips the
`app.demo.enabled` gate, since a named account is not the public demo.
- The account keeps `isDemoAccount() === false` (that check compares
against `app.demo.email`), so none of the demo UI restrictions apply to
it.
- `--imported` marks one account's transactions as bank-imported, so a
reviewer can verify that `update_transaction` and `delete_transaction`
refuse to touch synced data — one of the negative test cases in the
submission. That account gets no banking connection, so no sync ever
runs on it.
- The public `demo:reset` path is unchanged: same config, same gate,
same data.
Usage:
```
php artisan demo:reset --email=openai-review@whisper.money --password='...' --imported
```
## Testing
`tests/Feature/Console/ResetDemoAccountCommandTest.php` adds two cases
on top of the existing ones: a named account comes out on the Pro plan,
not flagged as the demo account, and holding both manual and imported
transactions; and `--email` without `--password` fails without creating
anything.
## What
The admin Discord channel is Spanish, but the four scheduled `stats:*`
reports posted English. They now post Spanish, and the three
cohort/experiment reports open with a short AI-written summary so a
reader understands the situation without decoding the table.
### 1. Spanish reports
`stats:daily-report`, `stats:subscription-funnel`,
`stats:experiment-funnel` and `stats:ai-cohort-report` now post Spanish
embed titles, field names, ASCII table headers, legends and disclaimers,
with Spanish dates (`sáb., 13 jun. 2026`). Table headers and cells stay
ASCII (`Semana`, `Variante`, `UMad`, `pdte`, …) so `sprintf`'s byte
padding keeps the columns aligned inside the code block.
Hardcoded, not `__()`: this is an internal channel, not user-facing UI,
so it never needs a second language and doesn't belong in
`lang/es.json`. Everything else — code, comments, PHPDoc, command
descriptions, `$this->info()` — stays English.
### 2. AI summary (best-effort)
New `ReportSummarizer` + `ReportSummaryAgent` (laravel/ai, same
Gemini-Flash pattern and `AI_PROVIDER` switch as the other AI features,
config in `config/ai_reports.php`). It prepends a few sentences to the
embed description that compare the current period against the previous
one (week over week for the two weekly reports, month over month for the
monthly cohort one), say what got better or worse, and call out
explicitly when a figure isn't conclusive — small sample, immature
cohort, signup surge week, or no previous period.
- **Data**: only what each collector already computes. The two cohort
reports pass their weekly series with the per-metric maturity flags; the
experiment report passes the per-variant figures plus the
already-rendered significance verdict (one source of truth), with money
figures nulled exactly where the table renders `—`, so the summary can't
report a zero where the reader sees no data.
- **Previous period**: the experiment report has no time series, so each
run caches its figures (with a capture timestamp) as the next run's
baseline. A same-day manual re-run doesn't overwrite it, and the model
is told to flag a gap that isn't roughly one period.
- **Degrades safely**: no API key, a provider outage, a bad provider
name or a slow response (30s timeout) → the report is posted unchanged,
without the summary. Transient provider errors are logged; anything else
is reported, matching `CategorizeTransactions` /
`LaravelAiRuleSuggestionGenerator`.
### 3. Fix found while reviewing: Discord's embed limits
The translated "Cómo leerlo" field came out at 1152 characters, past
Discord's 1024-character cap on a field value — Discord rejects the
**whole** payload, so the experiment funnel would have silently stopped
appearing in the channel every Monday (`DiscordWebhook` only logs the
400). Both long fields are now tighter (745 and 898 chars),
`DiscordWebhook` trims anything still over the limit instead of losing
the report, and a test measures every scheduled report's embed so
growing copy fails in CI rather than in production.
## Testing
- `tests/Feature/Ai/ReportSummarizerTest.php`: baseline in/out, same-day
re-run, dry run, backtick stripping, truncation, empty answer,
transient-vs-reported failures.
- `tests/Feature/DiscordReportEmbedLimitsTest.php`: all four embeds
inside Discord's limits, plus the trimming fallback.
- The four command tests: Spanish assertions, summary is the first thing
in the description, and the report is still posted when the AI throws.
- 64 tests / 338 assertions pass locally, plus PHPStan and Pint.
## QA
Ran all four commands against the local DB with `--no-discord` (and
dumped the real webhook payloads with the Discord call faked). Real
Gemini output, e.g. the experiment funnel on the second run:
> No se ha producido ningún cambio en las métricas del experimento
respecto a la ejecución previa del 10 de agosto de 2026. La tasa de
conversión sobre usuarios maduros se mantiene en el 4,7 % para la
variante reducida, el 4,0 % para pay_now y el 3,7 % para el control. Las
cifras no son conclusivas ya que las diferencias entre variantes no son
estadísticamente significativas, con un p-valor de 0,593 frente al
umbral α de 0,017.
Also verified with an unreachable provider: the report renders in full,
without the summary.
## Not included
- `stats:stuck-cohort-report` is still English. It posts to the same
webhook but isn't scheduled in `routes/console.php`, so it was out of
the four active reports; worth its own decision.
- The `—` and `⚡` cells are still a couple of bytes off inside the code
block (multibyte in `sprintf`). Pre-existing on `main`, unchanged here.
## Why
The ChatGPT app directory rejects the submission with:
> Every MCP tool must set readOnlyHint, openWorldHint, destructiveHint
to true or false.
We only ever declared one hint per tool — `#[IsReadOnly]` on the reads,
`#[IsDestructive]` on the writes — so the other two were absent from
`tools/list` and the portal's scan flagged all 23 tools.
## What
- `McpTool::annotations()` now defaults all three hints, so every tool
reports `readOnlyHint`, `destructiveHint` and `openWorldHint`
explicitly. The attributes still override: `#[IsReadOnly]` on the eight
read tools, `#[IsDestructive]` on the four deletes.
- `openWorldHint` is always `false`: every tool reads or writes the
user's own account, never the open web.
- `destructiveHint` drops to `false` on the eleven
create/update/categorize/label tools. Marking them destructive was wrong
— the directory reserves it for irreversible operations — and it made
ChatGPT ask for confirmation on every write, including recategorizing a
transaction.
- Tool descriptions trimmed to the portal's 200-character cap (nine were
longer, `create_automation_rule` ran to 524). The cuts are facts the
server instructions already state — amounts in minor units,
whole-account scope. The JsonLogic variable list and example move to the
`rules_json` schema field, which the model still reads and the form does
not cap.
- `chatgpt-app-submission.json` is the submission-import file the portal
accepts, carrying the listing metadata, the per-tool hints with their
required justifications, and the positive/negative test cases.
## Testing
`tests/Unit/Mcp/ToolAnnotationsTest.php` pins both contracts: every tool
declares all three hints with `readOnlyHint`/`destructiveHint` matching
the expected tool lists, and no description exceeds 200 characters.
`tests/Feature/Mcp` still passes.
## Why
Publishing the MCP server to the ChatGPT app directory requires proving
we own the host that serves it. The submission portal issues a token and
fetches `https://whisper.money/.well-known/openai-apps-challenge`,
expecting the bare token back. That path currently 404s.
## What
- `GET /.well-known/openai-apps-challenge` returns
`OPENAI_APPS_CHALLENGE` as `text/plain`, nothing else — no JSON
envelope, no extra tokens.
- Aborts with 404 when the token is unset, so a host without the
variable configured cannot answer with an empty body that the verifier
would read as a mismatched token.
- Token lives in config/env rather than the repo: it is per-plugin and
rotates independently of the code.
## Testing
`tests/Feature/OpenAiAppsChallengeTest.php` covers both branches: the
token is served verbatim when configured, and the route 404s when it is
not.
The production env var is already set, so the endpoint answers once this
deploys.
## What
Replaces Canny (`whisper-money.canny.io`) with UserJot
(`whispermoney.userjot.com`) everywhere it is linked in the app.
| Surface | Before | After |
| --- | --- | --- |
| User dropdown → **Feedback** |
`whisper-money.canny.io/feature-requests` | `whispermoney.userjot.com/`
|
| User dropdown → **Roadmap** | `whisper-money.canny.io/` |
`whispermoney.userjot.com/roadmap` |
| Jan 2026 update email | both Canny URLs | `/roadmap` + root |
Canny's board root was the feedback list and `/feature-requests` the
submission board; UserJot inverts that. The root is the cross-board "All
Feedback" feed — it covers both the Features and Bugs boards and carries
the *Give Feedback* button, so it is a superset of the old
`/feature-requests` destination. `/roadmap` is a real dedicated page,
which Canny never had.
A repo-wide grep confirms no `canny` reference remains. Nothing else
linked it — not the README, the landing page, the privacy/terms pages,
or the drip emails.
The new vitest case pins both hrefs, so an accidental revert fails the
build.
## Testing
- `user-menu-content.test.tsx` — 3/3 green.
- Browser QA on the running app: logged in, opened the dropdown, clicked
**Feedback** and **Roadmap**, and confirmed each opens the right UserJot
page in a new tab (`rel="noopener noreferrer"` intact). Repeated at a
390×844 mobile viewport. See the demo below.
## Demo
<!-- PLACEHOLDER: drag the video here -->
## Follow-ups (outside this repo)
- **The UserJot workspace still has demo seed posts.** "Shared access
for family accounts" (Emily Roberts) is the only card under **In
Progress**, so every user clicking Roadmap sees a fake item presented as
actively being built — and it duplicates the real imported post
"Multi-tenant support for families or companies". "Custom tags for
transactions" (Sam Wilson) is in the feed too. Worth deleting before
this ships.
- **Canny is still live** and still serves its 17 posts. It has no URL
redirect feature, so already-sent emails, Discord pins and search
results will keep landing there. Close or rename the boards with a
pointer to UserJot, and reconcile the import (Canny 17 vs UserJot
Planned 5 / In Progress 1 / Done 11) before decommissioning.
- **The Bugs board has no entry point.** The Support dialog still routes
bug reports to Discord/email, so `/board/bugs` sits empty. Either point
Support at it or drop the board.
- **No SSO.** UserJot supports JWT identification; without it users need
a second account to vote. Same as Canny, but the migration is the
natural moment to add it.
## Why
You could not add a transaction by hand to a bank-connected account.
There was no good reason for it: bank sync **only inserts** rows it has
not seen before (dedup runs on `dedup_fingerprint` /
`external_transaction_id`, both `null` on manual rows) and **never
deletes or updates**, so a hand-entered transaction survives every later
sync untouched.
The one thing that genuinely does not make sense is letting a user set a
**balance** on a connected account, because the next sync overwrites it.
That restriction stays.
## What was actually blocking it
Less than it looked. The HTTP endpoint already allowed it,
`ManualBalanceAdjuster` already skipped connected accounts, and the
transaction dialog already handled them (it forces `updateBalance:
false`). Only two surfaces blocked it:
- **MCP** — `WriteTool::writableAccount()` rejected every connected
account for *all* writes. Split into `accountInSpace()` (no connection
check — used by `create_transaction` / `update_transaction`) and
`balanceWritableAccount()` (still rejects connected — used by
`create_balance`).
- **The account detail page** — hid its "Add transaction" button for
connected accounts, even though the same dialog on the transactions page
already offered them in its account picker.
## Also in here
Two problems the change surfaced, both fixed:
- **`EnableBankingSyncer` linked-account watermark** took the newest
transaction of *any* source. With manual rows now able to land on a
connected account, one dated later than the bank's last posting would
shrink the fetch window and skip the bank history in between —
permanently, since the watermark only moves forward. On the QA data this
would have skipped **36 days**. Now restricted to bank-sourced rows.
- **`calculateHistoricalBalances`** derives history by walking back from
a bank-provided reference balance, summing transactions unfiltered.
Counting a hand-entered row subtracts money the bank never had. Now
walks bank-sourced rows only. (Safe before only because manual rows
could not reach a connected account.)
Plus the honesty/copy work:
- `ManualBalanceAdjuster` returns whether it shifted anything, so
`create` / `update` / `delete_transaction` all report `balance_updated`
instead of silently no-opping and letting the agent claim a balance
moved.
- The **OAuth consent screen** and the AI Connector settings page said
"bank-connected accounts stay read-only". That was a trust statement,
and it is no longer true — both now say bank-*synced transactions*
cannot be edited or deleted and connected balances stay untouched.
- `create_balance`'s description no longer contradicts the server
instructions shipped alongside it.
- The transaction dialog now *explains* why the "Update account balance"
checkbox is absent on a connected account instead of just hiding it (it
defaults to on and is localStorage-persisted, so it used to vanish
mid-form with no reason given).
## What stays blocked
- Balances on connected accounts — MCP `create_balance` rejects them,
the adjuster no-ops, the UI shows the explanation instead of the
checkbox.
- Editing or deleting bank/imported transactions — still gated on
`source === manually_created`, unchanged.
## Testing
- `create_transaction` on a connected account leaves its balances alone;
moving a transaction onto a connected account unwinds only the manual
side it came from.
- A manual row survives a sync and does not block the bank's own rows —
the invariant the whole change rests on, previously untested.
- A manual transaction does not move the linked-account sync window.
- The account page offers "Add transaction" on connected accounts, and
hides it on non-transactional ones.
- Full suite: 2047 passing, phpstan clean.
QA'd in the browser end to end (create from a connected account, edit it
afterwards, contrast with a manual account, switch accounts mid-form)
and over real MCP calls against the running server (`create_transaction`
returned `balance_updated: false` on connected / `true` on manual;
`create_balance` refused the connected account).
## Demo
https://github.com/user-attachments/assets/7a7f1cbb-8f68-402d-ba35-3288dd7ea77f
<!-- PLACEHOLDER: drag the video here -->
## Problem
A paying user reported that "Save & Sync" on the account mapping screen
did nothing — no error, no navigation, nothing.
EnableBanking returned their Société Générale card (`CB Visa`) with
`uid: null`:
```json
{"uid": null, "name": "CB Visa", "cash_account_type": "CARD", "account_id": {"iban": null, "other": {"scheme_name": "CPAN", "identification": "************8210"}}}
```
The page renders one mapping row per pending account, so the form posted
`bank_account_uid: null`. `MapAccountsRequest` requires it, the request
422'd, and since the page never rendered validation errors the button
looked dead. The user retried the connection six times.
A second problem kept them from doing what they actually wanted: both
accounts reported `currency: "XXX"` — ISO 4217 for "no currency".
`getCompatibleAccounts` filtered their existing EUR accounts against
`"XXX"`, matched nothing, and the "Link to existing account" option was
never rendered. The backend already knew `XXX` isn't a currency
(`AccountUserCurrencyService::resolveImportedCurrency`); the frontend
didn't.
## Fix
- Accounts without a uid are filtered out of the mapping screen. They
can never be synced anyway — every sync service early-returns on an
empty `external_account_id` — and `CreatesAccountsFromPending` already
skipped them during onboarding. That rule now lives in one place,
`BankingConnection::mappablePendingAccounts()`.
- The skipped accounts are named on the page, so users don't go hunting
for an account they can see in their bank app.
- A connection where *no* account has a uid is closed out instead of
parking on a mapping page that could only ever 422.
- Validation errors surface as a toast, with app-authored messages in
`MapAccountsRequest::messages()` instead of raw field paths.
- `XXX` (and a blank/lowercase variant) is treated as "unknown
currency": the code isn't displayed, and it no longer filters out every
linkable account.
## Scope
One user affected in production (the reporter). Their connection has one
valid account alongside the card, so this fix unblocks them on deploy —
no data change needed.
## Testing
- `tests/Feature/OpenBanking` — 315 passed, including two new cases:
uid-less accounts are hidden and named, and an all-uid-less connection
is closed rather than left awaiting mapping.
- Browser QA against a local reproduction of the exact production
payload: only `Compte Bancaire` is offered, `CB Visa` is named as
skipped, "Link to existing account" appears despite the `XXX` currency,
submitting without picking an account toasts the error, and picking
`Dany SG` links it (`external_account_id` set, connection `active`).
## Demo
https://github.com/user-attachments/assets/6275834a-a518-47b9-9015-a698e5926d71
<!-- PLACEHOLDER: drag account-mapping-fix-qa.mp4 here -->
## Not in this PR
`AuthorizationController::refreshAccountIds()` consumes the raw pending
list on reconnect; its positional fallback can pair a legacy account
with a uid-less entry. Not reachable for any account in production
today, it belongs to a different flow, and it deserves its own test —
filed separately rather than smuggled in here.
Reported by a user who spent over an hour and a half on the onboarding
syncing step, on two devices, without ever getting into the app.
## What was happening
When Enable Banking rate limits a connection, `SyncBankingConnectionJob`
records the error and backs off, but leaves the connection **Active with
`last_synced_at` still NULL** — the exact shape `syncStatus` treated as
"still syncing". The step polled that endpoint forever, and there was no
deadline or escape on the client.
Confirmed in production for the reporter: a Trade Republic connection
returned `429` on every scheduled sync for two days straight. They
unblocked themselves by deleting the connection — `onboarded_at` was set
four seconds later. Three other users are in the same state right now,
one of them from today.
## What changed
- **`syncStatus` no longer waits on a connection that already failed**,
and reports it as `failed` separately from `pending`.
- **The step says so instead of pretending it worked.** Advancing
silently dropped the user on a "You are all set!" screen with an empty
dashboard and no way to find out why. Now they get a short explanation
and a Continue button.
- **Client deadline raised to 5 minutes** — the previous 2 was under the
worst case of a healthy first sync (3 attempts of a 120s job, 30s apart)
— and the status request now has a timeout, so a hanging poll can't
stall the step either. A failing status check keeps polling until the
deadline rather than skipping the user ahead.
- **`AccountMappingController` clears the stale error** when it
reactivates a connection, so a genuine sync in flight isn't reported as
failed.
## Testing
- Feature tests for the rate-limited case, for a healthy connection
syncing alongside a failed one, and for the failed flag; a
`rateLimited()` factory state replaces the hand-rolled attributes.
- Vitest coverage for the three client branches: failed, deadline
reached, and the normal advance.
- Browser QA against the real app, reproducing the production state: the
honest screen appears instead of the spinner, Continue moves on, the
user completes onboarding and reaches the app; and a genuinely pending
sync still spins and then advances by itself once the connection syncs.
No console errors.
## Demo
https://github.com/user-attachments/assets/e54e029d-db7a-47b0-8b93-0da5569aa363
<!-- PLACEHOLDER: drag the QA video here -->
## Not in this PR
`settings/connections.tsx` has the same `active && !last_synced_at` =
"syncing" heuristic, so a rate-limited connection shows a permanent
green "Syncing" badge there and its error is never rendered (the error
block is gated on `status === 'error'`). Same bug class, separate
surface — worth a follow-up.
## Why
A support ticket: Bankinter transactions arrive with the raw ISO 20022
remittance tag in front of the text, so AI categorization reads the tag
instead of the merchant.
```
/TXT/D|SumUp *GELATERIA SALV
/TXT/H|TRANSF NOMI /AIGUA DE RIGAT, S ← the payroll that got categorized as Fuel
/TXT/CONDIS SANT JUST DESV07/07/26|20260714
```
`/TXT/` is the unstructured remittance tag, `D|`/`H|` is the debe/haber
marker (which only repeats the sign of the amount), and card payments
append the purchase and settlement dates. None of it describes the
transaction.
In production this hits **7297 transactions across 20 users** —
Bankinter (6569) and Unicaja Banco (728), which ships the same shape.
## What
**`RemittanceTagFormatter`** strips the tag, the credit/debit marker,
the card dates and the `#` marker on card charges. The tag identifies
itself, so the formatter is keyed on the **description** rather than on
a bank name — it works for any bank shipping the same shape instead of
needing a new class per bank. `BankFormatter::matches()` now takes the
description as well; `BbvaFormatter` keeps matching on the bank name.
**`banking:backfill-descriptions`** fixes the rows that are already
imported. It also rewrites the automation rules that match on the raw
text: **48 user-authored rules across 4 users** contain literals like
`/TXT/D|RECIBO VISA CLASICA`, and rewriting descriptions without
rewriting those rules would silently stop them from ever matching again.
A test pins that a rule still matches its transaction after both are
rewritten.
```
banking:backfill-descriptions [--user=email] [--dry-run] [-v]
```
## QA
Ran the formatter over **all 2755 distinct tagged descriptions in
production**: 0 no-ops, 0 leftover tags/dates/markers, 0 degenerate
output.
Ran the command against a database seeded with production-shaped rows
and rules:
```
===== DRY RUN (-v) =====
DRY RUN — no changes will be saved.
/TXT/D|SumUp *GELATERIA SALV → SumUp *GELATERIA SALV
/TXT/H|TRANSF NOMI /AIGUA DE RIGAT, S → TRANSF NOMI /AIGUA DE RIGAT, S
/TXT/EL CLANDESTI 27/07/26|20260803 → EL CLANDESTI
/TXT/CONDIS SANT JUST DESV07/07/26|20260714 → CONDIS SANT JUST DESV
/TXT/RECIBO MES TARJETA|20260806 → RECIBO MES TARJETA
/TXT/D|#RECOBRO RECIBO VISA → RECOBRO RECIBO VISA
/TXT/OPENAI *CHATGPT SUBSCR MP → OPENAI *CHATGPT SUBSCR MP
rule …080: {"in":["RECIBO VISA CLASICA",{"var":"description"}]}
8 transaction(s) and 2 automation rule(s) would be reformatted.
===== SECOND RUN =====
0 transaction(s) and 0 automation rule(s) reformatted. ← idempotent
```
The raw text is kept in `original_description`; an already-stored
original is never overwritten; untagged descriptions and rules are left
alone; transactions the server cannot read (`description_iv`) are
skipped.
## Not in this PR
**Already-categorized transactions keep their category.** The backfill
fixes the text, not the past AI decisions — 4005 of the affected rows
already have a category (1024 of them AI-sourced), and
`ai:categorize-backfill` only picks up uncategorized ones.
Rule-categorized rows are unaffected because the rules are migrated. If
we want the AI ones re-run, that's a separate deliberate step: clear
`category_source = 'ai'` on the affected rows, then run the existing
backfill command.
## Rollout
```bash
php artisan banking:backfill-descriptions --dry-run -v # inspect
php artisan banking:backfill-descriptions # apply
```
## The bug
Sentry
[PHP-LARAVEL-53](https://whisper-money.sentry.io/issues/PHP-LARAVEL-53)
— `SQLSTATE[22001]: Data too long for column 'title'` on `PATCH
/transactions/{transaction}`. 6 events, 1 user.
Changing a transaction's category learns a forward-looking automation
rule, and `AiRuleLearner::title()` rebuilds the rule's title from the
merchant names it matches. This user's bank puts the **entire statement
line** in `creditor_name`:
```
15/06 12/06 Pago Con Tarjeta En Discos, Libros, Fotos Y Pc's -59,00 189,27 | 4940197152806468 Classpass* Monthly
```
Two corrections into the same category produced a 279-char title against
a `varchar(255)` column, so `$rule->save()` threw and took the whole
request with it. Since `creditor_name` is itself capped at 255 on
ingest, a single correction can overflow it too.
## The fix
1. **`AiRuleLearner::title()`** caps each merchant token (40 chars) and
the category name (60), so the title stays readable instead of merely
short — the `→ Category` tail is the part that carries the meaning.
2. **A `title` mutator on `AutomationRule`** truncates to the column
width. Both reviews flagged that fixing the learner alone leaves the
sibling generator, `ApplyRuleSuggestions::title()`, unguarded: it joins
three AI match tokens that are each validated at `max:255`, so it can
overflow the same column and 500 the accept-suggestions step **during
onboarding**. The mutator covers every generator, present and future, in
one place. User-authored titles never reach it —
`StoreAutomationRuleRequest` already rejects longer ones with a 422.
3. **Learning can no longer abort the correction.**
`CategoryOverrideHandler::record()` runs before the transaction is saved
and outside any DB transaction, so the incident left the correction
logged and the ai rules stripped while the category the user asked for
was never written. `bulkUpdate` is worse: it records every transaction
before the mass update, so one throw mutated rules for the earlier ones
and categorized none of them. A learning failure is now reported and
swallowed — both callers already handle "nothing learned".
## Tests
- `AiRuleLearnerTest` — replays the exact production payload; reproduces
the identical `SQLSTATE[22001]` on the pre-fix code and asserts the
title stays inside the column with the category still visible.
- `AutomationRuleTest` — the mutator truncates rather than failing the
write.
- `CategoryOverrideHandlerTest` — a throwing learner is reported, not
propagated.
191 tests green locally (`tests/Feature/Ai` + the three automation-rule
suites) against the MySQL testcontainer; `pint --test` clean.
## Deliberately out of scope
- **Dead-weight rules.** With such a merchant name the learned clause is
`creditor_name == "…-59,00 189,27 | …"` — it contains amounts and a
running balance, so it can never match again, yet it accumulates one
clause per correction and the UI toasts *"Learned: similar transactions
will be categorized automatically"*. The review's prod data says length
is the wrong guard (62 long merchant names DO repeat, across 490
transactions); the right one is rejecting the statement-line *signature*
(an embedded `dd/mm` or `nn,nn`) in `merchantKey()` so it falls through
to the already-guarded description-token path. Worth its own PR.
- **Rules list UI**: a long title sits in a `whitespace-nowrap` cell and
pushes the actions column off-screen (`settings/automation-rules.tsx`).
- **Unbounded writes of the same class elsewhere**, found while
reviewing: `transactions.currency_code` `varchar(3)` written unvalidated
from the Enable Banking payload (`TransactionSyncService.php:179`), and
`banking_connections.aspsp_name`/`aspsp_logo` with no `max:` rule in
`StartAuthorizationRequest`.
Fixes PHP-LARAVEL-53