## 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`)
## Summary
- add a per-connection cutoff timestamp so silent first/full sync
imports are excluded from later daily bank sync emails
- keep the existing one-email-per-day user cap while still reporting
transactions created after the silent sync cutoff
- add regression coverage for silent first sync, full sync, and
post-cutoff reporting behavior
## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
- vendor/bin/pint --dirty --format agent
## Summary
- move bank transaction sync emails from per-connection inline sends to
a unique per-user daily job
- send at most one bank sync email per user per day while still
including all unreported enable-banking transactions since last reported
mail
- keep first-ever connection sync silent and add coverage for same-day
suppression and next-day catch-up emails
## Testing
- php artisan test --compact
tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
## Summary
- **Store `invested_amount` in user's currency** (e.g. EUR) instead of
the account's currency (e.g. BTC), since it represents "how much of my
money did I put in" — a concept tied to the user's home currency.
- **Flip `display_invested_amount`** in API responses: now converts from
user currency → account currency (previously account → user), for the
chart's account-currency toggle mode.
- **Remove redundant conversions** in `AccountMetricsService` since
invested amounts are already in user currency after sync.
## Changes
### Backend (5 files)
- **BinanceBalanceSyncService** — convert to
`$account->user->currency_code` instead of `$account->currency_code`
- **BitpandaBalanceSyncService** — same currency target change
- **IndexaCapitalBalanceSyncService** — inject
`CurrencyConversionService`, convert invested amount float to user
currency before storing as cents
- **DashboardAnalyticsController** — flip `display_invested_amount`
conversion direction (user→account) in both monthly and daily endpoints
- **AccountMetricsService** — remove 4 now-redundant invested_amount
conversion spots (values already in user currency)
### Frontend (6 files)
- **update-balance-dialog.tsx** — invested amount input uses user
currency
- **balances-modal.tsx** — invested amount display and edit uses user
currency
- **account-balance-chart.tsx** — currency toggle: user-currency mode
keeps invested_amount as-is; account-currency mode swaps to
`display_invested_amount`
- **import-balances-drawer.tsx** — passes `investedAmountCurrencyCode`
(user currency) to sub-components
- **import-balance-step-mapping.tsx** /
**import-balance-step-preview.tsx** — format invested amounts in user
currency
### Tests (2 files)
- **IndexaCapitalBalanceSyncTest** — use `app()` instead of `new`
(constructor now has DI), pin account currency to USD for deterministic
assertions
- **DashboardAnalyticsTest** — add EUR-based exchange rate, flip
assertion from `/ 0.90` to `* 0.90`
## Summary
- **Bug fix:** Dashboard and accounts index cards displayed the
account's original currency code (e.g. `BTC`) even though balances were
already converted to the user's currency (e.g. `EUR`). Now passes
`displayCurrencyCode` to card components so the label matches the
converted amount.
- **Feature:** Added a currency toggle on the account detail chart to
switch between the account's native currency and the user's main
currency. Extends the balance evolution APIs with `display_*` fields
when conversion applies.
- **First-account restriction:** Restricts first-account creation to
primary (fiat) currencies only, ensuring the user's base currency is
always widely supported.
## Changes
### Bug fix — currency label on cards
- `AccountBalanceCard` / `AccountListCard`: Added `displayCurrencyCode`
prop; all amount renders now use it instead of `account.currency_code`
- `dashboard.tsx`: Passes `netWorthEvolution.currency_code` as
`displayCurrencyCode`
- `Accounts/Index.tsx`: Passes `auth.user.currency_code` as
`displayCurrencyCode`
### Backend — API extension
- `DashboardAnalyticsController`: Both `accountBalanceEvolution()` and
`accountDailyBalanceEvolution()` now return `display_value`,
`display_invested_amount`, `display_mortgage_balance` per data point and
a top-level `display_currency_code` when the account currency differs
from the user's
### Frontend — currency toggle
- New `ChartCurrencyToggle` component with `ToggleGroup` showing
currency code labels (e.g. `BTC` / `EUR`)
- `ChartSettingsPopover`: Extended with optional `currencyToggle` prop
for mobile
- `AccountBalanceChart`: Full integration — all amounts, trends,
tooltips, MoM chart, and equity swap to `display_*` values when toggle
is set to user currency
### First-account currency restriction
- `StoreAccountRequest`: First account limited to primary currency codes
- `AccountForm` / `CreateAccountDialog` / `StepCreateAccount`: Pass
`usePrimaryCurrenciesOnly` when applicable
### Tests
- 5 new Pest tests for `display_*` fields in balance evolution endpoints
- 3 new Vitest tests for `deriveAccountMetrics` (mixed-currency, empty
data, invested amounts)
- 1 new Pest test for first-account BTC rejection
## Summary
- Removes the `account-mapping` Pennant feature flag entirely, making
the account mapping flow (pending accounts data + map-accounts page) the
default and only code path
- Removes the old direct-creation branches from all banking controllers
(Authorization, Bitpanda, Binance, IndexaCapital)
- Extracts a shared `CreatesAccountsFromPending` trait for the
onboarding auto-create logic
- Updates all controller tests to reflect the always-on mapping behavior
## Changes
### Backend
- **AppServiceProvider** — removed `account-mapping` flag definition
- **AuthorizationController** — removed flag check +
`createAccountsFromSession()` dead code; always stores
`pending_accounts_data` and redirects to mapping
- **BitpandaController / BinanceController / IndexaCapitalController** —
removed flag checks, old inline account creation, and unused imports
- **HandleInertiaRequests / ActivateDevelopmentFeatures /
ResolvesFeatures** — removed `account-mapping` from flag arrays
- **New `CreatesAccountsFromPending` trait** — shared auto-create logic
for onboarding path (to be consumed next)
### Frontend
- Removed `'account-mapping'` from the TypeScript `Features` interface
### Tests
- Merged flag-specific test variants into single always-on tests
- Removed redundant tests that tested the old disabled-flag code path
- Updated assertions to check `pending_accounts_data` instead of direct
account creation
## Summary
- **Fixes the broken retry mechanism** — after the first failed attempt
set status to `Error`, subsequent retry attempts bailed out because
`isActive()` returns `false` for `Error`. Now both `Active` and `Error`
statuses are syncable.
- **Adds auto-retry across scheduled runs** —
`SyncAllBankingConnectionsJob` and `banking:sync` command now include
`Error` connections where `consecutive_sync_failures < 3` (configurable
via `MAX_SCHEDULED_RETRIES`). After 3 full dispatch cycles, manual
intervention is required.
- **Logs every sync attempt to DB** — new `banking_sync_logs` table
records status (Success/Failed/Skipped), attempt number, error details,
duration, and metadata for each sync.
## Changes
### Core logic (`SyncBankingConnectionJob`)
- `isSyncableStatus()` allows both `Active` and `Error` through the gate
- Temporary errors: status only set to `Error` on the final attempt
(attempt 3); earlier attempts re-throw without changing status
- Permanent auth errors (401/403): `$this->fail()` called immediately,
`consecutive_sync_failures` set beyond the cap
- Rate limit (429): handled silently (existing behavior preserved)
- Every attempt is logged to `banking_sync_logs`
### Query updates
- `SyncAllBankingConnectionsJob`: includes `Error` connections under
retry cap
- `SyncBankingConnections` command: same query update for
`--user`/`--connection` filtered runs
### Controller updates
- `ConnectionController::sync()` and `updateCredentials()` reset
`consecutive_sync_failures` to 0
### New files
- `BankingSyncLogStatus` enum (Success, Failed, Skipped)
- `BankingSyncLog` model
- Two migrations: `add_consecutive_sync_failures` column,
`create_banking_sync_logs` table
### Tests
- Updated 3 existing auth error tests in `SyncBankingConnectionJobTest`
(24 pass)
- Added 17 new tests in `SyncRetryAndLoggingTest` covering retry
behavior, sync logging, scheduled retry inclusion/exclusion, and manual
retry reset
- All 10 `SyncBankingConnectionsCommandTest` tests still pass
## Summary
- A `429 ASPSP_RATE_LIMIT_EXCEEDED` response from the bank's API was
incorrectly marking connections as `status=error`, blocking all future
syncs.
- Rate limit errors are transient — the connection is still valid and
should be retried on the next scheduled sync.
- Added `isRateLimitError()` check in the `catch` block of
`SyncBankingConnectionJob`: on 429, the job returns early without
updating the connection status or error message.
## Problem
Enable Banking issues **new account UIDs** (`external_account_id`) with
every new session. On reconnect, `AuthorizationController::callback()`
was correctly updating `session_id` on the `banking_connections` table
but **never refreshing `external_account_id` on child accounts**.
When the background `SyncBankingConnectionJob` ran after a reconnect, it
called `GET /accounts/{old-uid}/transactions` using stale UIDs from the
expired session, causing Enable Banking to return `401 EXPIRED_SESSION`
— surfaced to users as _"Authentication failed. Your credentials may
have expired or been revoked."_
## Changes
- **`AuthorizationController`** — added `refreshAccountIds()` private
method, called in the reconnect branch after `session_id` is updated and
before the sync job is dispatched. Matches existing accounts by **IBAN
first**, falls back to **positional order** (`created_at ASC`) for
legacy accounts without a stored IBAN.
- **`AuthorizationController`** — `createAccountsFromSession()` and
`createAccountsFromPending()` now persist the `iban` field on account
creation so future reconnects can use IBAN matching.
- **`Account` model** — `iban` added to `$fillable`.
- **Migration** — adds nullable `iban` column to `accounts` table.
## Tests
4 new feature tests in `AuthorizationControllerTest`:
- `reconnect callback updates external_account_id when enable banking
issues new account uids`
- `reconnect callback matches accounts by iban before falling back to
position`
- `reconnect callback uses positional fallback for accounts without
stored iban`
- `callback stores iban when creating accounts for the first time`
## Deploy notes
Run the migration after deploying:
```
php artisan migrate --force
```
## Summary
- Add `reauthorize` endpoint and reconnect detection in OAuth
`callback()` so users can re-authorize a revoked EnableBanking session
without losing their accounts or transaction history
- Replace "Retry" with "Reconnect" button (dropdown + error panel) for
EnableBanking authentication errors; catch-up sync of missed
transactions is handled automatically by the existing
`SyncBankingConnectionJob` via `last_synced_at`
- Add 5 missing Spanish translations (`Reconectar`, `Autenticación
fallida...`, `Error al iniciar la reautorización.`, `Error al
reconectar...`, `Cuenta bancaria reconectada exitosamente.`) and wrap
the reconnect flash message in `__()`
- 7 new Pest tests covering all reauthorize scenarios and the reconnect
callback path (15 total passing)
## Summary
- When a user already has a connection to a bank/provider, that
institution is now hidden from the list when they open the \"Connect
Bank\" dialog
- Applies to all providers: EnableBanking institutions (matched by
`aspsp_name`), Binance, Bitpanda, and Indexa Capital (matched by
`provider`)
- `ConnectAccountDialog` accepts a new `connections` prop (passed from
the connections settings page where it's already available);
`ConnectAccountInline` (used in onboarding) accepts an optional
`connections` prop defaulting to `[]`
## Tests
- Added 2 backend tests to `ConnectionControllerTest` verifying the data
contract: the connections page sends `provider` and `aspsp_name` fields
for all provider types, which the frontend filtering logic depends on
## Summary
- **Gate bank connection flow** — free-tier users see an upgrade dialog
instead of the bank connection flow (connections page, account creation
dialog). Backend 402 guard added as safety net in
`AuthorizationController`.
- **Billing page upgrade UI** — non-subscribed users see a plan selector
with monthly/yearly options and an upgrade CTA; subscribed users see the
existing Stripe portal button. Connected Bank Accounts added as the
headline benefit.
- **Upgrade dialog** now redirects to `/settings/billing` (plan
selector) instead of the checkout page directly.
- **Spanish translations** added for all new UI strings.
## Screenshots
<img width="804" height="509" alt="image"
src="https://github.com/user-attachments/assets/6238f72a-a5e2-45f8-974a-7a664f976b83"
/>
<img width="1122" height="892" alt="image"
src="https://github.com/user-attachments/assets/8438bbe2-d3ec-48fb-b3c0-33a940dd94a7"
/>
## Summary
- The \"Connected\" account card in `StepCreateAccount` was rendered
unconditionally during onboarding, allowing users without the
`open-banking` Pennant flag to see and interact with the bank connection
option
- Now follows the same pattern as `CreateAccountDialog`, which already
correctly checks `features['open-banking']` before showing the Connected
option
- The grid also collapses to a single column when only the Manual option
is available
## Changes
- **`resources/js/components/onboarding/step-create-account.tsx`** —
reads `features['open-banking']` from shared Inertia props and
conditionally renders the Connected card, the connected inline flow, and
the "Pro feature" hint message
- **`tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php`** — two
new tests asserting the flag value is correctly shared to the frontend
on the onboarding page (both enabled and disabled states)
## Why
### Problem
The Indexa Capital dashboard shows +€11,339.01 profit for the Index
funds account, but our app shows +€9,680.43 — a €1,658.58 discrepancy.
The invested amount (and therefore the profit) is wrong.
### Root Cause
`calculateInvestedAmount()` used `instruments_cost + cash_amount` to
determine how much was invested. However, `instruments_cost` is the
**cost basis of currently held fund shares**, not the actual money
deposited. When Indexa rebalances portfolios, they sell shares
(realizing gains) and buy new ones at a higher cost basis. This inflates
`instruments_cost` over time without any new deposits, making the
"invested" figure too high and profit too low.
## What
### Changes
- Use `net_amounts` from the Indexa Capital API response instead of
`instruments_cost + cash_amount`
- `net_amounts` is an object keyed by date (`YYYYMMDD` format), where
each value is the cumulative net investment (inflows - outflows -
tax_outflows), matching Indexa Capital's own "investment" figure
- Keep `total_amount - return` as a fallback when `net_amounts` is
unavailable
- Return `null` when neither data source is available
### Concrete numbers (Index funds account, 2026-02-24)
| | Old (wrong) | New (correct) |
|---|---|---|
| Invested | €48,081.67 | €46,423.09 |
| Profit | €9,680.43 | €11,339.01 |
## Verification
### Tests
- Updated `stores invested_amount from net_amounts data` — verifies
invested_amount is sourced from `net_amounts`, not `instruments_cost +
cash_amount`
- Updated `stores null invested_amount when net_amounts is missing` —
verifies graceful null when API lacks `net_amounts`
- Updated `falls back to total_amount minus return when net_amounts is
missing` — verifies fallback path
- All 11 balance sync tests pass, all 23 SyncBankingConnectionJob tests
pass
## Why
### Problem
When API tokens for Indexa Capital, Binance, or Bitpanda expire or are
revoked, syncs fail silently with 401/403 errors. Users have no way to
replace their credentials without disconnecting and recreating the
entire connection (losing account mappings and history).
## What
### Changes
- **Auth failure email notification**: on the final retry attempt (3rd
of 3), if a sync job fails with 401/403 for an API-key provider, an
email is sent to the user with a link to the connections settings page
- **Update credentials endpoint**: `PATCH
/settings/connections/{connection}/credentials` validates new
credentials against the provider API before saving, then triggers a sync
- **Update credentials dialog**: provider-specific form (API token for
Indexa/Bitpanda, API key + secret for Binance) shown via an "Update
Credentials" button in the error banner and dropdown menu
- **Mailable**: `BankingConnectionAuthFailedEmail` follows existing
patterns (queued, rate-limited, Markdown template)
- **Form request**: `UpdateConnectionCredentialsRequest` with dynamic
validation rules per provider and authorization check
### Files changed
| File | Change |
|------|--------|
| `app/Jobs/SyncBankingConnectionJob.php` | Send auth failed email on
final attempt for auth errors on API-key providers |
| `app/Mail/BankingConnectionAuthFailedEmail.php` | New queued mailable
|
| `resources/views/mail/banking-connection-auth-failed.blade.php` |
Email template |
| `app/Http/Controllers/OpenBanking/ConnectionController.php` |
`updateCredentials()` action with provider credential validation |
| `app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php`
| Dynamic validation per provider |
| `routes/settings.php` | PATCH route for credential updates |
| `resources/js/components/open-banking/update-credentials-dialog.tsx` |
Dialog with provider-specific fields |
| `resources/js/pages/settings/connections.tsx` | Update Credentials
button in error state and dropdown |
## Verification
### Tests
- **12 new tests** across 2 test files, all passing:
- `SyncBankingConnectionJobTest`: 5 tests covering email sent on final
retry (Indexa 401, Binance 403), not sent before final retry, not sent
for non-auth errors, not sent for EnableBanking
- `ConnectionControllerTest`: 7 tests covering valid credential update
for each provider, invalid credentials, EnableBanking rejection,
authorization, feature flag, required field validation
- Full OpenBanking test suite: **145 tests, 473 assertions** passing
## Summary
- Subsequent syncs (every 6h) now only process recent data instead of
re-syncing full history, reducing unnecessary API calls and database
writes
- Full sync still runs automatically on first connection and can be
forced anytime with `banking:sync --full`
- Centralizes `isFirstSync` logic in `SyncBankingConnectionJob` and
propagates the `fullSync` flag through the entire chain: Command →
`SyncAllBankingConnectionsJob` → `SyncBankingConnectionJob` → provider
services
## Changes by provider
- **Indexa Capital**: Skips portfolio entries older than the last
recorded balance date on incremental syncs (the API doesn't support date
filtering, so filtering is done client-side)
- **Binance**: Reuses stored `invested_amount` from the database on
subsequent syncs instead of fetching up to 2 years of deposit/withdrawal
history in 90-day windows
- **EnableBanking / Bitpanda**: Already minimal — no changes needed
## Testing
- Fixed 6 existing Binance tests to pass `isFirstSync: true` for
invested amount calculation
- Added 7 new tests covering incremental sync behavior, full sync
override, and `--full` flag propagation