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:
parent
4a891a5906
commit
1c5a76a3a4
|
|
@ -7,4 +7,5 @@ enum TransactionSource: string
|
|||
case ManuallyCreated = 'manually_created';
|
||||
case Imported = 'imported';
|
||||
case EnableBanking = 'enablebanking';
|
||||
case Wise = 'wise';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
Loading…
Reference in New Issue