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 && (
+
+ )}
+
{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',
+ ]);
+});