`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
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
`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.
## 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
`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
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 -->
## 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
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.
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.
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
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.
## 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
> **Draft on purpose.** The migration can put connections into
`Expired`, which sends `BankingConnectionExpiredEmail` to real users —
up to ~18 of them shortly after deploy. That is the right outcome (they
have been broken for weeks without being told), but outbound email to
real users should be a human's call, not an autonomous one. Everything
else here is ready.
## The bug
Sentry
[PHP-LARAVEL-3J](https://whisper-money.sentry.io/issues/PHP-LARAVEL-3J)
— `RequestException: HTTP 403` in
`EnableBankingProvider::getTransactions`. 45 events, 10 users,
regressed.
`EnableBankingProvider` classifies known upstream failures into domain
exceptions and rethrows the rest as a bare `RequestException`. Two
responses were unclassified:
- `401 {"error":"CLOSED_SESSION"}`
- `403 {"detail":{"error_name":"PsuActionRequiredException"}}`
`SyncBankingConnectionJob` reads any bare 401/403 as an auth failure:
the connection goes to `Error` with `consecutive_sync_failures =
MAX_SCHEDULED_RETRIES + 1`. Both the scheduler and `banking:sync` filter
that out, so **the connection is never dispatched again**. Enable
Banking's syncer has `notifiesOnAuthFailure() === false`, and the
"reconnect your bank" notice only counts `Expired` connections — so
nothing told the user. Their bank sync was dead, silently.
Prod confirms the damage: **18 connections across 17 users** stranded
that way, 14 with this bug's exact error message, the oldest not synced
since **2026-05-06**.
## What each response now does
**`401 CLOSED_SESSION` → expired session.** The session is genuinely
gone (revoked at the bank, or superseded by a newer authorization) and
only the user can fix it. It now joins `EXPIRED_SESSION`: connection
marked `Expired`, reconnect email sent, notice shown, not reported as an
application error.
**`403 PsuActionRequiredException` → transient.** My first pass expired
these too; the product review pulled the prod data and it says
otherwise. The nine connections that hit this on 2026-08-05 had all
synced cleanly at 18:00 the evening before, failed inside the same
**nine-minute** window, all had months of consent left, and none has
recurred in the four days since. Nine users at nine different banks do
not revoke consent inside nine minutes — that is the provider faulting
behind a generic `"Internal error."` envelope.
Expiring is a one-way door: expired connections are never dispatched,
manual sync is refused, and the only way out is a full reauthorization
with SCA. Expiring on first sight would have pushed nine users through a
re-consent they did not need. So it is classified transient: the
connection stays schedulable and self-heals next cycle, and the error
logs as a warning instead of being reported. If a PSU action really is
required, the consent lapses on its own and the 401 path takes over.
A 403 or 401 the bank sends for any other reason still surfaces exactly
as before — tested, because widening either check to "any 401/403" would
silently expire every connection during a credentials bug of our own.
## Rescuing the stranded connections
The classification fix cannot reach the 18 already-parked rows: they are
excluded from dispatch before any of this code runs. The migration
clears their failure counter so the next scheduled cycle re-evaluates
them through the fixed path — they resume, or they expire properly, with
the email and the notice the user should have had months ago.
Also: the reconnect email's five keys were missing from `lang/es.json`,
so Spanish users got it in English. Unenforced because the localization
test only scans `resources/js`.
## Tests
`tests/Feature/OpenBanking` — 319 green, including 6 new provider cases
(both new codes on `getTransactions` and `getBalances`, plus negative
cases for an unrecognised 401 code, a bare 403, and a non-object
`detail`). The job-level behaviour — `Expired` + email + not reported —
is already covered end to end in `SyncRetryAndLoggingTest`, through the
real syncer.
## Follow-ups (not in this PR)
- **`Expired` badge next to a future "Expires:" date.** `markExpired()`
doesn't touch `valid_until`, and this fix expires connections whose
window hasn't lapsed, so `settings/connections.tsx:411` will show both.
Hide the line for expired connections or backdate the field.
- **Forensics gap.** `logSyncAttempt` records no
`error_class`/`error_message` on the expired path, so a provider-wide
wave becomes indistinguishable from ordinary consent churn in
`banking_sync_logs` — that is exactly the data that disproved my first
reading here.
- **Reconnect loop.** `AuthorizationController` sets the connection back
to `Active` and syncs immediately; if the bank refuses again the user
gets a second "reconnect" email seconds later.
- **`banking:sync --connection=<id>` obeys the health filter**, so ops
has no escape hatch for a stranded connection.
- **Duplicated catch ladders** in `getTransactions`/`getBalances` — the
shape that let a code get classified in one and not the other.
Fixes PHP-LARAVEL-3J
## Why
The analysis drawer was built for **bounded** filters — a trip, a
project — where "total spent" and "avg / day" are the answer.
Filter by something open-ended instead, like a single category, and it
surfaces a year or two of expenses. There both numbers stop meaning
anything: the cumulative line only ever climbs, `€12.32 / day` is not a
figure anyone decides on, and the largest-expenses table fills with the
same monthly transfer repeated five times.
## What
A fourth analysis view that answers the open-ended question instead:
**the monthly rate, and where the recent months sit against it**.
Automatic picks it past a 120-day span; the existing per-filter override
now carries four options, each named after the number it produces.
`Automatic` · `Total spent` · `Income & expenses` · **`Monthly trend`**
| Panel | Bounded view | Monthly trend |
|---|---|---|
| Rate | Total spent + avg / day | **Monthly average** and **last 3
months** with the change between them |
| Chart | Bars + cumulative line | **Monthly bars**, dashed line at the
average, no cumulative |
| Largest expenses | top 10 | **not rendered** — across an open-ended
span the biggest single transaction is trivia, and the payee and
category breakdowns already answer where the money went |
| Category · payee · account · label | unchanged | unchanged |
Footer names the months covered rather than counting days. When income
is a meaningful share the cards report net figures and the chart adds
income bars.
### Only whole months count
Every monthly figure covers calendar months the series spans end to end.
Both edges would otherwise drag the average down, in the same direction:
- The **trailing** month is still in progress — on the 2nd it holds two
days of spending.
- The **leading** month is partial whenever the series does not start on
its 1st, whether a filter clipped it or the first transaction simply
landed on the 18th.
Skipping only the trailing one manufactured a rise: flat €300/month over
a 120-day span starting 18 March reported **+16%** in amber on spending
that never changed. Both are excluded now, which is what the one new
backend field (`summary.first_date`) is for. The part-month bars are
faded rather than dropped, so the solid bars are exactly the months
behind the average.
The average card states how many whole months it spans and the
percentage says it is against the average — two figures that otherwise
silently covered less than they appeared to.
### Edge cases
- Forcing the view onto a span with no whole month falls back to the
bounded shape, toggle label included, and the popover says why.
- The recent card is hidden when whole months do not outnumber its own
window, rather than repeating the overall average.
- A **day override** is the user stating the real duration, so it
decides the automatic view too: a trip whose transactions posted over
eight months stays bounded instead of jumping to trend and leaving that
override inert.
## Cost
**Backend: one enum case and one summary field.** No migration —
`analysis_mode` is already a nullable string behind `Rule::enum`. Every
other figure is derived in the browser from the `over_time` series the
endpoint already returned.
## Tests
`resolveAnalysisView`, `monthlyRates` and `isAdverseChange` are pure and
unit-tested directly — the part-month cutoff, the change against a
negative average, the zero-average guard, and the adverse-direction rule
that inverts for net. 32 component tests, plus feature coverage for the
new field and the persisted mode.
## QA
Browser QA against a year of grocery spending on the demo account (352
transactions, July 2025 – July 2026): the trend view resolves
automatically, all four options switch correctly, and a 30-day date
filter still lands on the bounded view. 22 assertions, no console
errors.
## Demo
https://github.com/user-attachments/assets/f4a14349-3d58-4da4-b85c-9a4ca076551f
Budget email notifications shipped opt-in, then #733 turned all three on
by default. The per-transaction notice is the wrong one to force on: it
emails on **every single transaction** assigned to a budget, and a
catch-all budget matches all of them. This puts it back to opt-in and
leaves the limit alerts (close to limit, over limit) on by default.
## Why a second migration instead of editing the first one
`2026_07_24_100000_enable_budget_notifications_by_default` **has already
run in production** (batch 76). Laravel never re-runs an applied
migration, so editing it in place would have been inert: 382 budgets and
174 settings rows would keep the flag on, and the `user_settings` column
default would stay `true`.
That default matters beyond new signups: every settings controller
writes its own column through `setting()->updateOrCreate(...)`, so a
user who merely changed their chart colour scheme would get a settings
row with `budget_notify_on_new_transaction = 1` filled in by MySQL —
silently opted in, with the checkbox rendering as checked.
So the original migration is restored to its merged form and a new one
resets the flag: column default back to `false`, plus a one-off reset of
existing `user_settings` and `budgets` rows. It uses the query builder,
so `updated_at` is untouched — same as the force-enable did. 381 of the
382 affected budgets had not been touched by their owner since the
deploy, so the reset undoes a force-enable rather than a user's choice.
## Changes
- New migration resetting
`user_settings.budget_notify_on_new_transaction` (default + rows) and
`budgets.notify_on_new_transaction`.
- `BudgetController::store` and
`NotificationPreferenceController::index` fall back to `false` for the
per-transaction flag when a user has no settings row.
- `UserSettingFactory` matches the new default.
Users who want the per-transaction notice can still turn it on per
budget, or as their default for new budgets, in Settings →
Notifications.
## QA
Ran the migration against a real database and checked the end state:
`budget_notify_on_new_transaction` default `0` with all 151 settings
rows and 333 budgets reset, both limit flags default `1` and left on.
Then drove real transactions through the listener and queue on a live
budget (limit €300) and read the resulting mail off Mailhog:
| Event | Spent / limit | Email |
| --- | --- | --- |
| New transaction, default settings | €50 / €300 | none |
| Reaches 90% | €270 / €300 | «Presupuesto de Ocio» está cerca de su
límite |
| Exceeds the limit | €320 / €300 | «Presupuesto de Ocio» ha superado su
límite |
| New transaction, after opting in | €10 / €5000 | Nueva transacción en
«Presupuesto de Ocio» |
Assignment still happens in every case — only the email is suppressed.
265 tests green across the Budget/Setting/Notification suites, including
new assertions that the rendered defaults have the per-transaction flag
off and that a settings row created without the budget columns does not
opt the user in.
## What
Follow-up to #731 (per-budget email notifications). Two changes:
1. **Enable budget email notifications by default.** They shipped opt-in
with every toggle defaulting to `false`, so no one received them. They
are now on by default.
2. **Coalesce budget alerts into one email per transaction.** A single
transaction that crossed a budget's limit could send two emails for the
same budget (the "new transaction" notice *and* the over/close-limit
alert). Now at most one email is sent per budget per assignment.
## Enable by default
- `user_settings.budget_notify_on_*` column defaults flipped to `true`,
and **all existing `user_settings` and `budgets` rows are backfilled to
`true`** so current users and budgets start notifying — not just new
ones.
- `BudgetController::store` and
`NotificationPreferenceController::index` fall back to `true` when a
user has no settings row yet.
- `UserSettingFactory` reflects the new default.
- The `budgets` columns keep their own `false` DB default on purpose:
new budgets are seeded from the user's `user_settings` defaults at
creation, so only the one-off backfill is needed there.
## Coalescing
`BudgetNotificationService::processPeriod` now sends **at most one email
per budget per transaction assignment**, picking the most severe
applicable event with priority **over-limit > close-to-limit >
new-transaction**, and passes the triggering transaction through to
whichever fires. So a transaction that pushes a budget over its limit
produces a single over-limit email that still names the transaction and
shows how far over the budget is.
Preserved from #731: the atomic per-period `close_to_limit_notified` /
`over_limit_notified` claim (one email per crossing, safe under
concurrent workers), the reset-and-re-notify path when spending drops
back below the threshold, and the no-emails-on-historical-backfill
guarantee. Coalescing is **per budget** — a transaction matching two
different budgets still sends one email to each.
## Trade-offs (deliberate)
- **`notify_on_new_transaction` is on by default**, which means one
email per transaction assigned to any budget (no batching yet — flagged
in #731). A catch-all budget matches every expense, so this can be
high-volume; the whole-user-base backfill also produces a send spike on
first sync after deploy. Batching the new-transaction type into a daily
digest (mirroring the bank-sync digest) is the natural follow-up if
volume proves noisy.
- The migration backfill is intentionally unconditional and one-way for
data: `down()` restores the column defaults but not the backfilled row
values.
## Every email links to the settings page
Each budget email already carries a "Manage notifications in
notification settings" link (`route('notifications.index')`) so users
can turn any of them off. A render-test assertion now locks this in.
## Tests
- Coalescing: a single limit-crossing transaction sends exactly one
over-limit email that references the triggering transaction.
- On-by-default: a new budget created without custom preferences enables
all three types.
- All existing #731 sending/preference tests still pass (dedup,
re-notify after drop, no-limit budgets, historical backfill sends
nothing, enabling while already over).
QA (backend): rendered the coalesced over-limit email and confirmed it
names the transaction, shows "Over by", and links to the settings page;
verified the migration backfill flips pre-existing `false` rows to
`true` against a real DB.
## Problem
The production image runs the queue workers via supervisor but **nothing
runs the Laravel scheduler** (there is no `schedule:work` program and no
cron running `schedule:run`). As a result, none of the commands in
`routes/console.php` ever fire automatically — most notably
`banking:sync` (every 6h), so self-hosted users have to trigger bank
synchronization by hand. The manual "sync" button works because it
dispatches the same job to the queue, which the existing workers pick
up; only the scheduled trigger was missing.
## Changes
- **`docker/supervisor/supervisord.conf`**: add a `[program:scheduler]`
running `php artisan schedule:work`, mirroring the existing worker
programs (autostart/autorestart, dedicated log). This is what makes
every scheduled command fire on its own cadence.
- **Gate the operator-only stats reports** (`stats:daily-report`,
`stats:subscription-funnel`, `stats:ai-cohort-report`,
`stats:experiment-funnel`) behind `config('subscriptions.enabled')` with
an early return. These are subscription-conversion telemetry destined
for the admin Discord; on a self-hosted instance
(`subscriptions.enabled=false`) `stats:daily-report` would otherwise
make an unnecessary live Stripe API call, and the others would build
reports for an unconfigured Discord channel. On the SaaS (subscriptions
enabled) behavior is unchanged.
The guard lives **inside each command** (not in `routes/console.php`) so
it stays unit-testable. `banking:cancel-free-enablebanking` was left
alone (already a safe no-op via `hasProPlan()` when subscriptions are
off), as were the drip-email commands (already gated by
`mail.drip_emails_enabled`).
## Testing
- `vendor/bin/pint --test` passes.
- New Pest test per gated command asserting it skips and makes no
external call when subscriptions are disabled; existing tests updated to
enable subscriptions so they still exercise the real path.
- Full suite green except 5 unrelated pre-existing environment failures
(timezone alias, dashboard 409, Passport OAuth key) that fail
identically on `main`.
- Verified end-to-end with a real supervisor run: the `scheduler`
program reaches `RUNNING`, `schedule:work` boots, and autorestart
resurrects it; `php artisan schedule:test --name=banking:sync`
dispatches successfully.
> Takes effect after rebuilding the production image.
## What
Adds opt-in **email notifications per budget** for three events, plus a
dedicated **Notifications** settings page.
Each budget can independently enable:
- **New transaction** — a transaction was assigned to the budget
- **Close to limit** — spending crossed 90% of the limit
- **Over limit** — spending reached/passed the limit
A **Default (new budgets)** row lets you set the toggles that newly
created budgets inherit.
The existing *bank-transactions-synced* email toggle was **moved** from
the account page into this new Notifications page, so all email
preferences live in one place.
## UI
`Settings → Notifications`: one "Email notifications" section containing
the bank-sync toggle and a "Budgets" table (rows = Default + each
budget, columns = the three events).
<img width="1265" height="638" alt="budget-notifications-settings"
src="https://github.com/user-attachments/assets/b8386410-98dd-4a5e-9dee-6e5bf7effa31"
/>
## How it works
- Notifications fire from the **live transaction-assignment path**
(`BudgetTransactionService::assignTransaction`, already a queued
listener), never from historical backfill when a budget is created.
- Only the budget's **current period** is considered (the emails
describe the live "available before the limit" state).
- **New transaction** fires for a genuinely newly-assigned transaction;
the email includes the transaction.
- **Close/over** use two atomic per-period flags
(`close_to_limit_notified` / `over_limit_notified`) flipped with a
compare-and-set `UPDATE`, so exactly one email is sent per crossing even
under concurrent queue workers. The flags reset once spending drops back
below the close threshold, so a later crossing can notify again.
- Budgets with no limit (`allocated_amount <= 0`) never send close/over
emails; the "new transaction" email for such a budget omits the
limit/available rows.
- Every email shows the budget's current status (period, spent, limit,
available or over-by). Subcopy links back to the Notifications page.
- New budgets inherit the user's default toggles at creation time.
## Data model
- `budgets`: `notify_on_new_transaction`, `notify_on_close_to_limit`,
`notify_on_over_limit` (default `false`)
- `user_settings`: `budget_notify_on_*` defaults for new budgets
(default `false`)
- `budget_periods`: `close_to_limit_notified`, `over_limit_notified`
(dedup flags)
## Deliberate decisions
- **Threshold is a fixed 90%** for "close to limit" (not configurable) —
kept simple; easy to make per-budget later if requested.
- **"New transaction" sends one email per assigned transaction.** It is
opt-in and defaults to off. A bank sync importing many matching
transactions into an opted-in budget will therefore send several emails;
if this proves noisy we can batch per sync run (mirroring the daily
bank-sync digest). Flagged here rather than pre-building batching.
- **Limit basis is `allocated_amount`** (matches the budget
cards/spending chart), carry-over excluded.
## Tests
- Preference endpoints: page renders budgets + defaults, per-budget
toggle update, cross-user update forbidden, user-settings defaults
update, new budget inherits defaults.
- Sending: new-transaction opt-in/out, over-limit send, close-limit at
90%, no-resend dedup, re-notify after dropping below and crossing again,
no-limit budget skips close/over, historical backfill sends nothing,
notify after enabling the preference while already over. Plus a render
test covering all three email variants.
## Notes
- CI-enforced `es.json` keys for the new page and emails are included.
- All new PHP follows existing mail/queue conventions (`ShouldQueue`
mailable, `emails` queue, `RateLimited` middleware).
## What
Adds an Artisan command to delete a specific user's AI consent by email:
```bash
php artisan ai:delete-consent user@example.com
```
It looks up the user by email and **hard-deletes** all their AI consent
records — as if they had never granted it (no `revoked_at` trail left
behind). Since `AiCategorizationGate` requires `hasActiveAiConsent()`,
AI stops being used for that account immediately (queued categorization
jobs re-check the gate at execution time and early-return).
The command is idempotent: it reports "nothing to do" when there's no
consent on record and fails cleanly when the email doesn't exist.
## Why
Two goals:
1. **Stop using AI for a given user** on request, with no lingering
consent record.
2. **Let users who enabled AI by mistake fall back to a free plan.**
Non-Pro users with active AI consent are pushed toward the paid plan by
`EnsureUserIsSubscribed`. Deleting consent moves them back into the
free-plan-eligible bucket. This billing-gating change is **intended**,
not a side effect.
## Notes / scope decisions
- **Hard delete, not revoke.** Removes every consent row for the user
(all scopes/versions) so there's no trace they ever consented.
- **Prompt flag untouched.** `ai_consent_prompt_dismissed_at` is left
as-is, so the in-app consent banner does not re-appear to nudge them
back on.
- **Live users only.** Deleted (soft-deleted) accounts are out of scope
— they can't use the app, so their consent is moot.
- **No subscription changes.** The command only touches AI consent; it
doesn't cancel Stripe subscriptions (`user:delete` already handles that
separately).
## Tests
New Pest feature test covering all three paths (delete all consent
records incl. a revoked one / no consent on record / user not found).
Green locally.
## What
Adds an artisan command that deletes all transactions and balances of a
specific user's **non-connected** (manual) accounts, identified by
email.
```bash
php artisan user:delete-manual-account-data user@example.com
```
## Behavior
- Looks up the user by email (`withTrashed`, matching `user:delete`).
- Selects accounts with `banking_connection_id IS NULL` (manual
accounts).
- Shows a confirmation prompt with the transaction/balance counts and
the number of affected accounts.
- On confirm, deletes inside a `DB::transaction`:
- Transactions via `forceDelete()` (they use `SoftDeletes`, so this also
purges already soft-deleted rows).
- Balances via `delete()` (`AccountBalance` has no `SoftDeletes`, so
it's a hard delete).
- The accounts themselves are **not** deleted — only their transactions
and balances.
Related `label_transaction`, `budget_transactions` and
`category_corrections` rows are cleaned up by existing
`cascadeOnDelete()` foreign keys.
## Tests
Pest feature test covering: manual-only deletion (connected accounts
untouched), cross-user isolation, soft-deleted transactions counted and
purged, connected-only user reports zero, cancellation, and
user-not-found.
QA skipped per request (backend maintenance command, covered by feature
tests).
## What
Adds label filtering to the `search_transactions` MCP tool. It's the one
filter the web transactions view has that the MCP surface was missing.
- New optional `label_ids` array param. Returns transactions carrying
**any** of the given labels (OR-semantics), matching the web app's label
filter (`Transaction::scopeApplyFilters`).
- Combines with the other filters (account, category, date, amount) with
AND, consistent with how the tool already treats them — so `category_id`
+ `label_ids` means "in that category **and** carrying one of these
labels".
- Each returned transaction now also exposes its `labels` (`id`,
`name`), so the agent can see why a row matched, consistent with what
the write tools already return.
## Validation & consistency
- `label_ids` are validated against the space via `labelsInSpace`,
promoted from `WriteTool` to the shared `McpTool` base so read and write
tools use one implementation. An unknown or stale label id now returns
the same actionable *"call list_labels"* error the rest of the MCP
surface gives, instead of a silent empty result.
- The tool `#[Description]` now mentions `label`, so agents discover the
capability without inspecting each param.
## Tests
- `search_transactions` filters by label id (matching row in, unlabelled
row out).
- A foreign/unknown label id is rejected with an error.
- Full MCP read + write suites stay green (26 tests) — confirms moving
`labelsInSpace` to the base didn't regress the write tools.
Pint, Larastan and the affected Pest suites pass locally.
## Problem
Sentry **PHP-LARAVEL-4Q** fires on POST `/mcp/oauth`: a client presents
a bearer token with a bad/expired/wrong JWT signature ("Token signature
mismatch"), Passport's `TokenGuard` → league/oauth2-server throws
`OAuthServerException::accessDenied` (HTTP **401**). Laravel returns the
correct 401 to the client (`handled: yes`) **but also reports it to
Sentry as an error** — noise, not a server fault. It's recurring (fresh
events after the initial ones).
These OAuth exceptions are, by design, client-facing 4xx protocol
responses (expired/invalid tokens, denied consent, bad grants). Any
public OAuth endpoint — and the MCP endpoint accepts
dynamically-registered Claude/ChatGPT clients — sees a steady stream of
them. They should not page as errors.
`OAuthServerException` does not implement `HttpExceptionInterface`, so
Laravel's built-in `internalDontReport` does not already cover it.
## Fix
Add a `dontReportWhen` rule in `bootstrap/app.php` for
`OAuthServerException` whose HTTP status is **< 500**, mirroring the
existing `MaxAttemptsExceededException` guard right above it.
`serverError` (500) — the only 5xx variant, the genuine "unexpected
condition" case — still reports, so real OAuth-flow faults stay visible.
**Reporting-only change — no user-facing behavior changes.** Clients
still receive the same 4xx OAuth error responses (status/body/headers
unchanged).
## Tests
`tests/Feature/Mcp/McpOAuthTest.php`: assert
`ExceptionHandler::shouldReport()` is `false` for `accessDenied()` (401)
and `invalidCredentials()` (400), and `true` for `serverError()` (500).
## Notes
- Two independent reviews (architecture + product/bug): no must-fix,
both "ship as-is".
- Follow-up (not code): a mass signing-key mismatch would fail as
`access_denied` (401) and thus be suppressed here — the right way to
catch that is an MCP success-rate / 401-rate alert, not error-count.
Worth adding as an alert later.
## Problem
`bun run build` failed whenever `REGISTRATION_ENABLED=false`.
`config/fortify.php` only added `Features::registration()` when the env
flag
was truthy, so with the flag off Fortify never registered the
`/register`
routes. Wayfinder only generates helpers for registered routes, so the
`register` / `register.store` helpers imported by
`resources/js/pages/auth/login.tsx`
and `resources/js/pages/auth/register.tsx` no longer resolved and the
build
broke. The build outcome depended on an env flag — not deterministic.
## Fix
Decouple *route registration* from *whether sign-ups are accepted*:
- **Always register** `Features::registration()` in
`config/fortify.php`, so the
`/register` routes (and their Wayfinder helpers) always exist and the
build is
deterministic regardless of the flag.
- Gate acceptance at **runtime** via a new
`config('auth.registration_enabled')`
value (mapped from `env('REGISTRATION_ENABLED', true)`), the single
source of truth.
- When registration is disabled:
- `GET /register` (Fortify register view) and `POST /register`
(`CreateNewUser`)
return **403**.
- Registration CTAs stay hidden via `canRegister` (login view + landing
page).
- Guests are redirected to `/login`
(`AuthEntryPointService::guestRedirectRoute`).
- With the flag enabled (the default), registration behaves exactly as
before.
## Tests
- Rewrote `tests/Feature/Auth/RegistrationDisabledTest.php` to the
runtime
mechanism: registration enabled by default, route helpers always
registered
regardless of the flag, `GET`/`POST /register` return 403 when disabled
(and no
user is created), and the landing/login CTAs hide.
- Updated the `DashboardTest` disabled-registration case to toggle
`config('auth.registration_enabled')` instead of filtering the Fortify
feature.
- Verified `bun run build` succeeds with `REGISTRATION_ENABLED=false`
and that the
`register` / `register.store` Wayfinder helpers are generated.
## Docs
- Updated `README.md` and `.env.example` to describe the 403 behavior
(routes stay
registered rather than being removed).
## Summary
Closes#716.
Both AI execution paths hard-coded `provider: Lab::Gemini`, so even
though `laravel/ai` already understands `OLLAMA_URL` and the model was
env-overridable, no other provider could ever be reached by the actual
UI features. This makes the **AI provider configurable**, keeping
**Gemini as the default** so existing deployments are unaffected.
Although issue #716 asked specifically for **Ollama**, the fix is
generic: because the provider is resolved through the
`Laravel\Ai\Enums\Lab` enum, this unlocks **any text provider
`laravel/ai` supports** — `gemini`, `openai`, `anthropic`, `azure`,
`groq`, `xai`, `deepseek`, `mistral`, and self-hosted `ollama`. Ollama
is the headline case (fully local, private processing), but nothing in
the code is Ollama-specific.
> Note: each provider still needs its own credentials configured for
`laravel/ai` (e.g. `GEMINI_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_URL`),
and only text-capable providers apply — a non-text or unknown provider
fails fast.
## Changes
- **`config/ai_suggestions.php` / `config/ai_categorization.php`** — add
a `provider` key. Each reads its own `AI_SUGGESTIONS_PROVIDER` /
`AI_CATEGORIZATION_PROVIDER`, both falling back to a shared
`AI_PROVIDER` and finally `gemini`. So `AI_PROVIDER=<provider>` flips
every AI feature at once, and either feature can still be overridden
individually.
- **`app/Services/Ai/CategorizeTransactions.php` /
`app/Services/Ai/LaravelAiRuleSuggestionGenerator.php`** — resolve the
configured provider to the `Laravel\Ai\Enums\Lab` enum via
`Lab::from((string) config('...provider'))` and pass it to `prompt()`
(the SDK recommends referencing providers by the `Lab` enum rather than
a plain string). `Lab::from()` also **validates** the value: an unknown
provider fails fast with a clear `ValueError` instead of erroring deep
in the provider stack.
- **`.env.example`** — document the provider vars plus an Ollama block
(`OLLAMA_URL`, `OLLAMA_API_KEY`, `AI_CATEGORIZATION_MODEL`), kept
commented so defaults stay Gemini.
- **`README.md`** — new *AI Provider* section: the generic provider
switch, the list of supported text providers, and a local-Ollama
example.
- **Tests** — cover the `gemini` default and a non-Gemini (`ollama`)
override for both the categorization and rule-suggestion paths, plus the
fail-fast `ValueError` on an unknown provider.
## Usage
Any supported provider follows the same pattern — set `AI_PROVIDER`,
that provider's credentials, and the `*_MODEL` vars. Example, fully
local/private with Ollama:
```dotenv
AI_PROVIDER=ollama
OLLAMA_URL=http://ollama.example.local:11434
AI_SUGGESTIONS_MODEL=gemma3:12b
AI_CATEGORIZATION_MODEL=gemma3:12b
```
## Validation
- `./vendor/bin/pest tests/Feature/Ai` → **128 passed**.
- `vendor/bin/pint` and `vendor/bin/phpstan` (level 5) → clean.
- **End-to-end against a real Ollama server** (`gemma3:12b`), through
the actual application code (not faked):
- Categorization: `MERCADONA COMPRA` → *Groceries*, confidence 0.95.
- Rule suggestion: `netflix` → *Subscriptions*, structured output
intact.
## Backward compatibility
Default provider is unchanged (`gemini`); no env changes are required
for existing installs.
## What & why
The `HIDE_AUTH_BUTTONS` flag and its signed-link auth-override existed
only to gate registration/login behind a waitlist during the launch
period. That period is long over, so this removes the flag and the whole
waitlist apparatus. Registration and login are now always open, still
governed by `REGISTRATION_ENABLED` (Fortify's registration feature),
which is untouched.
## Removed
- **`HIDE_AUTH_BUTTONS`** config/env and
**`LandingAuthOverrideService`** — the signed-link override, override
cookie, and `?force=` bypass — plus its
`GenerateLandingAuthLinkCommand`.
- **Waitlist lead-capture + invitation apparatus**: `UserLeadController`
+ `waitlist.*`/`user-leads.*` routes, `StoreUserLeadRequest`, the
landing `WaitlistForm`, `waitlist/*` pages, waitlist/invitation mails,
invitation & re-invitation commands, the lead verification notification,
`LeadCohort`/`LeadCohortResolver`/`LeadPromoCodeAllocator`, and the
lead→Resend segment sync (scheduled tasks removed too).
- Frontend `hideAuthButtons`/`forcedRegistration` threading and the
`?force=` query on auth links (header, welcome, register, login).
- All tests for the removed features; `landing.hide_auth_buttons` config
stubs stripped from surviving tests.
## Kept on purpose (data preservation)
The `user_leads` **table, its migrations, and a lean `UserLead` model
are retained** — no destructive migration. Pre-launch contacts are not
lost, and new signups whose email matches a preserved lead still receive
that lead's promo code at Stripe checkout
(`SubscriptionController::resolveLeadPromotionCodeId`, unchanged).
## Testing
- Full non-Browser Pest suite green locally (**1948 passed**), plus
Pint, Prettier, ESLint.
- Browser QA (Playwright) on the running app: landing no longer shows
the waitlist form and renders the register/login CTAs; `/register`
renders the form (previously returned null when hidden); `/login` shows
the "Sign up" link pointing to `/register` with no `?force`. 16/16
functional checks passed; only pre-existing external-resource 404s in
console (gravatar `d=404`, `via.placeholder.com` seed images).
## Demo
https://github.com/user-attachments/assets/18a70f6d-b82f-4d92-b9de-da2610dc017a
## Summary
Adds a dedicated `REGISTRATION_ENABLED` env var (default `true`) so
self-hosters on a publicly reachable instance can **close public
sign-ups while keeping login open** — the exact "sign-ups closed, login
open, permanently" case from #711 that `HIDE_AUTH_BUTTONS` can't cover.
Closes#711.
## What changed
- **`config/fortify.php`** — the Fortify `registration()` feature is now
conditional on `env('REGISTRATION_ENABLED', true)`. When disabled:
- Fortify **never registers the `/register` routes** (GET form + POST
submit → `404`), i.e. a real server-side block, not just a hidden
button.
- The existing `canRegister =
Features::enabled(Features::registration())` flag flips to `false`
automatically everywhere it's used (`routes/web.php`,
`FortifyServiceProvider`, login page).
- **`resources/js/pages/welcome.tsx`** — the hardcoded `/register` CTAs
(hero button, Free/paid pricing cards, final "Ready to take control"
CTA) now respect `canRegister`, falling back to a `Log in` → `/login`
button when sign-ups are closed. The header already gated its `Register`
button on `canRegister`.
- **`.env.example` / `README.md`** — document the new variable.
**Backward compatible / optional:** `env('REGISTRATION_ENABLED', true)`
defaults to `true`, so existing installs that upgrade without setting
the var keep public sign-ups exactly as today. It's shown as a
commented, optional example in `.env.example`.
`/login` and the "Iniciar sesión / Log in" button stay fully available
in all cases. `AuthEntryPointService::guestRedirectRoute()` already
redirects guests to `/login` when registration is disabled, so no dead
`route('register')` calls remain.
## Tests
New `tests/Feature/Auth/RegistrationDisabledTest.php`:
- registration feature present by default / removed when
`REGISTRATION_ENABLED=false` (config-level).
- landing page exposes `canRegister=true` by default and `false` when
the feature is disabled.
- login page hides its sign-up link when registration is disabled.
- **`/register` GET + POST return 404 and the named routes are gone**
when disabled (via `refreshApplication()` with the env set), while
`/login` still returns 200.
All new tests pass; the wider `tests/Feature/Auth` + landing-override
suites stay green (the one unrelated `Asia/Calcutta` legacy-timezone
failure is a pre-existing tzdata quirk of the CI-less local box, not
touched here). `pint`, `prettier --check`, and `eslint` are clean.
## Manual QA
Verified against a running instance (headless Chromium) in both states:
| `REGISTRATION_ENABLED=true` (default) | `REGISTRATION_ENABLED=false` |
| --- | --- |
| Header: `Log in` + `Register`; hero CTA `Get Started` → `/register` |
Header: `Register` gone, only `Log in`; hero CTA `Log in` → `/login` |
| `GET /register` → `200` | `GET /register` → `404`, `GET /login` →
`200`, `Route::has('register')` → `false` |
## Problem
Connecting the MCP connector from **ChatGPT on Android** fails. The
installed Whisper Money PWA is a Chrome **WebAPK** that auto-verifies as
an Android **App Link handler** for the whole app origin (manifest
`scope: "/"`), so `https://whisper.money/oauth/authorize` gets routed
**into the app**. Once inside the standalone app, the redirect back to
the OAuth client can't complete → the connection fails. (Claude works
because it opens OAuth in a Custom Tab.)
Confirmed on an Android emulator: the WebAPK shows `AutoVerify=true`,
`whisper.money: verified`. DB evidence: ChatGPT registers + reaches
consent (auth codes issued) but never exchanges a token.
## Why not `handle_links`
`handle_links: "not-preferred"` (tried in #707, reverted in #708) is
**origin-wide** — it would push *every* `whisper.money` link (bank-auth
callback, email verification, shared deep links) to the browser, not
just `/oauth`. We want links to keep opening the installed app.
## Fix (surgical)
Move the OAuth **authorization server** to a dedicated host outside the
PWA scope. `config('mcp.authorization_server')` becomes env-driven
(`MCP_AUTHORIZATION_SERVER`); in prod → `https://oauth.whisper.money`
(DNS already points at the same app).
Every endpoint derives from the request host (no forced root URL), so
pointing the auth server at the subdomain makes `issuer` +
`authorize`/`token`/`register` all resolve to `oauth.whisper.money` —
**same origin as each other**, no cross-origin metadata mismatch. The
protected resource (`/mcp/oauth`) and **all other app links stay on
`whisper.money`**, so deep-linking into the app is fully preserved. Only
the OAuth flow leaves the app — into the browser, where the round-trip
completes.
## Activation (after merge + deploy)
1. Set `MCP_AUTHORIZATION_SERVER=https://oauth.whisper.money` in prod
env, redeploy.
2. I'll curl the discovery chain to confirm it resolves to the
subdomain.
3. Test the ChatGPT connect on a real phone.
Safe until step 1: env unset → `authorization_server` stays `null` →
current behavior. No effect on local/dev.
## Tests
Added a Pest test: with `mcp.authorization_server` configured,
protected-resource metadata advertises the dedicated host and
auth-server metadata (fetched from that host) keeps `issuer` + all
endpoints on it. App has no `TrustHosts` restriction (already serves the
subdomain) and `SESSION_DOMAIN=null` (host-only cookies — subdomain gets
its own session, no security downgrade).
## Problem
A user reported being stuck forever on the onboarding "Looking for
patterns" step (the "Polishing your suggestions…" spinner). Confirmed in
prod: their `suggestion_runs` row has been stuck in `processing` for a
week, and `failed_jobs` shows `TimeoutExceededException:
GenerateRuleSuggestionsJob has timed out`.
### Root cause
- The user has 4,163 transactions — the largest onboarding dataset (max
with a completed run was 3,481). The Gemini generation exceeded the
job's `$timeout = 120`.
- The worker timeout is raised **outside** the `try/catch` in
`GenerateRuleSuggestions::run()`, so the code that sets `status =
Failed` never ran.
- `GenerateRuleSuggestionsJob` had **no `failed()` handler** (5 other
jobs do), so nothing flipped the run to `Failed`. It stayed
`processing`, `show()` kept returning `processing`, and the client
polled every 3s forever.
- The timeout never reached Sentry, so it was invisible. Only 1 user
affected, but it's a latent bug that recurs on any large account.
## Fix
**Backend** — add `failed()` to `GenerateRuleSuggestionsJob`: mark the
run `Failed` (only if not already terminal) and `report()` the exception
so these surface in Sentry.
**Frontend** (`step-ai-suggestions.tsx`) — belt-and-suspenders so the
client never spins forever even if the worker dies before it can mark
the run:
- `poll()` now guards against transient errors (network blip / expired
session / 5xx) instead of silently dying.
- A client-side deadline (3 min, just beyond the "up to two minutes"
copy) surfaces the existing "couldn't generate" screen with **Try again
/ Skip**.
Not included: raising the timeout / capping the number of groups sent to
Gemini so large accounts *complete* instead of just being able to skip.
Follow-up if we want big accounts to get suggestions.
## Tests
- `tests/Feature/Ai/GenerateRuleSuggestionsJobTest.php` — `failed()`
marks the run `Failed` + reports; doesn't overwrite an already-terminal
run.
-
`resources/js/components/onboarding/step-ai-suggestions-timeout.test.tsx`
— after the deadline the spinner gives way to the failed screen.
Green locally: pint, prettier, eslint, backend + frontend tests.