## What
Adds the **Danish Krone (DKK)** as both a user primary currency and an
account currency.
Per `docs/adding-a-currency.md` this is a config change — validation
(`ProfileUpdateRequest`, `StoreAccountRequest`, `UpdateAccountRequest`),
the Inertia dropdown props and conversion all derive from
`config/currencies.php`.
Both pre-checks in the doc pass:
- `DKK` is the current ISO 4217 code (no deprecated-code trap like
`GHC`/`GHS`).
- The provider covers it: `EUR→DKK = 7.4754`, consistent with the ERM II
peg (~7.46).
## Closing the translation gap
Currency names are translated in PHP by `CurrencyOptions`, so they never
appear as literal `__()` keys in the TS/TSX source — `LocalizationTest`
never saw them, and a missing Spanish name silently shipped the English
one. The doc even warned about it.
`LocalizationTest` now feeds the configured currency names into its
translatable-key check, so Spanish is enforced and French warns, exactly
like every other key. That surfaced pre-existing gaps, now filled:
- Spanish: PKR, BRL, DOP, SAR
- French: those four plus NGN
## Also
Dropped the docs' "add a symbol" step. `getCurrencySymbol` has no
callers left — every consumer of `utils/currency` imports
`formatCurrency` only — so entries in its map change nothing on screen.
Removing the function itself is a separate cleanup.
## QA
Real browser QA against the running app, in Spanish:
- `DKK - Corona danesa` appears in the profile currency select; saving
persists `users.currency_code = 'DKK'` and survives a reload.
- `DKK - Corona danesa` appears in the create-account currency select;
created a `Danske Bank Private` account and confirmed
`accounts.currency_code = 'DKK'`.
- Conversion both ways: `100 EUR → 747.68 DKK`, `100 DKK → 13.38 EUR`.
- `LocalizationTest` + `CurrencyConversionServiceTest` green; `pint`,
`phpstan`, `prettier`, `eslint` clean.
## Demo
<!-- PLACEHOLDER: drag the video here -->
**⬆️ Attach `~/Downloads/dkk-currency-qa.mp4` here.**
## Follow-up (out of scope)
Prod already holds accounts and users on currencies that aren't in the
config (PLN, MAD, PHP).
`AccountUserCurrencyService::resolveImportedCurrency` accepts whatever a
bank reports and `forceFill`s it onto `users.currency_code`, bypassing
validation — so those users can't save **any** profile change, and their
currency select renders empty. Worth a separate issue: either
validate/fall back on import, or make the selects tolerate an
out-of-list current value.
## Summary
Adds `docs/adding-a-currency.md`, mirroring
`docs/adding-a-banking-provider.md` but for currencies. Distilled from
the NGN (#642) and GHS (#644) additions.
The gist: adding a currency is a config change, not a feature.
- **Required**: one entry in `config/currencies.php`. Validation (Form
Requests), the Inertia dropdown props, and conversion all auto-derive
from it via `CurrencyOptions` / `CurrencyConversionService`.
- **Translation**: add the name to `lang/es.json` (enforced),
`lang/fr.json` optional. Flagged that `LocalizationTest` won't
auto-catch a missing one, since the name is translated in PHP, not
scanned from TS source.
- **Optional**: a custom short symbol in
`resources/js/utils/currency.ts`.
- **Two gotchas**: use the current ISO 4217 code (the GHC→GHS lesson
from #644), and verify the provider covers the code at the right rate
via the CDN URL.
## Testing
Docs-only change; no code touched.
## 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.
## 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.