From 1c5a76a3a4cc9173d7ba2a7f8e3f67fc303e30d4 Mon Sep 17 00:00:00 2001 From: Toni Grunwald Date: Tue, 16 Jun 2026 08:23:25 -0500 Subject: [PATCH] feat: add Wise open banking integration with balance sync (#525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) Co-authored-by: Víctor Falcón --- app/Enums/TransactionSource.php | 1 + .../OpenBanking/WiseController.php | 138 ++++++++++++ .../OpenBanking/ConnectWiseRequest.php | 34 +++ app/Jobs/SyncBankingConnectionJob.php | 35 +++ app/Models/BankingConnection.php | 5 + .../Banking/WiseBalanceSyncService.php | 48 +++++ app/Services/Banking/WiseClient.php | 83 +++++++ .../Banking/WiseTransactionSyncService.php | 204 ++++++++++++++++++ .../factories/BankingConnectionFactory.php | 15 ++ lang/es.json | 6 + public/images/banks/logos/wise.png | Bin 0 -> 6973 bytes .../open-banking/connect-account-dialog.tsx | 81 ++++++- resources/js/pages/settings/connections.tsx | 10 +- routes/web.php | 3 + .../OpenBanking/WiseBalanceSyncTest.php | 98 +++++++++ .../OpenBanking/WiseControllerTest.php | 131 +++++++++++ 16 files changed, 879 insertions(+), 13 deletions(-) create mode 100644 app/Http/Controllers/OpenBanking/WiseController.php create mode 100644 app/Http/Requests/OpenBanking/ConnectWiseRequest.php create mode 100644 app/Services/Banking/WiseBalanceSyncService.php create mode 100644 app/Services/Banking/WiseClient.php create mode 100644 app/Services/Banking/WiseTransactionSyncService.php create mode 100644 public/images/banks/logos/wise.png create mode 100644 tests/Feature/OpenBanking/WiseBalanceSyncTest.php create mode 100644 tests/Feature/OpenBanking/WiseControllerTest.php diff --git a/app/Enums/TransactionSource.php b/app/Enums/TransactionSource.php index b2f97550..0a8169bb 100644 --- a/app/Enums/TransactionSource.php +++ b/app/Enums/TransactionSource.php @@ -7,4 +7,5 @@ enum TransactionSource: string case ManuallyCreated = 'manually_created'; case Imported = 'imported'; case EnableBanking = 'enablebanking'; + case Wise = 'wise'; } diff --git a/app/Http/Controllers/OpenBanking/WiseController.php b/app/Http/Controllers/OpenBanking/WiseController.php new file mode 100644 index 00000000..7ac7f6db --- /dev/null +++ b/app/Http/Controllers/OpenBanking/WiseController.php @@ -0,0 +1,138 @@ +user(); + + if ($this->shouldBlockOpenBankingAccess($user)) { + return $this->subscribeJsonResponse(); + } + + $client = new WiseClient($request->api_token); + + try { + $profiles = $client->getProfiles(); + } catch (\Throwable $e) { + Log::warning('Wise credential validation failed', ['error' => $e->getMessage()]); + + return response()->json([ + 'message' => 'Invalid API token or failed to connect to Wise.', + ], 422); + } + + $pendingAccounts = $this->buildPendingAccounts($client, $profiles); + + if ($pendingAccounts === []) { + return response()->json(['message' => 'No Wise multi-currency account found for this token.'], 422); + } + + $bank = Bank::firstOrCreate( + ['name' => 'Wise', 'user_id' => null], + ['name' => 'Wise', 'logo' => null], + ); + + $connection = $user->bankingConnections()->create([ + 'provider' => 'wise', + 'api_token' => $request->api_token, + 'api_secret' => null, + 'aspsp_name' => 'Wise', + 'aspsp_country' => 'GB', + 'aspsp_logo' => $bank->logo, + 'status' => BankingConnectionStatus::AwaitingMapping, + 'pending_accounts_data' => $pendingAccounts, + ]); + + if (! $user->isOnboarded()) { + $this->createAccountsFromPending($user, $connection, $accountUserCurrencyService); + SyncBankingConnectionJob::dispatch($connection); + + return response()->json([ + 'redirect_url' => route('onboarding', ['step' => 'create-account']), + 'connection_id' => $connection->id, + ]); + } + + return response()->json([ + 'redirect_url' => route('open-banking.map-accounts', $connection), + 'connection_id' => $connection->id, + ]); + } + + /** + * Build pending account entries across every profile on the token. + * + * Each currency wallet becomes one pending account with + * external_account_id = "{profileId}:{currency}", the format the Wise + * sync services use to fetch activities and balances per profile. + * + * @param array}> $profiles + * @return array + */ + private function buildPendingAccounts(WiseClient $client, array $profiles): array + { + $pendingAccounts = []; + + foreach ($profiles as $profile) { + $profileId = $profile['id'] ?? null; + + if ($profileId === null) { + continue; + } + + try { + $borderlessAccount = $client->getBorderlessAccount($profileId); + } catch (\Throwable $e) { + Log::warning('Failed to load Wise borderless account', [ + 'profile_id' => $profileId, + 'error' => $e->getMessage(), + ]); + + continue; + } + + $label = ucfirst((string) ($profile['type'] ?? 'account')); + + foreach ($borderlessAccount['balances'] ?? [] as $balance) { + $currency = $balance['currency'] ?? null; + + if ($currency === null) { + continue; + } + + $pendingAccounts[] = [ + 'uid' => $profileId.':'.$currency, + 'currency' => $currency, + 'name' => 'Wise '.$label.' '.$currency, + ]; + } + } + + return $pendingAccounts; + } +} diff --git a/app/Http/Requests/OpenBanking/ConnectWiseRequest.php b/app/Http/Requests/OpenBanking/ConnectWiseRequest.php new file mode 100644 index 00000000..02262b5a --- /dev/null +++ b/app/Http/Requests/OpenBanking/ConnectWiseRequest.php @@ -0,0 +1,34 @@ +> + */ + public function rules(): array + { + return [ + 'api_token' => ['required', 'string', 'min:10'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'api_token.required' => 'A Wise API token is required.', + 'api_token.min' => 'The API token appears to be too short.', + ]; + } +} diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index a94b4673..09a90218 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -19,6 +19,9 @@ use App\Services\Banking\CoinbaseClient; use App\Services\Banking\IndexaCapitalBalanceSyncService; use App\Services\Banking\IndexaCapitalClient; use App\Services\Banking\TransactionSyncService; +use App\Services\Banking\WiseBalanceSyncService; +use App\Services\Banking\WiseClient; +use App\Services\Banking\WiseTransactionSyncService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; @@ -122,6 +125,8 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue $this->syncIndexaCapital($connection, $isFirstSync); } elseif ($connection->isBinance()) { $this->syncBinance($connection, $isFirstSync); + } elseif ($connection->isWise()) { + $metadata = $this->syncWise($connection, $isFirstSync); } elseif ($connection->isBitpanda()) { $this->syncBitpanda($connection); } elseif ($connection->isCoinbase()) { @@ -329,6 +334,36 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue } } + /** + * @return array + */ + private function syncWise(BankingConnection $connection, bool $isFirstSync): array + { + $dateFrom = $isFirstSync + ? now()->subYear()->toDateString() + : ($connection->last_synced_at?->toDateString() ?? now()->subMonth()->toDateString()); + $dateTo = now()->toDateString(); + + $client = new WiseClient($connection->api_token); + $syncService = app(WiseTransactionSyncService::class); + $balanceSyncService = app(WiseBalanceSyncService::class); + + $connection->load('accounts'); + + $transactionsPerAccount = []; + + foreach ($connection->accounts as $account) { + $count = $syncService->sync($account, $client, $dateFrom, $dateTo); + $balanceSyncService->sync($account, $client); + $transactionsPerAccount[$account->name] = $count; + } + + return [ + 'transactions_synced' => array_sum($transactionsPerAccount), + 'transactions_per_account' => $transactionsPerAccount, + ]; + } + private function syncBitpanda(BankingConnection $connection): void { $client = new BitpandaClient($connection->api_token); diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php index b31bd6ef..44f66d44 100644 --- a/app/Models/BankingConnection.php +++ b/app/Models/BankingConnection.php @@ -126,6 +126,11 @@ class BankingConnection extends Model return $this->provider === 'enablebanking'; } + public function isWise(): bool + { + return $this->provider === 'wise'; + } + public function hasPendingAccounts(): bool { return ! empty($this->pending_accounts_data); diff --git a/app/Services/Banking/WiseBalanceSyncService.php b/app/Services/Banking/WiseBalanceSyncService.php new file mode 100644 index 00000000..c3869596 --- /dev/null +++ b/app/Services/Banking/WiseBalanceSyncService.php @@ -0,0 +1,48 @@ +external_account_id) { + return; + } + + [$profileId, $currency] = explode(':', $account->external_account_id, 2); + + $borderless = $client->getBorderlessAccount((int) $profileId); + + $walletBalance = collect($borderless['balances'] ?? []) + ->firstWhere('currency', $currency); + + $value = $walletBalance['amount']['value'] ?? null; + + if ($value === null) { + return; + } + + $amountCents = (int) round((float) $value * 100); + + $account->balances()->updateOrCreate( + ['balance_date' => now()->toDateString()], + ['balance' => $amountCents], + ); + + Log::info('Synced Wise balance', [ + 'account_id' => $account->id, + 'currency' => $currency, + 'balance' => $amountCents, + ]); + } +} diff --git a/app/Services/Banking/WiseClient.php b/app/Services/Banking/WiseClient.php new file mode 100644 index 00000000..be9c39ef --- /dev/null +++ b/app/Services/Banking/WiseClient.php @@ -0,0 +1,83 @@ + + */ + public function getProfiles(): array + { + $response = $this->client()->get('/v1/profiles'); + + $response->throw(); + + return $response->json(); + } + + /** + * Get the multi-currency borderless account for a profile. + * + * @return array{id?: int, profileId?: int, balances?: array} + */ + public function getBorderlessAccount(int $profileId): array + { + $response = $this->client()->get('/v2/borderless-accounts', [ + 'profileId' => $profileId, + ]); + + $response->throw(); + + $accounts = $response->json(); + + return $accounts[0] ?? []; + } + + /** + * Fetch paginated monetary activities for a profile. + * Use `since`/`until` (ISO 8601) for date range and `cursor` for pagination. + * + * @return array{activities?: array, cursor?: string|null} + */ + public function getActivities(int $profileId, string $since, string $until, ?string $cursor = null): array + { + $params = [ + 'size' => 100, + 'since' => $since, + 'until' => $until, + ]; + + if ($cursor !== null) { + $params['cursor'] = $cursor; + } + + $response = $this->client()->get("/v1/profiles/{$profileId}/activities", $params); + + $response->throw(); + + return $response->json(); + } + + private function client(): PendingRequest + { + return Http::baseUrl(self::BASE_URL) + ->withToken($this->apiToken) + ->acceptJson() + ->throw(function ($response, RequestException $exception) { + Log::error('Wise API error', [ + 'status' => $response->status(), + 'body' => $response->json(), + ]); + }); + } +} diff --git a/app/Services/Banking/WiseTransactionSyncService.php b/app/Services/Banking/WiseTransactionSyncService.php new file mode 100644 index 00000000..a04c449d --- /dev/null +++ b/app/Services/Banking/WiseTransactionSyncService.php @@ -0,0 +1,204 @@ +external_account_id) { + return 0; + } + + [$profileId, $currency] = explode(':', $account->external_account_id, 2); + + $since = $dateFrom.'T00:00:00Z'; + $until = $dateTo.'T23:59:59Z'; + $cursor = null; + $created = 0; + + do { + $result = $client->getActivities((int) $profileId, $since, $until, $cursor); + $activities = $result['activities'] ?? []; + $cursor = $result['cursor'] ?? null; + + foreach ($activities as $activity) { + // Skip zero-amount authorization checks and non-monetary types + if (($activity['type'] ?? '') === 'CARD_CHECK') { + continue; + } + + $parsed = $this->parseActivity($activity, $currency); + + if ($parsed === null) { + continue; + } + + if ($this->importTransaction($account, $activity, $parsed)) { + $created++; + } + } + } while ($cursor !== null && count($activities) > 0); + + Log::info('Synced Wise transactions', [ + 'account_id' => $account->id, + 'currency' => $currency, + 'new_transactions' => $created, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + ]); + + return $created; + } + + /** + * Parse a Wise activity into a normalised amount + currency. + * Returns null if the activity does not involve the target currency wallet. + * + * @return array{amount_cents: int, currency: string, description: string}|null + */ + private function parseActivity(array $activity, string $walletCurrency): ?array + { + $primary = $activity['primaryAmount'] ?? ''; + $secondary = $activity['secondaryAmount'] ?? ''; + + // Determine sign from HTML tags in the amount string + $isPositive = str_contains($primary, ''); + + // Strip HTML tags and whitespace to get a clean "1,234.56 EUR" string + $primaryClean = trim(strip_tags($primary)); + $secondaryClean = trim(strip_tags($secondary)); + + // Parse "1,234.56 EUR" → [float value, string currency] + $primaryParsed = $this->parseAmountString($primaryClean); + $secondaryParsed = $this->parseAmountString($secondaryClean); + + $type = $activity['type'] ?? ''; + + // Determine which value to record and whether it's wallet-relevant + if ($primaryParsed !== null && $primaryParsed[1] === $walletCurrency) { + // Direct wallet-currency transaction + $value = $primaryParsed[0]; + $recordCurrency = $walletCurrency; + } elseif ($secondaryParsed !== null && $secondaryParsed[1] === $walletCurrency) { + // Foreign-currency spend, EUR equivalent shown in secondary + $value = $secondaryParsed[0]; + $recordCurrency = $walletCurrency; + } else { + // Transaction doesn't touch this wallet + return null; + } + + // Determine sign: card payments are always debits; transfers/conversions use HTML tag + $sign = match (true) { + in_array($type, ['CARD_PAYMENT', 'CARD_CASHBACK_REVERSAL']) => -1, + $isPositive => 1, + default => -1, + }; + + $amountCents = (int) round($value * 100) * $sign; + + $description = trim(strip_tags($activity['title'] ?? '')); + + return [ + 'amount_cents' => $amountCents, + 'currency' => $recordCurrency, + 'description' => $description, + ]; + } + + /** + * Parse a string like "1,234.56 EUR" or "+ 500 EUR" into [float, currency]. + * + * @return array{float, string}|null + */ + private function parseAmountString(string $str): ?array + { + // Remove sign prefix, commas (thousands separator), extra spaces + $str = trim(preg_replace('/[+\-,]/', '', $str) ?? $str); + + // Match number and 3-letter currency code, e.g. "1234.56 EUR" + if (! preg_match('/^([\d.]+)\s+([A-Z]{3})$/', $str, $m)) { + return null; + } + + return [(float) $m[1], $m[2]]; + } + + /** + * @param array{amount_cents: int, currency: string, description: string} $parsed + */ + private function importTransaction(Account $account, array $activity, array $parsed): bool + { + $externalId = $activity['id'] ?? null; + $fingerprint = $this->fingerprint($activity, $parsed); + + $exists = $account->transactions() + ->withTrashed() + ->where(function ($query) use ($fingerprint, $externalId) { + $query->where('dedup_fingerprint', $fingerprint); + + if ($externalId !== null) { + $query->orWhere('external_transaction_id', $externalId); + } + }) + ->exists(); + + if ($exists) { + return false; + } + + $transactionDate = substr($activity['createdOn'] ?? now()->toIso8601String(), 0, 10); + + try { + $account->transactions()->create([ + 'user_id' => $account->user_id, + 'description' => $parsed['description'], + 'description_iv' => null, + 'original_description' => $parsed['description'], + 'transaction_date' => $transactionDate, + 'amount' => $parsed['amount_cents'], + 'currency_code' => $parsed['currency'], + 'notes' => null, + 'notes_iv' => null, + 'source' => TransactionSource::Wise, + 'external_transaction_id' => $externalId, + 'dedup_fingerprint' => $fingerprint, + 'raw_data' => $activity, + ]); + } catch (UniqueConstraintViolationException) { + return false; + } + + return true; + } + + private function fingerprint(array $activity, array $parsed): string + { + $id = $activity['id'] ?? null; + + if ($id !== null) { + return 'fp_'.hash('sha256', implode("\x1f", ['wise_activity_id', $id])); + } + + return 'fp_'.hash('sha256', implode("\x1f", [ + substr($activity['createdOn'] ?? '', 0, 10), + (string) $parsed['amount_cents'], + $parsed['currency'], + $parsed['description'], + ])); + } +} diff --git a/database/factories/BankingConnectionFactory.php b/database/factories/BankingConnectionFactory.php index ad7445c3..e6614624 100644 --- a/database/factories/BankingConnectionFactory.php +++ b/database/factories/BankingConnectionFactory.php @@ -132,6 +132,21 @@ class BankingConnectionFactory extends Factory ]); } + public function wise(): static + { + return $this->state(fn (array $attributes) => [ + 'provider' => 'wise', + 'authorization_id' => null, + 'session_id' => null, + 'api_token' => 'test-wise-api-token-'.fake()->uuid(), + 'api_secret' => null, + 'aspsp_name' => 'Wise', + 'aspsp_country' => 'BE', + 'aspsp_logo' => 'https://whisper.money/storage/banks/logos/wise.png', + 'valid_until' => null, + ]); + } + public function error(): static { return $this->state(fn (array $attributes) => [ diff --git a/lang/es.json b/lang/es.json index baa216b3..2b36e816 100644 --- a/lang/es.json +++ b/lang/es.json @@ -459,6 +459,7 @@ "Connect your Bitpanda account using your API Key.": "Conecta tu cuenta de Bitpanda usando tu Clave API.", "Connect your Coinbase account using a CDP API key.": "Conecta tu cuenta de Coinbase usando una clave API de CDP.", "Connect your Indexa Capital account using your API token.": "Conecta tu cuenta de Indexa Capital usando tu token API.", + "Connect your Wise account using a Personal API token.": "Conecta tu cuenta de Wise usando un token API personal.", "Connect your bank accounts": "Conecta tus cuentas bancarias", "Connect your bank accounts, savings, investments, and more — all in a single dashboard. No more switching between apps or losing track of accounts.": "Conecta tus cuentas bancarias, ahorros, inversiones y más, todo en un único panel. Sin más cambiar entre apps ni perder el control de tus cuentas.", "Connect your bank and sync transactions automatically.": "Conecta tu banco y sincroniza las transacciones automáticamente.", @@ -647,6 +648,7 @@ "Enter your API Key to connect your Bitpanda account.": "Introduce tu Clave API para conectar tu cuenta de Bitpanda.", "Enter your API token to connect your Indexa Capital account.": "Introduce tu token API para conectar tu cuenta de Indexa Capital.", "Enter your CDP API key name and private key to connect your Coinbase account.": "Introduce el nombre de tu clave API de CDP y la clave privada para conectar tu cuenta de Coinbase.", + "Enter your Wise Personal API token to connect your account.": "Introduce tu token API personal de Wise para conectar tu cuenta.", "Enter your CDP App Key ID and Secret to connect your Coinbase account.": "Introduce el ID de tu Clave API de CDP y el Secreto para conectar tu cuenta de Coinbase.", "Enter your details below to create your account": "Ingresa tus datos a continuación para crear tu cuenta", "Enter your email and password below to log in": "Ingresa tu correo electrónico y contraseña a continuación para iniciar sesión", @@ -735,6 +737,7 @@ "Fully transparent and open source. Review the code yourself.": "Totalmente transparente y de código abierto. Revisa el código tú mismo.", "Future transactions will be categorized automatically either way. You can preview the matches before confirming.": "Las transacciones futuras se categorizarán automáticamente de todos modos. Puedes previsualizar las coincidencias antes de confirmar.", "Gain/loss": "Ganancia/pérdida", + "Generate a token in Wise under": "Genera un token en Wise en", "Get Started": "Comenzar", "Get Started Free": "Empieza Gratis", "Get Started for Free": "Empieza Gratis", @@ -1133,6 +1136,7 @@ "Paste your Binance API Secret": "Pega tu Secreto API de Binance", "Paste your Bitpanda API Key": "Pega tu Clave API de Bitpanda", "Paste your Indexa Capital API token": "Pega tu token API de Indexa Capital", + "Paste your Wise API token": "Pega tu token API de Wise", "Patience is a virtue (especially with money)...": "La paciencia es una virtud (especialmente con el dinero)...", "Pattern Matching": "Coincidencia de Patrones", "Payment Processors:": "Procesadores de Pago:", @@ -1146,6 +1150,7 @@ "Period Duration (days)": "Duración del Período (días)", "Period Type": "Tipo de Período", "Period and carry-over settings cannot be changed after a budget is created because budgets are calculated historically. If you need different settings, delete this budget and create a new one.": "La configuración de período y arrastre no se puede cambiar después de crear un presupuesto porque los presupuestos se calculan históricamente. Si necesitas una configuración diferente, elimina este presupuesto y crea uno nuevo.", + "Personal API Token": "Token API personal", "Personal transfers": "Transferencias personales", "Peruvian Sol": "Sol peruano", "Pick whichever plan fits you, paste the matching code at checkout, and you are set.": "Elige el plan que prefieras, pega el código correspondiente al pagar y listo.", @@ -1369,6 +1374,7 @@ "Setting up encryption...": "Configurando encriptación...", "Settings": "Configuración", "Settings > Applications": "Configuración > Aplicaciones", + "Settings → Developer Tools → API tokens": "Configuración → Herramientas para desarrolladores → Tokens API", "Setup Encryption": "Configura tu encriptación", "Share": "Compartir", "Share Your Link": "Comparte Tu Enlace", diff --git a/public/images/banks/logos/wise.png b/public/images/banks/logos/wise.png new file mode 100644 index 0000000000000000000000000000000000000000..4df8fad777499b3c7964638f1332c96f17af1197 GIT binary patch literal 6973 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 ZPRYDt^`W|m_~+M^iIIh2#bwtA{{{Xk void; @@ -107,6 +114,7 @@ export function ConnectAccountDialog({ const [bitpandaApiKey, setBitpandaApiKey] = useState(''); const [coinbaseKeyName, setCoinbaseKeyName] = useState(''); const [coinbasePrivateKey, setCoinbasePrivateKey] = useState(''); + const [wiseApiToken, setWiseApiToken] = useState(''); const isIndexaCapital = useMemo( () => selectedBank?.name === 'Indexa Capital', @@ -128,6 +136,8 @@ export function ConnectAccountDialog({ [selectedBank], ); + const isWise = useMemo(() => selectedBank?.name === 'Wise', [selectedBank]); + const resetState = useCallback(() => { setStep('country'); setCountry(''); @@ -144,6 +154,7 @@ export function ConnectAccountDialog({ setBitpandaApiKey(''); setCoinbaseKeyName(''); setCoinbasePrivateKey(''); + setWiseApiToken(''); }, []); useEffect(() => { @@ -197,6 +208,7 @@ export function ConnectAccountDialog({ BINANCE_INSTITUTION, BITPANDA_INSTITUTION, COINBASE_INSTITUTION, + WISE_INSTITUTION, ]; if (countryCode === 'ES') { extraInstitutions.push(INDEXA_CAPITAL_INSTITUTION); @@ -245,7 +257,9 @@ export function ConnectAccountDialog({ ? '/open-banking/indexa-capital/connect' : isCoinbase ? '/open-banking/coinbase/connect' - : '/open-banking/authorize'; + : isWise + ? '/open-banking/wise/connect' + : '/open-banking/authorize'; const body = isBitpanda ? { api_key: bitpandaApiKey, country: country } @@ -259,11 +273,13 @@ export function ConnectAccountDialog({ private_key: coinbasePrivateKey, country: country, } - : { - aspsp_name: selectedBank.name, - country: country, - logo: selectedBank.logo, - }; + : isWise + ? { api_token: wiseApiToken } + : { + aspsp_name: selectedBank.name, + country: country, + logo: selectedBank.logo, + }; const response = await fetch(url, { method: 'POST', @@ -305,11 +321,17 @@ export function ConnectAccountDialog({ 'Select the country where your bank is located.', )} {step === 'bank' && __('Select your bank.')} + {step === 'confirm' && + isWise && + __( + 'Enter your Wise Personal API token to connect your account.', + )} {step === 'confirm' && !isIndexaCapital && !isBinance && !isBitpanda && !isCoinbase && + !isWise && __( 'You will be redirected to your bank to authorize access.', )} @@ -455,9 +477,13 @@ export function ConnectAccountDialog({ ? __( 'Connect your Coinbase account using a CDP API key.', ) - : __( - 'You will be redirected to authorize access to your account data.', - )} + : isWise + ? __( + 'Connect your Wise account using a Personal API token.', + ) + : __( + 'You will be redirected to authorize access to your account data.', + )}

@@ -584,6 +610,40 @@ export function ConnectAccountDialog({ )} + {isWise && ( +
+ + + setWiseApiToken(e.target.value) + } + className="mt-1" + placeholder={__( + 'Paste your Wise API token', + )} + /> +

+ {__('Generate a token in Wise under')}{' '} + + {__( + 'Settings → Developer Tools → API tokens', + )} + + . +

+
+ )} + {isCoinbase && (
@@ -654,7 +714,8 @@ export function ConnectAccountDialog({ (isBitpanda && !bitpandaApiKey) || (isCoinbase && (!coinbaseKeyName || - !coinbasePrivateKey)) + !coinbasePrivateKey)) || + (isWise && !wiseApiToken) } > {isSubmitting diff --git a/resources/js/pages/settings/connections.tsx b/resources/js/pages/settings/connections.tsx index 17c3f833..17de0691 100644 --- a/resources/js/pages/settings/connections.tsx +++ b/resources/js/pages/settings/connections.tsx @@ -122,9 +122,13 @@ export default function ConnectionsPage({ connections }: Props) { } function isApiKeyProvider(connection: BankingConnection): boolean { - return ['indexacapital', 'binance', 'bitpanda', 'coinbase'].includes( - connection.provider, - ); + return [ + 'indexacapital', + 'binance', + 'bitpanda', + 'coinbase', + 'wise', + ].includes(connection.provider); } function hasAuthError(connection: BankingConnection): boolean { diff --git a/routes/web.php b/routes/web.php index f38f7abb..31075915 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,7 @@ use App\Http\Controllers\OpenBanking\BitpandaController; use App\Http\Controllers\OpenBanking\CoinbaseController; use App\Http\Controllers\OpenBanking\IndexaCapitalController; use App\Http\Controllers\OpenBanking\InstitutionController; +use App\Http\Controllers\OpenBanking\WiseController; use App\Http\Controllers\RealEstateDetailController; use App\Http\Controllers\ReEvaluateTransactionRulesController; use App\Http\Controllers\RobotsController; @@ -158,6 +159,8 @@ Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function Route::post('bitpanda/connect', [BitpandaController::class, 'store'])->name('open-banking.bitpanda.connect'); Route::post('coinbase/connect', [CoinbaseController::class, 'store']) ->name('open-banking.coinbase.connect'); + Route::post('wise/connect', [WiseController::class, 'store']) + ->name('open-banking.wise.connect'); }); Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () { diff --git a/tests/Feature/OpenBanking/WiseBalanceSyncTest.php b/tests/Feature/OpenBanking/WiseBalanceSyncTest.php new file mode 100644 index 00000000..1a1e3853 --- /dev/null +++ b/tests/Feature/OpenBanking/WiseBalanceSyncTest.php @@ -0,0 +1,98 @@ +onboarded()->create(['currency_code' => 'EUR']); + $connection = BankingConnection::factory()->wise()->create([ + 'user_id' => $user->id, + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => '36875276:EUR', + 'currency_code' => 'EUR', + ]); + + Http::fake([ + 'api.wise.com/v2/borderless-accounts*' => Http::response([ + [ + 'id' => 44333087, + 'profileId' => 36875276, + 'balances' => [ + ['currency' => 'USD', 'amount' => ['value' => 100.00, 'currency' => 'USD']], + ['currency' => 'EUR', 'amount' => ['value' => 19.81, 'currency' => 'EUR']], + ], + ], + ]), + ]); + + $service = app(WiseBalanceSyncService::class); + $service->sync($account, new WiseClient('test-token')); + + expect($account->balances()->count())->toBe(1); + + $balance = $account->balances()->first(); + expect($balance->balance)->toBe(1981); // 19.81 EUR → 1981 cents + expect($balance->balance_date->toDateString())->toBe(now()->toDateString()); +}); + +test('updates the existing wise balance for today instead of duplicating', function () { + $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); + $connection = BankingConnection::factory()->wise()->create([ + 'user_id' => $user->id, + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => '36875276:EUR', + 'currency_code' => 'EUR', + ]); + + $account->balances()->create([ + 'balance_date' => now()->toDateString(), + 'balance' => 36_688, + ]); + + Http::fake([ + 'api.wise.com/v2/borderless-accounts*' => Http::response([ + [ + 'id' => 44333087, + 'profileId' => 36875276, + 'balances' => [ + ['currency' => 'EUR', 'amount' => ['value' => 19.81, 'currency' => 'EUR']], + ], + ], + ]), + ]); + + $service = app(WiseBalanceSyncService::class); + $service->sync($account, new WiseClient('test-token')); + + expect($account->balances()->count())->toBe(1); + expect($account->balances()->first()->balance)->toBe(1981); +}); + +test('skips wise balance sync when external_account_id is missing', function () { + $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); + $connection = BankingConnection::factory()->wise()->create([ + 'user_id' => $user->id, + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => null, + 'currency_code' => 'EUR', + ]); + + $service = app(WiseBalanceSyncService::class); + $service->sync($account, new WiseClient('test-token')); + + expect($account->balances()->count())->toBe(0); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/OpenBanking/WiseControllerTest.php b/tests/Feature/OpenBanking/WiseControllerTest.php new file mode 100644 index 00000000..ee8da4ff --- /dev/null +++ b/tests/Feature/OpenBanking/WiseControllerTest.php @@ -0,0 +1,131 @@ +url(); + + if (str_contains($url, '/v1/profiles')) { + return Http::response([ + ['id' => 36875276, 'type' => 'personal', 'details' => []], + ['id' => 87413525, 'type' => 'business', 'details' => ['name' => 'Day Zero LLC']], + ]); + } + + if (str_contains($url, '/v2/borderless-accounts')) { + parse_str(parse_url($url, PHP_URL_QUERY) ?: '', $query); + $profileId = (int) ($query['profileId'] ?? 0); + + if ($profileId === 36875276) { + return Http::response([[ + 'id' => 44333087, + 'profileId' => 36875276, + 'balances' => [ + ['currency' => 'EUR', 'amount' => ['value' => 19.81]], + ['currency' => 'USD', 'amount' => ['value' => 0]], + ], + ]]); + } + + if ($profileId === 87413525) { + return Http::response([[ + 'id' => 67770905, + 'profileId' => 87413525, + 'balances' => [ + ['currency' => 'EUR', 'amount' => ['value' => 100]], + ['currency' => 'MXN', 'amount' => ['value' => 50]], + ], + ]]); + } + } + + return Http::response([], 404); + }); +} + +test('connecting wise builds pending accounts for every profile using the profile id', function () { + Queue::fake(); + fakeWiseApi(); + + $user = User::factory()->onboarded()->create(); + + $response = $this->actingAs($user)->postJson('/open-banking/wise/connect', [ + 'api_token' => 'valid-wise-api-token-12345', + ]); + + $response->assertOk(); + $response->assertJsonStructure(['redirect_url', 'connection_id']); + + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'wise')->first(); + expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping); + + $uids = collect($connection->pending_accounts_data)->pluck('uid'); + + // Both profiles are represented... + expect($uids)->toContain('36875276:EUR', '36875276:USD', '87413525:EUR', '87413525:MXN'); + expect($connection->pending_accounts_data)->toHaveCount(4); + + // ...and uids use the profile id, never the borderless-account id (the old bug). + expect($uids->filter(fn ($uid) => str_starts_with($uid, '44333087') || str_starts_with($uid, '67770905'))) + ->toBeEmpty(); + + // No accounts created yet for an onboarded user (mapping step does that). + $this->assertDatabaseMissing('accounts', [ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + ]); +}); + +test('connecting wise during onboarding auto-creates accounts for both profiles', function () { + Queue::fake(); + fakeWiseApi(); + + $user = User::factory()->create(); // not onboarded + + $response = $this->actingAs($user)->postJson('/open-banking/wise/connect', [ + 'api_token' => 'valid-wise-api-token-12345', + ]); + + $response->assertOk(); + + $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'wise')->first(); + expect($connection->status)->toBe(BankingConnectionStatus::Active); + + foreach (['36875276:EUR', '36875276:USD', '87413525:EUR', '87413525:MXN'] as $uid) { + $this->assertDatabaseHas('accounts', [ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => $uid, + ]); + } + + Queue::assertPushed(SyncBankingConnectionJob::class); +}); + +test('invalid wise token returns 422', function () { + Http::fake([ + 'api.wise.com/v1/profiles' => Http::response(['error' => 'unauthorized'], 401), + ]); + + $user = User::factory()->onboarded()->create(); + + $response = $this->actingAs($user)->postJson('/open-banking/wise/connect', [ + 'api_token' => 'invalid-wise-token-12345', + ]); + + $response->assertUnprocessable(); + $response->assertJsonFragment(['message' => 'Invalid API token or failed to connect to Wise.']); + + $this->assertDatabaseMissing('banking_connections', [ + 'user_id' => $user->id, + 'provider' => 'wise', + ]); +});