Commit Graph

7 Commits

Author SHA1 Message Date
Víctor Falcón 08430c8b32 refactor(open-banking): drop per-provider log-message hook
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).
2026-07-04 20:08:00 +02:00
Víctor Falcón 0e6a144c2c refactor(open-banking): share crypto portfolio mapping via intermediate base
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).
2026-07-04 20:06:12 +02:00
Víctor Falcón a73adddb3b refactor(open-banking): extract shared connect flow into base controller
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.
2026-07-04 19:59:19 +02:00
Víctor Falcón f60e6d7035
feat(banking): add Interactive Brokers sync via Flex Web Service (#581)
## 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.
2026-06-23 11:39:24 +02:00
Víctor Falcón da0c8c58fc
refactor: centralize duplicated provider & locale keys into enums (#543)
## 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** ✓.
2026-06-16 13:43:14 +00:00
Víctor Falcón 0d4c68361a
fix(accounts): sync currency from first account (#430)
## 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'
2026-05-25 20:55:24 +02:00
Víctor Falcón e71a743a0a
feat: Coinbase banking integration (#388)
## Summary

Adds **Coinbase** as a banking/investment provider, alongside existing
Binance, Bitpanda, and Indexa Capital integrations. Connection auth uses
Coinbase Developer Platform (CDP) JWT (ES256) API keys.

Sync model mirrors Bitpanda: no historical balance reconstruction
(Coinbase API has no daily snapshot endpoint). Balance tracking starts
from connection date.

## Account model

One single **Crypto Portfolio** Whisper Account in the user's fiat
currency, aggregating all Coinbase wallets (crypto + fiat).

## Backend

- `app/Services/Banking/CoinbaseClient.php` — JWT-signed Coinbase
Advanced Trade client (`firebase/php-jwt` ES256). Per-request JWT, 120s
TTL, `uri` claim `"METHOD host/path"`. Methods:
`getAccounts`/`getAllAccounts` (cursor pagination), `getProduct`,
`getBestBidAsk` (batched), with 429 retry/backoff.
- `app/Services/Banking/CoinbaseBalanceSyncService.php` — Partitions
wallets into fiat vs crypto. Batched `best_bid_ask` for prices, USD
stablecoin shortcut (USDT/USDC/DAI/PYUSD/GUSD),
`CurrencyConversionService` fallback. Aggregates everything into user
fiat.
- `app/Http/Controllers/OpenBanking/CoinbaseController.php` +
`ConnectCoinbaseRequest.php` — mirror Bitpanda flow, validates
`api_key_name` (`organizations/{org}/apiKeys/{id}`) + PEM `private_key`.
Stores key_name in `api_token`, PEM in `api_secret` (already encrypted
TEXT).
- `BankingConnection::isCoinbase()`,
`SyncBankingConnectionJob::syncCoinbase()`, credential-update flow.
- `routes/web.php`: `POST /open-banking/coinbase/connect`.
- Bank seeder entry + factory `coinbase()` state.

## Frontend

- `connect-account-dialog.tsx` / `connect-account-inline.tsx`: Coinbase
appears in the bank picker. Confirm step shows `<Input>` for API key
name and `<Textarea>` (multi-line) for the PEM private key, with link to
CDP portal.
- `update-credentials-dialog.tsx`: Coinbase credentials editable.
- `settings/connections.tsx`: coinbase included in `isApiKeyProvider`.
- Wayfinder auto-generated `CoinbaseController.ts` +
`routes/open-banking/coinbase`.

## Tests

- `tests/Feature/OpenBanking/CoinbaseControllerTest.php` — happy path,
invalid creds (401 → 422), validation errors, subscription gate.
- `tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php` — mixed
crypto+fiat aggregation, USD stablecoin valuation, skip when
external_account_id missing.

All **217 OpenBanking tests pass**. Pint + ESLint clean.

## Follow-ups (not in this PR)

- Upload `storage/banks/logos/coinbase.png` to production storage (URL
referenced in seeder + frontend).
- Invested-amount calc from transaction history (deferred).
- Historical balance reconstruction (deferred — Coinbase has no daily
snapshot endpoint).
2026-05-13 19:53:30 +02:00