> Sentry's MCP token is still expired, so this came from the production
DB again.
## Two small fixes, and a much bigger finding I am *not* fixing here
I went after the rate-limit problem I flagged as the next big thing. The
headline number is real — **490 rate-limit failures in 14 days**, and
**14 of 18 Trade Republic connections have never once completed a sync**
— but my framing of it was wrong, and the change I first wrote would
have made those users slightly worse off. What survives is two narrow,
verified fixes. The actual cure is scoped at the bottom.
## 1. Recognise a spent daily allowance from what the banks actually say
`resolveRateLimitBackoffUntil` treated a 429 as a daily-allowance error
only when the message contained the literal word `daily`. Enable
Banking's banks say it several other ways, all of which were getting the
one-hour burst fallback:
| wording | events / connections (45d) |
|---|---|
| `[HUB046] Allowed number of accesses exceeded for consent.` | 234 / 88
|
| `Access exceeded` | 94 / 1 |
| `The access on the account has been exceeding the consented
multiplicity per day.` | 39 / 3 |
| `CLO03941 - Operación no disponible. Has superado el número máximo de
accesos.` (+ Catalan twin) | 5 / 2 |
The old one-hour window gave a free natural experiment: the later
same-day runs *did* happen, so I can check whether they recovered.
Across every wording above, **zero** later same-day attempts succeeded.
`Too many requests` is deliberately left on the short path — 1.7% of its
later same-day attempts do recover, so it is not a clean daily reset.
Matched on prose because there is nothing better: `detail.error_name` is
`RateLimitException` for a spent allowance and a plain burst alike.
Marked with a `ponytail:` note, including that a future attempt at a
structured field has to start by logging the full body, since
`error_message` keeps only the first 120 bytes.
**Honest scope**: for the largest widened cohort (HUB046) this saves
almost nothing, because those 429s land on the 18:00 run and the next
scheduled run is already after midnight. The saving is real for banks
that exhaust their allowance earlier — Eurocaja Rural burns its last two
runs every day, Postepay's wording says "per day" outright.
## 2. Stop the backoff from silently swallowing a manual sync
`ConnectionController::sync` cleared the status, the error message and
the failure counter, then dispatched — but left `rate_limited_until`,
which the job checks first. So "Sync started. Transactions will be
updated shortly." was followed by nothing at all. Fix (1) makes that
materially worse, turning a ≤1h swallow into the whole evening for the
banks that exhaust their allowance at 18:00.
The backoff is there to keep the *scheduler* off a provider that asked
us to stop. A person asking for their own connection is a different
request, and PSD2 budgets access with the user present separately from
unattended access. The other "try again" paths already clear everything
else on the connection; this one row was the omission.
## What I dropped, and why
I first also raised the unexplained-429 fallback from 1h to 7h, so it
would outlast the six-hourly schedule instead of expiring five hours
before it — the mechanism really is inert today, and I have the
receipts: of the ten connections currently holding a
`rate_limited_until`, nine are exactly 60 minutes past the run that set
them, because Enable Banking never sends `Retry-After`.
Both reviews showed it is worthless where it would apply, from different
angles, and they were right:
- Trade Republic is 81% of all rate limits, and its **first** run after
UTC midnight 429s as often as its fourth — 79% vs 73% over 21 days. Six
idle hours already don't help, so no sub-day window can.
- The cohort is bimodal: 8 connections with **zero** successes in 21
days, 3 at ~97%. That is a per-consent condition, not something our
cadence controls.
- Meanwhile those connections import transactions on nearly every run. A
7h window settles into 2 runs/day, so it would only have doubled their
worst-case transaction staleness in exchange for nothing.
## The actual cure, deliberately deferred
Both reviews rank two levers above anything in this PR, and they are
what fix the €0-balance users. **This PR does not claim to.**
**(a) Don't discard a run because the balance call was rate-limited.**
This is the whole user-visible bug. Transactions import fine; the 429
lands on `getBalances`, which `EnableBankingSyncer::syncBalances`
rethrows purely so the job can set the backoff — and that discards the
run, so `last_synced_at` is never set. Consequences: the connections
page shows an **indefinite spinner** ("Syncing transactions and
balances…") and polls the server **every 5 seconds** for as long as it
is open, `bank_transactions_email_cutoff_at` is never set so the daily
email never works, and ~11 users see a €0 balance feeding net worth.
Swallowing the 429 into metadata and applying the backoff on the success
path costs zero extra provider calls and fixes all of it. Its own PR
because threading the backoff out of the syncer needs a design, and last
time I let a partial run report success both reviews were right to stop
me.
**(b) Don't spend a provider access to rewrite a balance row we already
have.** `BalanceSyncService` keys the row on `balance_date`, so four
runs a day overwrite one value: ~1,740 balance calls/day producing ~305
new rows, ~82% redundant. This is the fix for the *non*-Trade-Republic
cohort, and the evidence is clean — **every one of the 86 non-TR
rate-limit events in 14 days happened on a connection that already had a
balance row for that day**, and HUB046 fires on the 4th run of the day
and essentially never on the first three (148 events at 18:00 vs 0 at
00:00 and 06:00). Needs a migration: the guard cannot key on
`balance_date` (some banks report yesterday's `reference_date`, so it
would never fire) nor on `updated_at` (an unchanged `updateOrCreate`
leaves the model clean), so it wants an explicit last-read timestamp.
Also noted: the rate-limit message never reaches the user at all —
`applyRateLimitBackoff` leaves the status Active while the connections
page only renders `error_message` for Error connections — and the copy
still says "wait a few minutes".
## Verification
`tests/Feature/OpenBanking`: 365 tests, 355 pass, the **same 10 failures
as clean main** (Inertia page-render tests hitting the SSR `/render`
endpoint). The daily-allowance test became a 7-case dataset covering
every wording production sends, and it fails on all the new ones against
the old matcher; the manual-sync test fails without its one-line fix.
Net −7 lines of test code, because two tests I had written folded into
the existing one. `pint`, `crap` and `dry` green.
## Auto-merge
Enabled. Both changes are small and evidence-backed: the matcher only
moves wordings that provably never recover within the day onto a window
that already existed and is already tested, and the second is a single
column added to an update that already clears three of its siblings.
## 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.
## What
Unifies keys that were repeated across the codebase into two PHP enums
as the single source of truth. Two independent commits:
### `refactor(banking)` — `BankingProvider` enum
- New `App\Enums\BankingProvider` (`indexacapital`, `binance`,
`bitpanda`, `coinbase`, `wise`, `enablebanking`).
- `BankingConnection`'s `provider` column is cast to the enum → magic
strings gone from the model, controllers (`match`), requests, jobs,
commands, factory and `where('provider', ...)`.
- Logic that was duplicated, now on the enum:
- `usesApiKey()` — API-key auth (everything except EnableBanking).
- `defaultAccountType()` — provider → account type (removes the
duplicated ternary in `CreatesAccountsFromPending` and
`AccountMappingController`).
### `refactor(i18n)` — `Locale` enum
- New `App\Enums\Locale` (`en`/`es`/`fr`).
- Unifies the `['en','es','fr']` list (validation in `SetLocale` and
`ProfileUpdateRequest` via `Rule::enum`) and the `Accept-Language`
detection (`Locale::detectFromHeader`), previously duplicated in
`SetLocale` and `CreateNewUser`.
## Interaction with Wise (PR #525)
Rebasing onto Wise surfaced two issues this PR fixes:
1. **`isWise()` was broken** once `provider` is cast (it compared `===
'wise'` against an enum → always `false`).
2. **Account type**: Wise uses an API key (✓ it gets the auth-failed
email) but is a **checking** account, not an investment one. Account
type now flows through `defaultAccountType()` (Wise/EnableBanking →
Checking; indexa/binance/bitpanda/coinbase → Investment).
## Behavior change (minor)
`CreateNewUser` now detects `fr` at registration (previously only
`es`/`en`), consistent with the existing French support.
## Out of scope
- Frontend (`['indexacapital','binance',...]`, `provider: string`):
syncing TS with the PHP enums would need typegen. It still receives the
string via `->value`.
- `TransactionSource::Wise/EnableBanking`: a separate enum (transaction
origin), not a duplicated check.
## Tests
- New: `tests/Unit/Enums/BankingProviderTest.php`,
`tests/Unit/Enums/LocaleTest.php`.
- `vendor/bin/pint --test` ✓ · Larastan ✓ · full suite (excluding
Browser) **1615/1616** ✓.
## 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 month-end command to revoke old Enable Banking sessions for users
without a paid plan while keeping their linked accounts as manual
accounts
- extract banking disconnect flow into a reusable action so manual
disconnects and scheduled cleanup share same revoke and detach behavior
- add feature coverage for free-user cleanup, paid-user skips,
under-6-hour skips, non-Enable Banking skips, and revoke failure
fallback
## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/CancelFreeEnableBankingConnectionsCommandTest.php
tests/Feature/OpenBanking/ConnectionControllerTest.php
## 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
## 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
- Adds Binance as a third banking provider alongside EnableBanking and
Indexa Capital
- Binance appears in the institution list for **all countries**, with
the full list sorted alphabetically
- Creates a single "Crypto Portfolio" investment account with total
portfolio value converted to the user's preferred currency
- Supports direct fiat pairs (e.g. BTCEUR), USD stablecoin 1:1 mapping,
and USDT fallback conversion
## Changes
- **Migration**: adds encrypted `api_secret` column to
`banking_connections`
- **BinanceClient**: HMAC-SHA256 authenticated API client for account
data and ticker prices
- **BinanceBalanceSyncService**: converts all non-zero balances to fiat
via direct pairs or USDT fallback
- **BinanceController + ConnectBinanceRequest**: validates credentials,
creates connection and single account
- **SyncBankingConnectionJob**: new `syncBinance()` branch
- **AccountMappingController**: Binance uses Investment account type
- **Frontend**: Binance institution for all countries, API Key + Secret
form fields, alphabetically sorted list
- **Factory**: `binance()` state on `BankingConnectionFactory`
## Test plan
- [x] `BinanceControllerTest` — 6 tests (valid connection, invalid
credentials, account-mapping flag, feature flag, validation, user
currency)
- [x] `BinanceBalanceSyncTest` — 7 tests (direct EUR pair, USDT
fallback, USD stablecoins, locked balances, same-date update, empty
balances, missing external ID)
- [x] Full test suite passes (545 tests)
- [x] Manual: open connection dialog → select any country → Binance
appears alphabetically → select Binance → API Key + Secret form →
connect
## Summary
- Allow users to connect their Indexa Capital (Spanish robo-advisor)
account via API token
- Adds "Indexa Capital" option when selecting Spain in the bank
connection dialog
- Token-based auth flow: user enters API token (instead of OAuth
redirect), validated against Indexa API, stored encrypted
- Syncs portfolio balance only (no transactions) from the
`/accounts/{id}/performance` endpoint
- Accounts created as `Investment` type
- Reuses existing account mapping flow when the `account-mapping`
feature flag is active
- Disconnect flow skips session revocation for Indexa (no OAuth session
to revoke)
## New files
- `IndexaCapitalClient` — HTTP client for Indexa Capital API
(`X-AUTH-TOKEN`)
- `IndexaCapitalBalanceSyncService` — Syncs portfolio value into
`account_balances`
- `IndexaCapitalController` — Token validation + connection creation
- `ConnectIndexaCapitalRequest` — Form request validation
- Migration adding encrypted `api_token` column to `banking_connections`
## Test plan
- [x] 6 controller tests (connect, invalid token, mapping, feature flag,
validation, multi-account)
- [x] 4 balance sync tests (sync, update, skip, missing field)
- [x] 3 sync job tests (balance-only, no expire, no email for Indexa)
- [x] 1 disconnect test (no revokeSession for Indexa)
- [x] All 530 existing tests still pass
## Summary
- Adds **EnableBanking** as the first open banking provider, allowing
users to connect real bank accounts and automatically sync transactions
and balances
- Uses a **`BankingProviderInterface`** contract so future providers
(Plaid, GoCardless, etc.) can be added by implementing the same
interface
- Feature-flagged behind the **`open-banking`** Pennant flag (default:
off)
- Connected accounts are **unencrypted** and transactions have `source =
'enablebanking'`
### What's included
**Backend:**
- `BankingProviderInterface` contract + `EnableBankingProvider`
implementation (JWT RS256 auth)
- `BankingConnection` model with full lifecycle (pending →
awaiting_mapping → active → expired/revoked/error)
- `TransactionSyncService` — pagination, deduplication by
`external_transaction_id`, amount/date mapping
- `BalanceSyncService` — preferred balance type selection (CLBD → ITAV
fallback)
- Authorization flow: start auth → bank redirect → callback → session
creation → account mapping → sync
- `SyncBankingConnectionJob` (unique per connection, 3 retries) +
scheduled every 6 hours
- `banking:sync` artisan command
- 5 migrations: `banking_connections` table, account fields, transaction
`external_transaction_id`, `pending_accounts_data`, `linked_at`
**Frontend:**
- Manual vs Connected account choice in the create account dialog
- Multi-step bank connection dialog (country → bank selection →
confirmation → redirect)
- Account mapping page — map discovered bank accounts to existing
accounts, create new ones, or skip
- Settings/Connections page with status badges, sync/disconnect actions
- "Connected" badge on linked accounts in settings
**Tests:**
- 49 tests covering feature flags, controllers, account mapping,
transaction sync, balance sync, deduplication, and pagination
### Feature Flags
This PR introduces **two Pennant feature flags**:
1. **`open-banking`** — Gates the entire open banking feature
(institutions endpoint, authorization flow, connections page). When
disabled, all open banking routes return 404.
2. **`account-mapping`** — Controls whether users see an intermediate
account mapping step after connecting a bank. When **enabled**, users
are redirected to a mapping page where they can choose to create new
accounts, link to existing ones, or skip each discovered bank account.
When **disabled**, all discovered accounts are automatically created
(original behavior). Linked accounts only sync transactions from their
last transaction date and only update the current balance from the
provider (no historical balance calculation or daily balance tracking).
Enable per-user:
```bash
php artisan feature:enable open-banking user@example.com
php artisan feature:enable account-mapping user@example.com
```
Enable for all users:
```bash
php artisan feature:enable open-banking all
php artisan feature:enable account-mapping all
```
## Test plan
- [x] Enable feature flag: `php artisan feature:enable open-banking`
- [x] Verify "Connected" option appears in create account dialog
- [x] Start authorization flow and verify redirect to bank
- [x] With `account-mapping` **disabled**: verify callback creates
accounts directly and dispatches sync
- [x] With `account-mapping` **enabled**: verify callback redirects to
mapping page
- [x] Test mapping page: create new, link to existing, and skip actions
- [x] Verify linked accounts sync only from last transaction date and
only update current balance
- [x] Verify connections page shows "Setup Required" badge for
awaiting_mapping status
- [x] Run `php artisan banking:sync` and verify transactions sync
- [x] Verify connections page shows status, sync, and disconnect actions
- [x] Run full test suite: `php artisan test --compact`