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 00000000..4df8fad7 Binary files /dev/null and b/public/images/banks/logos/wise.png differ diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx index 604996d3..2483345e 100644 --- a/resources/js/components/open-banking/connect-account-dialog.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.tsx @@ -74,6 +74,13 @@ const COINBASE_INSTITUTION: EnableBankingInstitution = { maximum_consent_validity: null, }; +const WISE_INSTITUTION: EnableBankingInstitution = { + name: 'Wise', + country: 'ALL', + logo: '/images/banks/logos/wise.png', + maximum_consent_validity: null, +}; + interface ConnectAccountDialogProps { open: boolean; onOpenChange: (open: boolean) => 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', + ]); +});