validationFailureLogMessage() was an abstract hook whose only job was to return a
cosmetically different warning string per provider ("Binance credential validation
failed", "Indexa Capital token validation failed", ...). It carried no functional
value and forced five one-line overrides.
Log a single structured message in the base with the provider enum value as
context, which is more useful for filtering than the free-text variants, and
remove the hook and its five overrides. Also restore the array-shape PHPDoc on
IndexaCapital's buildPendingAccounts() that the shared mixed signature dropped.
Only the warning-log text changes (now includes a "provider" key); the HTTP
responses are untouched. OpenBanking feature tests pass (304/304).
Binance, Bitpanda and Coinbase still repeated two identical hooks after the first
extraction: aspspCountry() (from $validated['country']) and buildPendingAccounts()
(a single "Crypto Portfolio" pending account). That was the very duplication the
refactor set out to remove.
Add CryptoPortfolioConnectController between the shared base and the three crypto
controllers to implement both hooks once. The pending-account uid is derived from
the provider enum value (binance/bitpanda/coinbase), so the generated uids
("binance-portfolio", etc.) are unchanged. Each crypto controller now declares
only its client, names, logo and error copy.
Behavior unchanged; OpenBanking feature tests still pass (304/304).
The Binance, Bitpanda, Coinbase, Indexa Capital, and Interactive Brokers connect
controllers each carried a ~60-line store() that was near-identical: subscription
gate, credential validation, Bank + BankingConnection creation, pending-account
mapping, and the onboarding/redirect branch. Any behavior change or bug fix had
to be repeated five times.
Introduce an abstract OpenBankingConnectController that owns the shared flow via a
template method (connect()), with per-provider extension points: provider(),
providerName(), bankLogo(), aspspCountry(), fetchProviderData(),
validationFailureLogMessage(), credentialErrorMessage(), buildPendingAccounts(),
and an optional emptyProviderDataMessage() guard (used by Interactive Brokers).
Each controller now declares only what varies.
Behavior is unchanged; the existing OpenBanking feature tests pass (304/304).
Wise is intentionally left as-is: it builds pending accounts before creating the
connection and has its own empty-result guard, so it does not fit this flow.
## 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
- 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
- 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
- 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
## 🚪 Why?
### Problem
The onboarding flow had no integration with connected (bank-linked)
accounts. Users who connected a bank via OAuth were redirected to a
separate account-mapping page, breaking the onboarding flow. After
returning, there was no way to resume at the correct step. Additionally,
connected accounts were incorrectly shown in the manual import steps
(import-transactions / import-balances), which don't apply to them.
## 🔑 What?
### Changes
- **Inline bank connect wizard**: Added `ConnectAccountInline` component
that embeds the full 3-step bank OAuth flow within the onboarding
`create-account` step, replacing the redirect to a separate page.
- **Manual / Connected selection UI**: `StepCreateAccount` now presents
a choice between manual and connected account setup before proceeding.
- **Auto-account creation on OAuth callback**:
`AccountMappingController` and `AuthorizationController` auto-create
accounts for non-onboarded users and redirect back to
`/onboarding?step=create-account` instead of showing the mapping UI.
- **`?step=` deep-linking**: The onboarding page reads a `?step=` query
param on mount and starts at that step, so returning from bank OAuth
lands at the right place.
- **Skip import steps for connected accounts**: `handleAccountCreated`
detects `connected: true` and skips `import-transactions` /
`import-balances`, going directly to `category-types` (first account) or
`more-accounts` (subsequent accounts).
- **Fix phantom account in more-accounts**: Connected accounts are no
longer added to `createdAccounts` state (they already appear via
`existingAccounts`), preventing a duplicate nameless entry in the final
account list.
- **Feature flags enabled in non-production**: `open-banking` and
`account-mapping` Pennant flags now resolve to `! app()->isProduction()`
instead of always `false`.
- **Open-banking routes accessible during onboarding**: Removed
`onboarded`/`subscribed` middleware from open-banking routes;
`EnsureOnboardingComplete` middleware explicitly allows `open-banking.*`
routes through.
## ✅ Verification
### Tests
- Updated `tests/Browser/OnboardingFlowTest.php` to reflect new UI
(manual/connected selection).
- Existing feature tests for open-banking controllers pass (pre-existing
failures in Binance/Bitpanda/IndexaCapital/AuthorizationController tests
are from a prior commit unrelated to this work).
### Manual Verification
- Connected account flow tested end-to-end: bank OAuth → auto-account
creation → redirect to `create-account` step → Continue → skip import
steps → `category-types`.
- 7-account BBVA user correctly shows all accounts in `more-accounts`
via `filteredExistingAccounts` with no phantom entry.
- Feature flags confirmed active in local environment via tinker.
## 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