feat: add Wise open banking integration with balance sync (#525)

## 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) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
This commit is contained in:
Toni Grunwald 2026-06-16 08:23:25 -05:00 committed by GitHub
parent 4a891a5906
commit 1c5a76a3a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 879 additions and 13 deletions

View File

@ -7,4 +7,5 @@ enum TransactionSource: string
case ManuallyCreated = 'manually_created';
case Imported = 'imported';
case EnableBanking = 'enablebanking';
case Wise = 'wise';
}

View File

@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\ConnectWiseRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Services\AccountUserCurrencyService;
use App\Services\Banking\WiseClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class WiseController extends Controller
{
use CreatesAccountsFromPending;
use HandlesSubscriptionGate;
/**
* Validate a Wise API token and create a connection.
*
* Every currency wallet across all of the token's profiles (personal and
* business) becomes a pending account with
* external_account_id = "{profileId}:{currency}".
*/
public function store(ConnectWiseRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
{
$user = auth()->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<int, array{id?: int, type?: string, details?: array<string, mixed>}> $profiles
* @return array<int, array{uid: string, currency: string, name: string}>
*/
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;
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest;
class ConnectWiseRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'api_token' => ['required', 'string', 'min:10'],
];
}
/**
* @return array<string, string>
*/
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.',
];
}
}

View File

@ -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<string, mixed>
*/
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);

View File

@ -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);

View File

@ -0,0 +1,48 @@
<?php
namespace App\Services\Banking;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
class WiseBalanceSyncService
{
/**
* Sync today's balance for a Wise currency wallet via the borderless account API.
*
* The account's `external_account_id` must be in the format
* "{profileId}:{currency}" (e.g. "36875276:EUR").
*/
public function sync(Account $account, WiseClient $client): void
{
if (! $account->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,
]);
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services\Banking;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WiseClient
{
private const BASE_URL = 'https://api.wise.com';
public function __construct(private string $apiToken) {}
/**
* @return array<int, array{id: int, type: string, details: array}>
*/
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(),
]);
});
}
}

View File

@ -0,0 +1,204 @@
<?php
namespace App\Services\Banking;
use App\Enums\TransactionSource;
use App\Models\Account;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Log;
class WiseTransactionSyncService
{
/**
* Sync transactions for a Wise currency wallet via the activities API.
*
* The account's `external_account_id` must be in the format
* "{profileId}:{currency}" (e.g. "36875276:EUR").
*
* @return int Number of new transactions created
*/
public function sync(Account $account, WiseClient $client, string $dateFrom, string $dateTo): int
{
if (! $account->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, '<positive>');
// 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'],
]));
}
}

View File

@ -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) => [

View File

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -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.',
)}
</p>
</div>
</div>
@ -584,6 +610,40 @@ export function ConnectAccountDialog({
</div>
)}
{isWise && (
<div className="space-y-2">
<Label htmlFor="wise-api-token">
{__('Personal API Token')}
</Label>
<Input
id="wise-api-token"
type="password"
value={wiseApiToken}
onChange={(e) =>
setWiseApiToken(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Wise API token',
)}
/>
<p className="text-xs text-muted-foreground">
{__('Generate a token in Wise under')}{' '}
<a
href="https://wise.com/user/account#/developer"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__(
'Settings → Developer Tools → API tokens',
)}
</a>
.
</p>
</div>
)}
{isCoinbase && (
<div className="space-y-4">
<div className="space-y-2">
@ -654,7 +714,8 @@ export function ConnectAccountDialog({
(isBitpanda && !bitpandaApiKey) ||
(isCoinbase &&
(!coinbaseKeyName ||
!coinbasePrivateKey))
!coinbasePrivateKey)) ||
(isWise && !wiseApiToken)
}
>
{isSubmitting

View File

@ -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 {

View File

@ -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 () {

View File

@ -0,0 +1,98 @@
<?php
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Banking\WiseBalanceSyncService;
use App\Services\Banking\WiseClient;
use Illuminate\Support\Facades\Http;
test('syncs the wise wallet balance for the matching currency', 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',
]);
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();
});

View File

@ -0,0 +1,131 @@
<?php
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
function fakeWiseApi(): void
{
Http::fake(function (Request $request) {
$url = $request->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',
]);
});