`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.
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.
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.
## 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.
> **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
## 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
## Problem
`EnableBankingProvider::getTransactions` and `getBalances` classify a
set of expected `RequestException`s (401 expired session, 400
inaccessible account, 422 wrong period, 400 ASPSP_ERROR) but let
**everything else** fall through to a raw `throw $e`. An upstream **500
`Internal server error`** — from EnableBanking itself or the ASPSP
behind it — therefore propagates as a plain `RequestException` and gets
`report()`ed as an app error.
**Sentry:** `PHP-LARAVEL-3J` — `Illuminate\Http\Client\RequestException:
HTTP request returned status code 500` in
`EnableBankingProvider::getTransactions`, **36 events / 3 users** since
2026-06-16 (regressed 2026-07-15). Nothing per-event our code can do
about the bank's server erroring.
## Fix
A 5xx is a transient server-side failure — the same class as a
`ConnectionException` (no response), which is **already** wrapped as
`TransientBankingProviderException` (logged at `warning`, retried,
self-healing, `ShouldntReport`). Classify any upstream 5xx the same way
in both `getTransactions` and `getBalances`, via a small
`isTransientServerError()` helper placed alongside the other
classifiers:
```php
private function isTransientServerError(RequestException $e): bool
{
return $e->response->status() >= 500;
}
```
So provider outages retry/self-heal instead of paging Sentry.
## Not changed
All non-5xx paths are untouched and stay reportable — 401/400/422/ASPSP
keep their dedicated exceptions, and genuine 4xx client errors (e.g. the
existing "keeps non-ASPSP client errors reportable" / "keeps unrelated
422 validation errors reportable" tests) still surface.
## Test
Added two cases to `EnableBankingProviderTest.php` (an upstream 500 on
`getTransactions` and on `getBalances` →
`TransientBankingProviderException` / `ShouldntReport`). Full provider
suite: **14/14 green** locally.
🤖 Found and fixed autonomously via the Sentry monitoring loop.
## Problem (Sentry PHP-LARAVEL-42)
EnableBanking's `GET /accounts/{id}/transactions` returns **HTTP 422
"Wrong transactions period requested"** when the requested date range is
wider than the bank is willing to serve. The catch-ladder in
`EnableBankingProvider::getTransactions()` only matched
401/EXPIRED_SESSION, 400/AccountNotAccessible and 400/ASPSP_ERROR, so
the 422 rethrew a **raw `RequestException`**. That:
1. escaped the per-account loop in `EnableBankingSyncer::sync` (which
only skips `InaccessibleBankAccountException`), so **every remaining
account in the connection stopped syncing too**;
2. hit the job's generic `catch (\Throwable)` → retried 3×
(deterministic, always the same 422) → connection marked **Error** and
**reported to Sentry**;
3. after the scheduled-retry budget (`consecutive_sync_failures >= 3`)
the whole connection was **dropped from scheduled sync** until a manual
reconnect.
Real user impacted (active connection). The failing request was a
~92-day window on the incremental/linked path.
## Fix (3 commits)
1. **Classify the 422** — new `WrongTransactionsPeriodException`
(`ShouldntReport`) thrown from `getTransactions()` when status is 422
and the message names the period. Also stop logging handled 422s at
`error` level in the HTTP client callback.
2. **Clamp + retry** — on that exception, `TransactionSyncService::sync`
restarts the account from page 1 with a progressively narrower window
(`90 → 30 → 7` days before `date_to`), so the user keeps as much history
as the bank will serve. `strategy='longest'` is dropped on the narrowed
retry so the explicit `date_from` is honoured; re-fetched pages are
idempotent (fingerprint dedup + date-keyed daily balances).
3. **Graceful skip** — if even the narrowest window is refused, the
syncer skips just that account (like an inaccessible account) and keeps
the connection Active, instead of failing the whole sync.
## Why draft (needs a human call, per two review agents)
The crash fix itself is well-covered and safe. What needs sign-off is
the **product tradeoff** the clamp introduces:
- **First-sync history truncation.** First sync requests
`now()->subYear()`. If the bank refuses a year, we narrow to ≤90 days
and there is **no back-fill path** (incremental syncs only move
`date_from` forward), so the skipped history is lost. This is bounded
and logged, but it is a deliberate behaviour change.
- **Incremental catch-up gap.** If the watermark is older than the
bank's servable window, the days between the watermark and the clamp are
never fetched (silent, but logged).
- **Heuristics worth a human eye:** the ladder values `[90, 30, 7]`,
dropping `strategy='longest'` on retry, and detecting the error by
`status 422 + message contains "period"` (ideally confirmed against
EnableBanking docs / a stable error code).
Low-risk per review: duplicate imports (fingerprint + `(account_id,
dedup_fingerprint)` unique index are robust to overlapping windows) and
balances (truncated, not corrupted).
## Testing
- Provider: 422 wrong-period → `WrongTransactionsPeriodException`;
unrelated 422 stays a raw `RequestException`.
- Service: clamps + retries once (asserts the clamped date and the
`strategy` drop); gives up after the ladder is exhausted; does not retry
an already-narrow window.
- Syncer: a refused account is skipped, connection stays
Active/unreported, siblings still sync.
- Full `tests/Feature/OpenBanking` + `tests/Feature/Sync` green (315
tests); Pint and Larastan clean.
Fixes PHP-LARAVEL-42
---
🤖 Opened by the autonomous Sentry-triage loop. Draft on purpose — the
data-truncation tradeoff above is a product decision for a human.
## Why
The five API-key OpenBanking "connect" controllers — Binance, Bitpanda,
Coinbase, Indexa Capital and Interactive Brokers — each carried a
~60-line `store()` that was near-identical: subscription gate →
credential validation → `Bank::firstOrCreate` → create the
`BankingConnection` (Pending) → update it to `AwaitingMapping` with
pending accounts → auto-map during onboarding or redirect to mapping.
Any change to that flow, or a bug in it, had to be made five times.
## What
Extract the shared flow into a small class hierarchy, so each controller
declares only what actually varies per provider.
- **`OpenBankingConnectController`** (abstract) owns the whole flow in a
`connect()` template method, with per-provider extension points:
`provider()`, `providerName()`, `bankLogo()`, `aspspCountry()`,
`fetchProviderData()`, `credentialErrorMessage()`,
`buildPendingAccounts()`, and an optional `emptyProviderDataMessage()`
guard.
- **`CryptoPortfolioConnectController`** (abstract, extends the above)
implements the two hooks the crypto providers share: `aspspCountry()`
(from the request) and the single "Crypto Portfolio"
`buildPendingAccounts()` (uid derived from the provider enum value, so
the generated uids are unchanged).
- The five controllers shrink to their genuine differences (client,
names, logo, error copy, and — for IB — the Flex error mapping and
empty-statement guard).
Net **−110 lines** across the five `store()` methods, and the connection
lifecycle now lives in one place.
## Behavior
No functional change. The credential-failure warning log is now a single
structured message with a `provider` key instead of five free-text
variants (the only observable difference; HTTP responses are
byte-for-byte identical). Verified by the full suite: **1869 passing, 0
failing** (`--exclude-testsuite=Browser`); `pint --test`, `format` and
`lint` clean.
## Review-driven follow-up commits
Two review agents (architecture/duplication/coverage, and
behavior/regressions) ran against the first commit. Applied, each as its
own commit:
- Finished the crypto dedup via the intermediate base (the reviewers
flagged the still-duplicated crypto hooks).
- Dropped the over-engineered per-provider
`validationFailureLogMessage()` hook in favor of a structured base log.
- Added the missing test for the Interactive Brokers empty-statement
guard (the only conditional hook, previously unexercised): asserts 422 +
NAV-section message, no connection row, and no warning logged.
## Deliberately out of scope
- **Wise** is left as-is: it builds pending accounts mid-flow (needs the
client), creates the connection in one step, and has its own
empty-portfolio guard — it does not fit this template.
- **Pre-existing** (not introduced here, noted by review):
`Bank::firstOrCreate` returns an existing bank without refreshing its
logo, so `aspsp_logo` can copy a stale/null value. Worth a separate fix.
## What
Addresses Sentry **PHP-LARAVEL-3X** — a slow DB query in
`App\Jobs\SendDailyBankTransactionsSyncedEmailJob::handle()`.
The daily "transactions synced" email filters transactions by:
```php
Transaction::query()
->where('user_id', $this->user->id)
->where('source', TransactionSource::EnableBanking)
->when($lastSentMailLog?->sent_at, fn ($q, $at) => $q->where('created_at', '>', $at))
->whereHas('account.bankingConnection', ...) // EXISTS on PKs
->get();
```
The only `user_id`-leading index is `idx_transactions_budget_lookup
(user_id, transaction_date, category_id)` — its second column is
`transaction_date`, **not** `created_at`, so it can't serve
`source`/`created_at`.
## How
Add a composite index matching the predicate — two equalities then the
range:
```
idx_transactions_user_source_created (user_id, source, created_at)
```
The `whereHas` compiles to correlated `EXISTS` subqueries that join on
primary keys (`accounts.id`, `banking_connections.id`), so no extra
index on those tables is needed — the `transactions` index alone gives
the optimizer the selective driving path it currently lacks.
## Production verification (via read-only prod queries)
Confirmed the diagnosis and de-risked the deploy against the live
database:
- **The query is genuinely slow.** `EXPLAIN ANALYZE` for the heaviest
user (~6.1k EnableBanking transactions) runs in **~6 s**. The optimizer,
lacking a selective path, drives from a **full table scan of all ~2,500
accounts** and examines ~108k transaction rows (334 account loops × ~323
rows), filtering `user_id`/`source` only afterwards.
- **The index fixes it.** `(user_id, source, created_at)` lets the
optimizer drive from transactions — a seek to `(user_id,
'enablebanking')` (~6.1k rows) plus primary-key semi-joins — far cheaper
than the current ~108k-row plan, so it will be chosen.
- **The deploy is low-risk.** `transactions` holds **~297k rows** (not
millions). Adding a secondary index on InnoDB/MySQL 8 is online
(`ALGORITHM=INPLACE, LOCK=NONE`, no table rebuild), so at this size the
build is seconds and does not block the sync write path.
## Commits
1. `perf(db): index transactions for the daily synced-email query` — the
migration + a test asserting the index columns/order.
2. `test(db): assert the daily-email index name, not just its columns` —
lock the explicit index name (review feedback).
## Reviewed by two independent agents (architecture +
product/operational risk)
Both rated the change correct and shippable: column order right
(equalities before the range), migration reversible, index non-redundant
(an existing index can't be widened without breaking budget queries),
and no behavior/result change (a secondary btree index never alters
result sets; the job has no `ORDER BY`).
## Impact / risk
- **User impact:** indirect — a background email job (Sentry reports 0
interactive users), but the ~6 s query wastes queue-worker time and IO
on every run.
- **Complexity:** low — one additive, reversible index migration; no
application logic changed.
- **Write path:** one more index maintained per transaction insert (10th
on the table). Marginal, consistent with the existing UUID-leading index
cost profile.
Fixes PHP-LARAVEL-3X
## What
Fixes the N+1 flagged by Sentry **PHP-LARAVEL-3Y** in
`App\Jobs\SyncBankingConnectionJob`.
`TransactionSyncService::importTransaction()` ran one `exists()` probe
**per incoming bank transaction** to detect duplicates:
```sql
select exists(
select * from transactions
where account_id = ?
and (dedup_fingerprint = ? or external_transaction_id = ?)
) as exists
```
A sync importing N transactions issued N dedup `SELECT`s. As transaction
volume per sync grows, so does the query count.
## How
Preload the account's existing dedup keys **once** at the start of
`sync()` and match against in-memory sets:
- `loadExistingDedupKeys()` streams (`cursor()`) the two dedup columns
for the account — including soft-deleted rows (`withTrashed()`) — into
two keyed sets. One query instead of N.
- `importTransaction()` checks membership in memory (`isset()`), an
exact mirror of the old `fingerprint OR external_id` predicate.
- Keys from successful inserts are folded back into the sets, so
duplicates **within the same sync** (e.g. the same transaction repeated
across pages) are still caught in memory.
- The `(account_id, dedup_fingerprint)` unique index +
`UniqueConstraintViolationException` catch still backstop concurrent
syncs (`SyncBankingConnectionJob` is already `ShouldBeUnique` per
connection).
## Commits
1. `perf(banking): preload dedup keys to kill per-transaction N+1` — the
core fix + a query-count regression test.
2. `perf(banking): stream dedup key preload with a cursor` — avoids
buffering every historical row into a Collection on top of the sets
(memory guard for very large accounts).
3. `fix(banking): match external ids case-insensitively in dedup` — the
old query compared `external_transaction_id` under the production
`utf8mb4_unicode_ci` collation; the in-memory `isset()` is byte-exact.
With no unique index on `external_transaction_id`, a legacy id stored as
`ABC` vs an incoming `abc` would have silently double-imported.
Normalized with `mb_strtolower()` on both sides + a test.
4. `test(banking): cover intra-run cross-page dedup` — new coverage for
the same transaction appearing on two pages of one sync.
## Testing
- 303/303 in `tests/Feature/OpenBanking/` green; full backend suite
green except one **pre-existing, unrelated** failure on `main`
(`DashboardTest` returns 409 locally — an Inertia asset-version artifact
that resolves once `bun run build` runs in CI).
- New tests: dedup runs as a single preload SELECT regardless of batch
size; case-insensitive external-id dedup; cross-page intra-run dedup.
- `pint` clean; `larastan` clean on the changed file.
## Reviewed by two independent agents (architecture +
product/correctness)
Both rated the change behavior-preserving and shippable. Follow-ups they
surfaced, intentionally **not** bundled here to keep this fix focused
and low-risk:
- **`WiseTransactionSyncService` has the identical latent N+1**
(`:149-158`). It is not currently firing in Sentry (lower volume). Worth
a separate PR — possibly extracting a shared dedup-set helper once both
call sites need it.
- **Residual collation divergence**: accent/width folding from
`utf8mb4_unicode_ci` is not replicated in PHP. Irrelevant for real bank
reference ids (ASCII), accepted.
- The test comment referencing a "cleanup command" that repoints orphan
fingerprinted rows describes a command that does not exist in the repo —
pre-existing, worth correcting separately.
Fixes PHP-LARAVEL-3Y
## Why
Production log dashboards were flooded with paired `Banking sync failed`
+ `EnableBanking API error` warnings for a handful of users, recurring
on every 6-hour `banking:sync` cycle for days. Investigation (prod
`BankingSyncLog` + connection state) showed the affected connections are
**healthy and syncing daily** (`last_synced_at` = today,
`consecutive_sync_failures = 0`): the first attempt hits a transient
`ASPSP_ERROR` / `429`, and the job's retry recovers it.
So the warnings are **noise, not breakage** — but
`SyncBankingConnectionJob` logged `Banking sync failed` inside the catch
block on *every* attempt, including non-final ones that later succeed.
That's one warning per cycle for connections that sync fine, which reads
as a failure and causes alert fatigue.
## What
Gate the `Log::log(...)` call so it only fires when the connection
actually gives up:
- the **final attempt** (`attempts() >= tries`), or
- a **permanent auth error** (`isAuthError`).
Transient errors recovered by the retry are no longer logged.
Per-attempt traceability is unchanged: `BankingSyncLog` still records
every attempt (Success / Failed) in the database.
## Tests
`tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php`:
- non-final attempt → `Log::log` is **not** called
- final attempt → `Log::log('error', 'Banking sync failed', ...)` **is**
called once
Full file: 24/24 passing.
## Why
Prod warning logs were flooded with `Exchange rate not found, returning
unconverted amount` (≈500 lines from a single dashboard load). The
`ExchangeRateService` was behaving correctly — the source currency was
the ISO 4217 placeholder **`XXX`** ("no currency"), which has no
exchange rate, so conversions silently fell back to an **unconverted
(wrong) amount**.
Root cause: when importing accounts from a banking connection (Enable
Banking can report `currency: "XXX"`), the code did
`$accountData['currency'] ?? 'EUR'`. The `??` only catches
`null`/missing, not the literal `"XXX"`, so the placeholder was
persisted as `currency_code`.
Prod data confirmed `XXX` is the **only** account currency not covered
by the rates table (342 currencies, incl. BTC/VES/exotics, are all
present): 18 accounts (16 from bank links, 2 manual) across 14 users,
plus **5 users** whose base currency had itself become `XXX` (a
first-account `XXX` propagates to the user via `syncFromFirstAccount`).
## What
1. **Fix the source** —
`AccountUserCurrencyService::resolveImportedCurrency()` resolves a
bank-reported currency to: bank value → user base currency → app default
(`cashier.currency`), treating `XXX`/empty/missing as "no currency".
Wired into both creation paths (`CreatesAccountsFromPending`,
`AccountMappingController`); also covers Interactive Brokers imports.
2. **Backfill** — migration fixes existing rows in order: `XXX` users →
app default first, then `XXX` accounts → their owner's currency.
## Tests
- `AccountUserCurrencyServiceTest` — resolver chain (valid / uppercasing
/ XXX·empty·null → user / both missing → default).
- `AccountMappingTest` — mapping flow falls back to the user currency
when the bank reports `XXX`.
- `BackfillXxxAccountCurrenciesTest` — migration resolves both XXX
owners and XXX accounts end-to-end.
23 tests green; Pint and Larastan clean.
## What
Removes the `InteractiveBrokers` Pennant feature flag. The integration
is now always available to every user.
## Changes
- Delete the `App\Features\InteractiveBrokers` flag class.
- Drop the per-user `abort_unless(...->active(...))` gate from
`InteractiveBrokersController`.
- Stop sharing the `interactiveBrokers` flag in Inertia props
(`HandleInertiaRequests`) and remove it from the `Features` type.
- Remove the now-unused `feature` gating mechanism from the
connect-provider registry and `useConnectFlow` — Interactive Brokers was
its only consumer.
- Update tests: drop the "blocked when flag off" case and all flag
activations; the connect dialog now asserts IB is always offered.
The Interactive Brokers integration itself (provider enum, client,
syncer) is untouched.
## Testing
- `php artisan test InteractiveBrokersControllerTest
InertiaSharedDataTest` — 17 passed
- `vitest connect-account-dialog` — 7 passed
- `bun run lint` / `bun run format` — clean
## What
Makes **Wise** connections support credential updates, like every other
API-key provider.
Wise was the only API-key provider not wired into the credential-update
path:
`ConnectionController::validateProviderCredentials()` had no Wise arm,
so it
fell to the `Unsupported provider` default, and the update dialog
(driven by the
provider registry's `updatable` flag) rendered nothing. A Wise
connection in an
auth-error state therefore showed an **Update Credentials** button that
opened
an empty, unusable dialog.
## Fix
- Add the Wise validation arm in `validateProviderCredentials`
(`WiseClient::getProfiles()`). The validation rules and the
`api_token` → column mapping already come from `BankingProvider`'s
credential
registry, so nothing else on the backend changes.
- Drop the now-unused `updatable` flag from the connect-providers
registry —
every connect provider is updatable — and simplify
`update-credentials-dialog`
to render fields for any registry provider.
## Tests
- Updating Wise credentials with a valid token succeeds (status back to
active,
token stored, sync dispatched).
- `BankingProviderTest`: every API-key provider declares credential
fields,
guarding against adding a provider without an update path again.
Follow-up to #581 (this branch is off the updated `main`).
## What
Adds **Interactive Brokers** as a banking sync provider (investment
account, balances only), mirroring the Indexa Capital integration.
It uses the **Flex Web Service** rather than IBKR's Web API: the Web API
requires registering as an IBKR third party (business entity, Compliance
approval, RSA-signed OAuth, ~3-5 weeks), which is overkill for read-only
balance sync. Flex is a read-only token + Query ID model that fits our
existing API-key provider shape.
### How the sync works
- The user creates an Activity Flex Query (NAV + Open Positions) and a
Flex Web Service token in their IBKR Client Portal, then pastes both.
- Client flow: `SendRequest` → reference code → poll `GetStatement` →
parse the XML statement.
- Mapping: `EquitySummaryByReportDateInBase@total` → `balance` (daily
rows give historical backfill on first sync); `Σ(OpenPosition
costBasisMoney × fxRateToBase) + cash` → `invested_amount`, so **profit
derives as `balance − invested_amount`** (unrealized P&L), like Indexa
Capital. Everything is already in base currency, so no FX conversion is
needed.
- One statement covers every account, so the syncer fetches once per
connection to respect IB's per-query rate limit.
- IB returns HTTP 200 with an error XML, so the client translates Flex
error codes into the exceptions the sync job already understands:
`RequestException(401)` for token problems, `RequestException(429)` for
throttling, `TransientBankingProviderException` otherwise.
### Connect flow
- New connect/update-credentials endpoints validate the credentials by
pulling a statement, then build pending accounts from it.
- Credentials reuse the encrypted `api_token` (Flex token) and
`api_secret` (Flex Query ID) columns — **no migration**.
- The IB option (two fields) is added to the connect dialog, inline
connect flow, and update-credentials dialog, with Spanish translations.
## Feature flag (why this is a draft)
Gated behind a Pennant feature `App\Features\InteractiveBrokers` (off by
default). It was built against documented/open-source Flex XML fixtures,
**not a live IBKR account** (we don't have one). Before enabling,
validate against a real account (beta tester or a free IBKR account):
```
php artisan feature:enable InteractiveBrokers user@example.com
```
If the parser needs tweaks against real XML, they should be minor
(field-name level).
## Tests
- Client + balance sync: NAV → balance, invested/profit, daily backfill,
since-date incremental, multi-account, GetStatement polling, token-401 /
rate-limit-429 mapping.
- Controller: feature-flag gate (403), valid/invalid credentials,
subscription gate, onboarding auto-create, validation.
- Factory wiring, enum cases, job-level sync, feature-flag visibility
(vitest).
- Spanish translations added (enforced by `LocalizationTest`).
All green: `pint --test`, `phpstan`, OpenBanking + localization suite,
vitest.
## Summary
Removes the `ManageBankAccounts` Pennant feature flag that gated the
per-connection "Manage Accounts" surface to the admin user, making the
flow available to all users.
## Changes
- Delete the `App\Features\ManageBankAccounts` feature class.
- `ConnectionAccountController`: drop the `ensureFeatureEnabled()` gate
(3 call sites + method) and the `Feature`/`ManageBankAccounts` imports.
Access stays guarded by `authorizeConnection()` (connection ownership).
- `HandleInertiaRequests`: stop sharing the `manageBankAccounts` Inertia
flag.
- Frontend: remove the flag from the `Features` type, the
`features.manageBankAccounts` conditional in `connections.tsx` (now
gated solely by `canManageAccounts`), and the now-unused `features`
destructure.
- Tests: update `InertiaSharedDataTest` and the `connections.test.tsx`
mock; simplify the `adminUser()` helper to `onboardedUser()` in
`ConnectionAccountTest` and remove the "forbidden when feature disabled"
test.
## Testing
- `vendor/bin/pint --dirty` — pass
- `php artisan test tests/Feature/OpenBanking/ConnectionAccountTest.php
tests/Feature/InertiaSharedDataTest.php` — 17 passed
- `bunx vitest run resources/js/pages/settings/connections.test.tsx` —
pass
- `bun run lint` / `bun run format` — clean
## Problem
Sentry issue
[PHP-LARAVEL-3J](https://whisper-money.sentry.io/issues/PHP-LARAVEL-3J)
started escalating again — but with a **different** error than the 401
expired-session fixed in #557. Sentry groups both under the same
`getTransactions` culprit:
```
RequestException: HTTP request returned status code 400:
{"code":400,"message":"Account not found","detail":{"message":"Account not found","error_name":"AccountNotAccessibleException"}}
```
When EnableBanking returns `400 AccountNotAccessibleException` for
**one** account (closed, or no longer covered by the consent), the raw
`RequestException` was re-thrown. This:
1. **Aborted the whole connection's account loop** in
`EnableBankingSyncer` — accounts after the dead one never synced.
2. Marked the **entire connection** `Error` (`friendlyErrorMessage`
default), even though its other accounts were fine.
3. **Reported to Sentry** on every scheduled retry (escalating noise).
Confirmed in production (`agent:db --prod`): connection `019ea143-…` is
`active` with `valid_until` in the future (2026-09-05) and **6
accounts**; one (`08bbcdf9-…`) returns the 400, and the connection is
now stuck in `error` with the generic "try again later" message. 2 users
affected.
## Fix
A single account the bank no longer exposes must not break the rest of
the connection.
- New domain exception `InaccessibleBankAccountException` (implements
`ShouldntReport`, like `TransientBankingProviderException` /
`ExpiredBankingSessionException`).
- `EnableBankingProvider::getTransactions()` / `getBalances()` wrap a
`400 AccountNotAccessibleException` in it (keyed on `detail.error_name`)
instead of re-throwing the raw `RequestException`.
- `EnableBankingSyncer` catches it **per account**, logs a warning, and
`continue`s — the remaining accounts sync and the connection stays
`Active`.
Connections currently stuck in `error` from this self-heal on the next
scheduled sync (they're `Error` + under the retry cap + `valid_until`
future, so the scheduler re-dispatches them; the dead account is now
skipped).
Composes with #558: a user can permanently *stop syncing* such an
account from the manage-accounts screen. This PR only stops one dead
account from breaking automated syncs in the meantime.
## Tests
- `EnableBankingProviderTest`: `getTransactions` / `getBalances` wrap a
`400 AccountNotAccessibleException` as a non-reportable
`InaccessibleBankAccountException`.
- `SyncRetryAndLoggingTest`: with one inaccessible and one good account,
the good one still syncs, the connection stays `Active`, and nothing is
thrown.
All three failed before the fix. Full `tests/Feature/OpenBanking` suite
passes (276), pint clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Why
During the bank connection flow (Enable Banking) a user picks which
accounts to sync and can skip the rest. Until now there was **no way
back**: a skipped account couldn't be enabled later, a synced account
couldn't be moved or turned off, and accounts the bank added afterwards
were invisible. Skipped accounts aren't persisted anywhere —
`pending_accounts_data` is cleared once mapping completes — so the app
simply forgets they exist.
This adds a per-connection **Manage Accounts** screen that closes that
gap.
## What
- **Known accounts render from the DB** (no provider call). For each
synced account the user can:
- **Stop syncing** → the account is unlinked and becomes a regular
manual account. Non-destructive: it keeps all its imported transactions.
- **Change destination** → move syncing to another compatible manual
account (unlinks the previous holder, links the new one with incremental
sync).
- **Load accounts** button re-fetches the consent's account list from
the provider *on demand* (`getSession` + `getAccount` only for uids we
don't already know) to surface newly available / previously skipped
accounts, which can then be created as new accounts or linked to
existing manual ones.
- The provider is **only ever hit on refresh**; every mutation is
DB-only, so the screen still works (read + rearrange) when the consent
has expired.
### Design notes
- No new column / cache: the default view derives entirely from the
`accounts` table, and refresh is on-demand and ephemeral. Re-fetching is
mandatory anyway (existing connections have no stored snapshot, and only
a live call can reveal accounts the bank added later).
- Scoped to **Enable Banking** for now; the approach is column-free so
extending to the crypto/API-key providers later is trivial.
## Endpoints
- `GET open-banking/connections/{connection}/accounts` — manage screen
(`discoveredAccounts` computed only when `?refresh=1`)
- `POST open-banking/connections/{connection}/accounts/map` — create /
link / change destination for one bank account
- `POST open-banking/connections/{connection}/accounts/{account}/unlink`
— stop syncing
## Testing
- New `ConnectionAccountTest` (7 tests): index props, refresh discovery
(excludes already-synced uids), create,
change-destination/unlink-previous, non-destructive unlink keeps
transactions, ownership 403, account-mismatch 404.
- Full `tests/Feature/OpenBanking` suite green (268), plus pint, eslint,
prettier and the enforced `es` localization test.
## Deliberately skipped
- **Subscription gating** on refresh/map — matches the currently ungated
`disconnect`. Easy follow-up if free users shouldn't trigger live
provider calls.
- Other providers (Enable Banking only, as agreed).
## Problem
Sentry issue
[PHP-LARAVEL-3J](https://whisper-money.sentry.io/issues/PHP-LARAVEL-3J)
— `RequestException: HTTP 401 {"error":"EXPIRED_SESSION"}` in
`EnableBankingProvider::getTransactions` — was escalating (26 events, 2
users).
When an EnableBanking **session** expires *before* its 90-day consent
window (`valid_until`), the sync hit a `401 EXPIRED_SESSION` that was
not classified as transient or ASPSP, so it was re-thrown as a raw
`RequestException`. Consequences:
1. **Sentry noise** — reported as an error on every scheduled retry
until the failure cap.
2. **Silent breakage for the user** — EnableBanking's syncer returns
`notifiesOnAuthFailure() === false`, so the 401 went through the generic
permanent-error path: connection set to `Error` (not `Expired`) with
**no reconnect email**.
Confirmed in production: **10 of 17** EnableBanking connections in
`error` status are actually expired sessions (`valid_until` in the
future + "Authentication failed" message), all with no notification
sent.
## Fix
Treat a `401 EXPIRED_SESSION` as the expected lifecycle event it is:
- New domain exception `ExpiredBankingSessionException` (implements
`ShouldntReport`, like `TransientBankingProviderException`).
- `EnableBankingProvider::getTransactions()` / `getBalances()` wrap the
`401 EXPIRED_SESSION` in it instead of re-throwing the raw
`RequestException`.
- `SyncBankingConnectionJob` catches it and routes through the existing
expiry handling (extracted to `markExpired()`): marks the connection
`Expired`, sends `BankingConnectionExpiredEmail`, logs a skipped
attempt, returns **without throwing**.
- The provider's HTTP error logger downgrades the expected expiry from
`error` → `warning`.
`Expired` connections are not re-dispatched by
`SyncAllBankingConnectionsJob`, so retries stop and the user is prompted
to reconnect.
## Tests
- `EnableBankingProviderTest`: `getTransactions` / `getBalances` wrap a
`401 EXPIRED_SESSION` as a non-reportable
`ExpiredBankingSessionException`.
- `SyncRetryAndLoggingTest`: an expired session marks the connection
`Expired`, queues the reconnect email, and does not throw.
All three failed before the fix. Full `tests/Feature/OpenBanking` suite
passes (264 tests).
## Follow-up (not in this PR)
The 10 connections already stuck in `Error` from this bug are past the
retry cap and won't self-heal. A one-off command to reclassify them to
`Expired` and send the reconnect email would recover those users — worth
a separate, reviewed change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Why
Syncing was a growing `if/elseif` chain in
`SyncBankingConnectionJob::handle()` plus one private method per
provider. Every new provider meant editing the job, and the file mixed
provider-specific logic with cross-cutting orchestration. This does not
scale to dozens/hundreds of providers.
## What
Introduces a **factory + strategy** pattern:
- **`App\Contracts\BankingConnectionSyncer`** — interface: `sync()`,
`expires()`, `notifiesOnAuthFailure()`.
- **`AbstractBankingConnectionSyncer`** — defaults for the common case
(API-key provider: never expires, notifies on auth failure). A new
provider usually only implements `sync()`.
- **`BankingConnectionSyncerFactory`** — maps `connection.provider` to
its syncer (resolved from the container, so dependencies are injected).
- **Six syncers** — `IndexaCapitalSyncer`, `BinanceSyncer`,
`WiseSyncer`, `BitpandaSyncer`, `CoinbaseSyncer`, `EnableBankingSyncer`.
Each fully owns its provider behavior, including EnableBanking's daily
email and first-sync cutoff.
`SyncBankingConnectionJob` drops from ~520 to ~190 lines and now only
orchestrates: deleted-user/expiry/rate-limit checks, error handling,
retries, logging, and status updates. It asks the syncer `expires()` /
`notifiesOnAuthFailure()` instead of hardcoding provider lists.
### Adding a provider now
1. Create `XSyncer extends AbstractBankingConnectionSyncer` implementing
`sync()`.
2. Add one line to the factory map.
No changes to the job.
## Tests
- Existing job tests (`SyncBankingConnectionJobTest`,
`SyncRetryAndLoggingTest`) migrated through a shared `runSync()` helper
in `tests/Pest.php`; all original assertions preserved.
- New `BankingConnectionSyncerFactoryTest`: provider→syncer resolution,
unknown-provider exception, and the capability flags.
- Full suite green: **1604 passed**, 0 failures. PHPStan clean, Pint
applied.
## Docs
Adds `docs/adding-a-banking-provider.md` — a step-by-step developer
guide (usable by a human or an AI agent) covering the sync provider and
the broader end-to-end integration touchpoints.
## Summary
Adds **Wise** as an API-token banking provider — connect flow,
transaction sync, and per-wallet balance sync — and fixes two bugs that
kept business/extra profiles and balances from working.
## Changes
- **Balance sync** — new `WiseBalanceSyncService` pulls
borderless-account balances on every sync. Previously Wise balances were
never fetched, so the displayed balance went stale (frozen at the last
value).
- **Multi-profile connect** — `WiseController` now creates accounts for
**every** profile on the token (personal *and* business), not just one.
- **ID-format fix** — `external_account_id` is now
`"{profileId}:{currency}"`. The connect flow previously wrote the
**borderless-account id**, but both sync services parse the **profile
id** — so any UI-connected account (and every business profile) silently
failed to sync.
- **Tests** — `WiseBalanceSyncTest` (currency matching, idempotent
today-balance, skip-when-unmapped) and `WiseControllerTest`
(multi-profile pending accounts, onboarding auto-create, invalid token).
## Test plan
- [x] `php artisan test
tests/Feature/OpenBanking/WiseBalanceSyncTest.php
tests/Feature/OpenBanking/WiseControllerTest.php` — 6 passing / 28
assertions
- [x] `vendor/bin/pint` — clean
- [ ] CI (full pest + lint + build)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
## Why
iOS PWAs hand the bank redirect back to the system browser (Safari),
where the app session does not exist. The old `/open-banking/callback`
sat behind `auth` + `verified` middleware, so Safari hit it with no
session → bounced to `/login`, the callback body never ran, and the
connection stayed `pending` forever (4 stale rows observed in prod).
## What
Make the bank connection flow session-independent end to end.
### 1. State-token callback (session-independent)
- `store()` generates `Str::random(40)`, persists it on the connection,
and passes it to `startAuthorization` as `state`.
- `/open-banking/callback` is now **public**. It resolves the connection
from `state_token` (`resolveConnectionFromState`) and derives the owner
from the connection, not the session.
- Connection is finalized server-side (`session_id`, status,
`state_token = NULL`) before any redirect.
- New migration adds the nullable, unique `state_token` column; the
column is hidden on the model.
### 2. Completion page instead of login bounce
When the callback runs without a session (the Safari case), the
connection is already finalized server-side. Instead of bouncing the
user to `/login`, render a standalone "go back to the app" page — their
session lives in the PWA, not here.
- New page `resources/js/pages/open-banking/connection-complete.tsx`
(success / error states, dark mode).
- `finishRedirect()` renders the page when `!Auth::check()`; logged-in
users still get the normal redirect.
### 3. Onboarding: land on the connections step + poll cross-browser
- A logged-in user finishing the flow lands straight on the onboarding
connections step (`?step=create-account`), now resolved server-side via
a validated `initialStep` prop so the step is deterministic.
- While on that step the page polls every 4s (`usePoll`, `only:
['accounts']`), so a connection finalized in another browser (PWA →
Safari) shows up without a manual refresh.
## Flow
```
POST /authorize → stateToken = Str::random(40)
→ startAuthorization(state = stateToken)
→ create BankingConnection(pending, state_token)
EnableBanking → callback?code&state=stateToken (PUBLIC, no session needed)
→ find connection WHERE state_token = state
→ createSession(code) → finalize (session_id, status, state_token = NULL)
→ session present? redirect to connections step / mapping
: render "go back to the app" completion page
PWA (logged in) → onboarding?step=create-account → polls accounts every 4s
```
## Tests
36 passing. Covers:
- state-token persistence and session-less finalization
- owner resolved from the connection regardless of who is authenticated
- completion page rendered (success + error) when no session; login
fallback when no connection resolves
- logged-in user redirected directly to the onboarding connections step
- `?step=create-account` lands on that step; unknown step falls back to
default
## Summary
Adds a **Notifications** section to the account settings page
(`/settings/account`, between Profile information and Update password)
where users can opt out of the daily "new transactions synced" email.
- New per-user preference `notify_on_bank_transactions_synced` on
`user_settings` (defaults to `true`, opt-out).
- `SendDailyBankTransactionsSyncedEmailJob` skips users who disabled it.
- Single generic `PATCH /settings/notifications` endpoint updates any
notification type via a key→column allowlist in
`NotificationPreferenceController::PREFERENCES`. Future notifications
only need a new entry there — no new route/controller.
- Email footer now links back to the settings section so users can
manage preferences.
## Testing
- `tests/Feature/Settings/NotificationPreferenceTest.php` — update,
unknown-key rejection, invalid value, auth, create-when-missing,
default-true, inertia prop.
- `tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php` — email
not sent when disabled.
Fixes two linked production banking-sync issues.
## PHP-LARAVEL-W — `cURL error 28: timed out` in
`EnableBankingProvider::getBalances` (8 events, 4 users, High)
`getBalances` called `$response->throw()` raw, so a connection timeout
(or ASPSP error) escaped as an **unhandled**
`ConnectionException`/`RequestException` and crashed the sync.
`getTransactions` already wraps these in
`TransientBankingProviderException` (which `implements ShouldntReport`
and is handled as a transient, retryable error in
`SyncBankingConnectionJob`).
→ `getBalances` now follows the exact same pattern. Genuine validation
errors (non-ASPSP 4xx) stay reportable.
## PHP-LARAVEL-2D — `SyncBankingConnectionJob has been attempted too
many times` (High, regressed)
The hanging balance call above pushed the job past its 120s `timeout`,
the worker was killed mid-job, and the retry tripped a
`MaxAttemptsExceededException`. That exception is thrown by the queue
worker (not catchable in `handle()`), and the job's `failed()` handler
**already** records the terminal `Error` state on the connection — so
the Sentry report is redundant operational noise.
→ Fixing W removes the main cause of the timeout. Additionally,
`MaxAttemptsExceededException` is no longer reported **for this job
only** (scoped via `dontReportWhen` on `$e->job?->resolveName()`); other
jobs still report it.
## Tests
- `getBalances` wraps connection failures and ASPSP errors as
non-reportable transient errors; keeps non-ASPSP client errors
reportable.
- `MaxAttemptsExceededException` is not reported for
`SyncBankingConnectionJob`, but still reported for other jobs.
Fixes PHP-LARAVEL-W, PHP-LARAVEL-2D.
## Summary
- add creditor/debtor fields to transactions with raw_data backfill
- store counterparties on bank sync and CSV/XLS imports
- add creditor/debtor filters plus hidden table columns
## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
tests/Feature/OpenBanking/TransactionSyncServiceTest.php
tests/Feature/TransactionFilterTest.php
- npm test -- resources/js/lib/file-parser.test.ts --run
- vendor/bin/pint --dirty --format agent
Note: `npm run types` still has pre-existing unrelated errors; no
creditor/debtor/import-related errors remained in filtered output.
## Screenshot
<img width="1241" height="676" alt="8odYWtFcvUM"
src="https://github.com/user-attachments/assets/55653485-d588-4beb-9e6a-5c7c81ba7cf8"
/>
## Summary
- sync user currency from the first account, manual or connected
- reuse one service across manual creation and open banking
mapping/auto-create flows
- keep later accounts from overwriting user currency
- add browser signup + onboarding account creation coverage
## Prod check
- users with any account: 210
- first account currency mismatches user currency: 64
- USD user + EUR first account: 36
- first account manual users: 189
- manual-first mismatches: 61
## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/OpenBanking/AccountMappingTest.php
tests/Feature/OpenBanking/AuthorizationControllerTest.php
tests/Feature/OpenBanking/IndexaCapitalControllerTest.php
tests/Feature/OpenBanking/BinanceControllerTest.php
tests/Feature/OpenBanking/BitpandaControllerTest.php
tests/Feature/OpenBanking/CoinbaseControllerTest.php
tests/Feature/Settings/AccountTest.php
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
--filter='syncs user currency from first onboarding account after
signup'
## Summary
- keep existing banking connections visible when reconnect callback
fails
- mark failed reconnects as error instead of soft-deleting them
- add regression test for failed ING-style reconnect
## Test
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php
## Summary
- backfill Coinbase portfolio balances for 12 previous months plus today
- retry missing historical backfill on later syncs
- add Coinbase candle pricing with USD fallback and mark Coinbase
accounts as investments
## Tests
- php artisan test --compact
tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php
tests/Feature/OpenBanking/AccountMappingTest.php
tests/Feature/OpenBanking/CoinbaseControllerTest.php
## Problem
Production user reported "inaccurate expenses, some appear multiple
times". DB inspection of their active BNP Paribas Fortis connection
confirmed duplicates growing every sync:
- `-3840` on `2026-05-11` x5
- `-2900` on `2026-05-08` x5
- `-2470` on `2026-05-12` x4
- 56 of 776 rows on the account had `external_transaction_id IS NULL`
- 16 (date, amount) duplicate groups, all with NULL upstream id
## Root cause
`TransactionSyncService::importTransaction()` short-circuited dedup when
both `transaction_id` and `entry_reference` were missing:
```php
$externalId = $data['transaction_id'] ?? $data['entry_reference'] ?? null;
if ($externalId) {
// dedup check
}
// else: fall through, always insert
```
BNP returns no stable id for certain card transactions (`status:
"OTHR"`, `bank_transaction_code.code: "CCRD"`, foreign currency). Every
cron tick (every 6h) re-inserts a fresh copy.
Confirmed with prod data: of 16 NULL-id duplicate groups on this user,
**zero** ever got upgraded to a real id later. BNP simply doesn't issue
one.
## Fix
Deterministic per-transaction fingerprint, persisted in a new column,
protected by a unique index.
- **Migration**: adds `transactions.dedup_fingerprint` (nullable string,
80) and unique index on `(account_id, dedup_fingerprint)`. The unique
index is the real source of truth — it also closes the race between
overlapping sync runs that the prior `exists()` + `create()` pattern
couldn't.
- **`TransactionFingerprint::for($data)`**: two-mode fingerprint. If
`transaction_id` or `entry_reference` exists, the fingerprint is based
only on that canonical upstream id. If no upstream id exists, the
fallback fingerprint uses the prod-verified stable fields:
`booking_date`, amount, currency, credit/debit indicator,
creditor/debtor names + accounts, bank tx codes, reference number, and
remittance info. It intentionally excludes volatile fields (`status`,
`value_date`, raw `transaction_date`). Prefix `fp_` avoids mixing with
bank-issued ids.
- **`TransactionSyncService`**:
- Always computes the fingerprint and writes it on every insert.
- Dedup lookup checks the fingerprint **and** (as a fallback) the legacy
`external_transaction_id` to gracefully handle rows imported before the
backfill runs.
- Wraps the insert in a `try/catch UniqueConstraintViolationException`
so concurrent syncs that pass `exists()` together don't crash.
## Rollout plan
Ship migration + service change → new duplicates stop. Existing
duplicate cleanup is intentionally out of scope for this PR.
## Tradeoff
Two genuinely distinct same-day, same-amount card transactions from the
same merchant on the same card collapse into one (no time-of-day in
raw_data for this BNP class). Today we over-count by 3–5x; after the fix
we may rarely under-count by 1. Acceptable net win, can monitor via logs
if needed.
## Tests
- `tests/Unit/Services/Banking/TransactionFingerprintTest.php` — 4 new
tests covering canonical id behavior and volatile-field exclusion.
- `tests/Feature/OpenBanking/TransactionSyncServiceTest.php` — 3 new
tests:
- Dedupes payloads without an upstream id across consecutive syncs.
- Doesn't crash when a payload arrives later with an upstream id
(bounded behavior).
- Dedupes against soft-deleted fingerprinted rows.
- Targeted suite green locally: **17 passed**.
## Files
-
`database/migrations/2026_05_13_085027_add_dedup_fingerprint_to_transactions_table.php`
*(new)*
- `app/Services/Banking/TransactionFingerprint.php` *(new)*
- `app/Services/Banking/TransactionSyncService.php`
- `app/Models/Transaction.php` (fillable)
- Tests above
## Out of scope
A smaller secondary pattern exists where BNP emitted distinct
`transaction_id`s for the same booking (Feb–Apr only, ~10 groups on the
reporter). Not addressed here because: (a) fingerprint includes
`transaction_id` so different ids = different fingerprints, (b) no
recent occurrences, (c) needs API-level analysis to determine when this
is genuine vs noise.
## Problem
Sentry:
[PHP-LARAVEL-1X](https://whisper-money.sentry.io/issues/PHP-LARAVEL-1X)
— 6 events, 2 users.
`IndexaCapitalClient::getPerformance()` throws `RequestException` when
Indexa Capital returns 404 (HTML page) for an account without
performance data — e.g. brand-new or inactive accounts. This aborts
`SyncBankingConnectionJob::syncIndexaCapital` and pages us via Sentry.
## Fix
Catch the 404 inside the client, log at info level, and return an empty
array. The downstream `IndexaCapitalBalanceSyncService` already
short-circuits when `portfolios` is empty, so the sync no-ops cleanly.
Other HTTP errors (auth, 5xx) keep throwing.
## Test
Added `returns empty performance when indexa capital responds 404` in
`IndexaCapitalBalanceSyncTest`.
```
php artisan test --filter=IndexaCapitalBalanceSyncTest
Tests: 12 passed
```
Fixes PHP-LARAVEL-1X
## Summary
- Keep non-onboarded users on the accounts step after bank authorization
errors
- Add feature and browser coverage for the callback error path
## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
--filter='returns to the accounts step'
- php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php
--filter='callback with error'
## Problem
Sentry showed two related production issues in the same sync path:
- `PHP-LARAVEL-13`: EnableBanking returned HTTP `400` from `GET
/accounts/{accountId}/transactions` with body `ASPSP_ERROR` / `Error
interacting with ASPSP`. This is an upstream bank/provider failure, but
the app threw an unhandled `RequestException`.
- `PHP-LARAVEL-14`: the same transactions endpoint timed out after 20s,
throwing an unhandled `ConnectionException`.
Both originated from `EnableBankingProvider::getTransactions()` and
bubbled through `SyncBankingConnectionJob`, creating Sentry issues for
expected transient provider/bank outages.
## New behavior
- Wrap EnableBanking `400 ASPSP_ERROR` responses in
`TransientBankingProviderException`.
- Wrap EnableBanking transaction connection failures / timeouts in the
same transient exception.
- Mark that exception as `ShouldntReport`, so these expected upstream
failures stop creating Sentry issues.
- Keep queue retry behavior intact. The job still retries and only marks
the connection as `Error` after normal retry exhaustion.
- Log transient sync failures as warnings instead of errors.
- Show users a retry-later message when retries are exhausted: the bank
provider is temporarily unavailable.
- Leave other `400` responses reportable. Validation / app-side request
bugs still throw `RequestException`.
- Leave auth failures and rate-limit handling unchanged.
Fixes PHP-LARAVEL-13
Fixes PHP-LARAVEL-14
## Testing
- `vendor/bin/pint --dirty --format agent`
- `php artisan test --compact
tests/Feature/OpenBanking/EnableBankingProviderTest.php
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php`
## Problem
Production logs show repeated EnableBanking 429s on the same
connections, every cron cycle:
```
[2026-05-04 18:00:55] EnableBanking API error status:429
body: [HUB046] Allowed number of accesses exceeded for consent
[2026-05-04 21:47:12] EnableBanking API error status:429
body: Daily PSU not present consultation limit has been exceeded
[2026-05-05 00:01:41] same connection, same error
[2026-05-05 06:01:41] same connection, same error
```
Root cause: `SyncBankingConnectionJob` returned early on 429s without
persisting any backoff state. The scheduler kept re-dispatching the same
connection on every run, hammering the provider and burning the daily
quota.
## Fix
Persist a per-connection backoff window so the scheduler stops
re-dispatching until the provider quota resets.
- New `rate_limited_until` column on `banking_connections`.
- On 429: derive the window from
1. `Retry-After` header if present,
2. "Daily ..." message → next UTC midnight (matches PSU daily limit
semantics),
3. default 1 hour (consent / generic).
- Job short-circuits with a `Skipped` sync log if the window is still
active.
- Successful sync clears the window.
- `SyncAllBankingConnectionsJob` + `SyncBankingConnections` command
filter out connections still inside their backoff.
## Tests
- Existing rate-limit test updated (now also asserts the backoff is
set).
- New: daily message → next UTC midnight.
- New: `Retry-After` header honoured (1800s).
- New: rate-limited connection skipped without calling provider.
- New: successful sync clears `rate_limited_until`.
- New: scheduler excludes connections whose backoff has not expired.
`php artisan test --compact
--filter="SyncBankingConnectionJobTest|SyncRetryAndLoggingTest|SyncBankingConnectionsCommandTest"`
→ 70 passed.
## Summary
- identify authenticated users on Sentry web requests with id and email
- add user and banking connection context to banking sync jobs
- cover Sentry context behavior with feature tests
## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/SentryUserMiddlewareTest.php
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
## Summary
- mark failed banking sync jobs as error so onboarding can continue
- add EnableBanking HTTP timeouts to avoid worker hard timeouts
- add regression coverage for failed active sync jobs
## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
--filter='failed sync job marks active connection as error'
- php artisan test --compact
tests/Feature/Onboarding/OnboardingSyncStatusTest.php --filter='returns
pending false when unsynced connection has an error status'
## Problem
`EnableBankingProvider::getTransactions` returned 422
`DATE_FROM_IN_FUTURE` when an account had a future-dated last
transaction (e.g. pending/scheduled). `linkedDateFrom` was set from
`lastTransaction->transaction_date` without bounding, producing
`dateFrom > dateTo`.
Sentry:
[PHP-LARAVEL-15](https://whisper-money.sentry.io/issues/PHP-LARAVEL-15)
## Fix
Clamp `linkedDateFrom` to `dateTo` (today) in
`SyncBankingConnectionJob::syncEnableBanking`.
## Tests
Added test covering future-dated last transaction case.
Fixes PHP-LARAVEL-15
## Summary
- redirect guest access to protected routes based on returning-user
cookie
- send new PWA guests to registration and returning guests to login
- persist returning-user cookie after register, login, and 2FA login
- keep explicit login links working via `force=1`
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/Auth/AuthenticationTest.php
tests/Feature/DashboardTest.php tests/Feature/Auth/RegistrationTest.php
## Summary
- save the first-sync email cutoff after the initial import finishes so
onboarding transactions are not counted as later sync activity
- add a regression test that reproduces onboarding imports and verifies
the daily sync email stays silent afterward
## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
- vendor/bin/pint --dirty --format agent
## Summary
- avoid sending bank transaction synced emails during 23:00-08:00 in the
user's local timezone by re-releasing the job until the next local 08:00
- deduplicate the daily bank transaction email by the user's local date
instead of the UTC date
- add feature coverage for quiet hours, first allowed local send time,
and local-day deduplication
## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
- vendor/bin/pint --dirty --format agent
## Notes
- existing 6-hour sync slots still leave at least one valid send slot
for every timezone
## Summary
- remove the Pennant-based `open-banking` flag and middleware gating so
open banking is always available for authenticated users
- simplify landing, onboarding, settings, and subscription flows to use
always-on open-banking behavior and remove stale frontend/shared flag
plumbing
- update open-banking tests and purge stored `open-banking` Pennant rows
## Testing
- `php artisan test --compact
tests/Feature/OpenBanking/InstitutionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BinanceControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/IndexaCapitalControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BitpandaControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/ConnectionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AccountMappingTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php`
- `php artisan test --compact tests/Feature/SubscriptionTest.php`
- `php artisan test --compact
tests/Feature/WelcomeBanksOrderingTest.php`
- `vendor/bin/pint --dirty --format agent`
## Notes
- `php artisan pennant:purge open-banking` was run to remove stale
stored values
- `php artisan test --compact tests/Browser/OnboardingFlowTest.php`
still has one unrelated real-estate onboarding browser failure (`it
creates a real estate account during onboarding when feature is
enabled`)