feat(connections): manage which accounts a bank connection syncs (#558)
## Why
During the bank connection flow (Enable Banking) a user picks which
accounts to sync and can skip the rest. Until now there was **no way
back**: a skipped account couldn't be enabled later, a synced account
couldn't be moved or turned off, and accounts the bank added afterwards
were invisible. Skipped accounts aren't persisted anywhere —
`pending_accounts_data` is cleared once mapping completes — so the app
simply forgets they exist.
This adds a per-connection **Manage Accounts** screen that closes that
gap.
## What
- **Known accounts render from the DB** (no provider call). For each
synced account the user can:
- **Stop syncing** → the account is unlinked and becomes a regular
manual account. Non-destructive: it keeps all its imported transactions.
- **Change destination** → move syncing to another compatible manual
account (unlinks the previous holder, links the new one with incremental
sync).
- **Load accounts** button re-fetches the consent's account list from
the provider *on demand* (`getSession` + `getAccount` only for uids we
don't already know) to surface newly available / previously skipped
accounts, which can then be created as new accounts or linked to
existing manual ones.
- The provider is **only ever hit on refresh**; every mutation is
DB-only, so the screen still works (read + rearrange) when the consent
has expired.
### Design notes
- No new column / cache: the default view derives entirely from the
`accounts` table, and refresh is on-demand and ephemeral. Re-fetching is
mandatory anyway (existing connections have no stored snapshot, and only
a live call can reveal accounts the bank added later).
- Scoped to **Enable Banking** for now; the approach is column-free so
extending to the crypto/API-key providers later is trivial.
## Endpoints
- `GET open-banking/connections/{connection}/accounts` — manage screen
(`discoveredAccounts` computed only when `?refresh=1`)
- `POST open-banking/connections/{connection}/accounts/map` — create /
link / change destination for one bank account
- `POST open-banking/connections/{connection}/accounts/{account}/unlink`
— stop syncing
## Testing
- New `ConnectionAccountTest` (7 tests): index props, refresh discovery
(excludes already-synced uids), create,
change-destination/unlink-previous, non-destructive unlink keeps
transactions, ownership 403, account-mismatch 404.
- Full `tests/Feature/OpenBanking` suite green (268), plus pint, eslint,
prettier and the enforced `es` localization test.
## Deliberately skipped
- **Subscription gating** on refresh/map — matches the currently ungated
`disconnect`. Easy follow-up if free users shouldn't trigger live
provider calls.
- Other providers (Enable Banking only, as agreed).
This commit is contained in:
parent
c36df98d32
commit
a9b90a200e
|
|
@ -45,7 +45,7 @@ interface BankingProviderInterface
|
||||||
/**
|
/**
|
||||||
* Get session details and status.
|
* Get session details and status.
|
||||||
*
|
*
|
||||||
* @return array{status: string, access: array, accounts: array}
|
* @return array{status: string, access: array, accounts: array, accounts_data?: array}
|
||||||
*/
|
*/
|
||||||
public function getSession(string $sessionId): array;
|
public function getSession(string $sessionId): array;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,13 @@ enum AccountType: string
|
||||||
{
|
{
|
||||||
return in_array($this, [self::Investment, self::Retirement, self::RealEstate], true);
|
return in_array($this, [self::Investment, self::Retirement, self::RealEstate], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a bank connection can sync transactions into this account type.
|
||||||
|
* Excludes balance/value-tracking types (loan, investment, retirement, real estate).
|
||||||
|
*/
|
||||||
|
public function canSyncBankTransactions(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [self::Checking, self::CreditCard, self::Savings, self::Others], true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Features;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates the per-connection "Manage Accounts" surface while it is being rolled
|
||||||
|
* out. For now it is limited to the admin user (ADMIN_EMAIL) so the flow can be
|
||||||
|
* dogfooded before a wider release.
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
*/
|
||||||
|
class ManageBankAccounts
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve the feature's initial value.
|
||||||
|
*/
|
||||||
|
public function resolve(?User $user): bool
|
||||||
|
{
|
||||||
|
return $user?->isAdmin() ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\OpenBanking;
|
||||||
|
|
||||||
|
use App\Contracts\BankingProviderInterface;
|
||||||
|
use App\Features\ManageBankAccounts;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\OpenBanking\MapConnectionAccountRequest;
|
||||||
|
use App\Jobs\SyncBankingConnectionJob;
|
||||||
|
use App\Models\Account;
|
||||||
|
use App\Models\Bank;
|
||||||
|
use App\Models\BankingConnection;
|
||||||
|
use App\Services\AccountUserCurrencyService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Laravel\Pennant\Feature;
|
||||||
|
|
||||||
|
class ConnectionAccountController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private BankingProviderInterface $provider) {}
|
||||||
|
|
||||||
|
public function index(Request $request, BankingConnection $connection): Response
|
||||||
|
{
|
||||||
|
$this->ensureFeatureEnabled();
|
||||||
|
$this->authorizeConnection($connection);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return Inertia::render('open-banking/manage-accounts', [
|
||||||
|
'connection' => $connection,
|
||||||
|
'syncedAccounts' => $connection->accounts()->with('bank')->get(),
|
||||||
|
'availableAccounts' => $user->accounts()
|
||||||
|
->whereNull('banking_connection_id')
|
||||||
|
->with('bank')
|
||||||
|
->get(),
|
||||||
|
'discoveredAccounts' => $request->boolean('refresh')
|
||||||
|
? $this->discoverAccounts($connection)
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function map(MapConnectionAccountRequest $request, BankingConnection $connection, AccountUserCurrencyService $accountUserCurrencyService): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->ensureFeatureEnabled();
|
||||||
|
|
||||||
|
$validated = $request->validated();
|
||||||
|
$uid = $validated['bank_account_uid'];
|
||||||
|
|
||||||
|
$bank = Bank::firstOrCreate(
|
||||||
|
['name' => $connection->aspsp_name, 'user_id' => null],
|
||||||
|
['name' => $connection->aspsp_name, 'logo' => $connection->aspsp_logo],
|
||||||
|
);
|
||||||
|
|
||||||
|
$currentHolder = $connection->accounts()->where('external_account_id', $uid)->first();
|
||||||
|
|
||||||
|
$iban = $validated['iban'] ?? null;
|
||||||
|
if ($currentHolder) {
|
||||||
|
$iban = $currentHolder->iban;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $validated['action'] === 'link'
|
||||||
|
? $connection->user->accounts()->find($validated['existing_account_id'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($validated['action'] === 'link') {
|
||||||
|
abort_if($target === null, 404);
|
||||||
|
abort_unless($target->type->canSyncBankTransactions(), 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currentHolder && (! $target || $currentHolder->isNot($target))) {
|
||||||
|
$currentHolder->update([
|
||||||
|
'banking_connection_id' => null,
|
||||||
|
'external_account_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($validated['action'] === 'create') {
|
||||||
|
$account = $connection->user->accounts()->create([
|
||||||
|
'name' => $validated['name'] ?? $iban ?? $connection->aspsp_name.' Account',
|
||||||
|
'name_iv' => null,
|
||||||
|
'encrypted' => false,
|
||||||
|
'bank_id' => $bank->id,
|
||||||
|
'currency_code' => $validated['currency'] ?? 'EUR',
|
||||||
|
'type' => $connection->provider->defaultAccountType()->value,
|
||||||
|
'banking_connection_id' => $connection->id,
|
||||||
|
'external_account_id' => $uid,
|
||||||
|
'iban' => $iban,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$account = $target;
|
||||||
|
$account->update([
|
||||||
|
'banking_connection_id' => $connection->id,
|
||||||
|
'external_account_id' => $uid,
|
||||||
|
'iban' => $iban ?? $account->iban,
|
||||||
|
'bank_id' => $bank->id,
|
||||||
|
'linked_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$accountUserCurrencyService->syncFromFirstAccount($account);
|
||||||
|
|
||||||
|
SyncBankingConnectionJob::dispatch($connection);
|
||||||
|
|
||||||
|
return back()->with('success', __('Account synced. Transactions will be updated shortly.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unlink(BankingConnection $connection, Account $account): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->ensureFeatureEnabled();
|
||||||
|
$this->authorizeConnection($connection);
|
||||||
|
|
||||||
|
if ($account->banking_connection_id !== $connection->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$account->update([
|
||||||
|
'banking_connection_id' => null,
|
||||||
|
'external_account_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', __('Account is no longer syncing. It is now a manual account.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureFeatureEnabled(): void
|
||||||
|
{
|
||||||
|
abort_unless(Feature::for(auth()->user())->active(ManageBankAccounts::class), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeConnection(BankingConnection $connection): void
|
||||||
|
{
|
||||||
|
if ($connection->user_id !== auth()->id()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the bank accounts the consent covers that are not yet synced.
|
||||||
|
*
|
||||||
|
* The session only returns account uids, so we enrich the unknown ones with a
|
||||||
|
* details call. Already-synced uids are skipped — they live in syncedAccounts.
|
||||||
|
*
|
||||||
|
* @return array<int, array{uid: string, name: string|null, currency: string|null, iban: string|null}>
|
||||||
|
*/
|
||||||
|
private function discoverAccounts(BankingConnection $connection): array
|
||||||
|
{
|
||||||
|
if (! $connection->isEnableBanking() || ! $connection->isActive() || ! $connection->session_id) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$session = $this->provider->getSession($connection->session_id);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Failed to refresh EnableBanking session accounts', [
|
||||||
|
'connection_id' => $connection->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$knownUids = $connection->accounts()->pluck('external_account_id')->filter()->all();
|
||||||
|
|
||||||
|
$uids = collect($session['accounts_data'] ?? $session['accounts'])
|
||||||
|
->map(fn ($account) => is_array($account) ? ($account['uid'] ?? null) : $account)
|
||||||
|
->filter()
|
||||||
|
->reject(fn (string $uid) => in_array($uid, $knownUids, true))
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return $uids->map(function (string $uid): ?array {
|
||||||
|
try {
|
||||||
|
$details = $this->provider->getAccount($uid);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uid' => $uid,
|
||||||
|
'name' => $details['name'] ?? $details['account_id']['iban'] ?? null,
|
||||||
|
'currency' => $details['currency'],
|
||||||
|
'iban' => $details['account_id']['iban'] ?? null,
|
||||||
|
];
|
||||||
|
})->filter()->values()->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Middleware;
|
||||||
use App\Enums\BankingConnectionStatus;
|
use App\Enums\BankingConnectionStatus;
|
||||||
use App\Enums\BankingProvider;
|
use App\Enums\BankingProvider;
|
||||||
use App\Features\CalculateBalancesOnImport;
|
use App\Features\CalculateBalancesOnImport;
|
||||||
|
use App\Features\ManageBankAccounts;
|
||||||
use App\Features\TransactionAnalysis;
|
use App\Features\TransactionAnalysis;
|
||||||
use App\Models\BankingConnection;
|
use App\Models\BankingConnection;
|
||||||
use App\Services\CurrencyOptions;
|
use App\Services\CurrencyOptions;
|
||||||
|
|
@ -178,18 +179,21 @@ class HandleInertiaRequests extends Middleware
|
||||||
'cashflow' => true,
|
'cashflow' => true,
|
||||||
'calculateBalancesOnImport' => false,
|
'calculateBalancesOnImport' => false,
|
||||||
'transactionAnalysis' => false,
|
'transactionAnalysis' => false,
|
||||||
|
'manageBankAccounts' => false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$features = Feature::for($user)->values([
|
$features = Feature::for($user)->values([
|
||||||
CalculateBalancesOnImport::class,
|
CalculateBalancesOnImport::class,
|
||||||
TransactionAnalysis::class,
|
TransactionAnalysis::class,
|
||||||
|
ManageBankAccounts::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'cashflow' => true,
|
'cashflow' => true,
|
||||||
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
|
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
|
||||||
'transactionAnalysis' => $features[TransactionAnalysis::class] !== false,
|
'transactionAnalysis' => $features[TransactionAnalysis::class] !== false,
|
||||||
|
'manageBankAccounts' => $features[ManageBankAccounts::class] !== false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\OpenBanking;
|
||||||
|
|
||||||
|
use App\Http\Requests\Concerns\ValidatesUserOwnedResources;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class MapConnectionAccountRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use ValidatesUserOwnedResources;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->route('connection')->user_id === $this->user()->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<mixed>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'bank_account_uid' => ['required', 'string'],
|
||||||
|
'action' => ['required', 'in:create,link'],
|
||||||
|
'existing_account_id' => [
|
||||||
|
'nullable',
|
||||||
|
'uuid',
|
||||||
|
'required_if:action,link',
|
||||||
|
$this->userOwned('accounts'),
|
||||||
|
],
|
||||||
|
'currency' => ['nullable', 'string', 'size:3'],
|
||||||
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'iban' => ['nullable', 'string', 'max:34'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
lang/es.json
16
lang/es.json
|
|
@ -1,4 +1,20 @@
|
||||||
{
|
{
|
||||||
|
"Manage Accounts": "Gestionar cuentas",
|
||||||
|
"Choose which accounts from this bank are synced and where their transactions go.": "Elige qué cuentas de este banco se sincronizan y a dónde van sus transacciones.",
|
||||||
|
"No accounts are syncing yet.": "Todavía no se está sincronizando ninguna cuenta.",
|
||||||
|
"Add accounts": "Añadir cuentas",
|
||||||
|
"Load the latest list of accounts from your bank to sync more of them.": "Carga la última lista de cuentas de tu banco para sincronizar más.",
|
||||||
|
"Load accounts": "Cargar cuentas",
|
||||||
|
"Reconnect this bank to load its accounts again.": "Vuelve a conectar este banco para cargar sus cuentas de nuevo.",
|
||||||
|
"No new accounts available to add.": "No hay cuentas nuevas para añadir.",
|
||||||
|
"Change destination": "Cambiar destino",
|
||||||
|
"Stop syncing": "Dejar de sincronizar",
|
||||||
|
"Stop syncing this account?": "¿Dejar de sincronizar esta cuenta?",
|
||||||
|
"This account will stop syncing with your bank and become a manual account. Your existing transactions are kept, but new ones from the bank will no longer be imported. You can start syncing it again later.": "Esta cuenta dejará de sincronizarse con tu banco y pasará a ser una cuenta manual. Tus transacciones actuales se conservan, pero las nuevas del banco ya no se importarán. Podrás volver a sincronizarla más adelante.",
|
||||||
|
"Move syncing to…": "Mover la sincronización a…",
|
||||||
|
"Link to existing": "Vincular a una existente",
|
||||||
|
"Account synced. Transactions will be updated shortly.": "Cuenta sincronizada. Las transacciones se actualizarán en breve.",
|
||||||
|
"Account is no longer syncing. It is now a manual account.": "La cuenta ya no se sincroniza. Ahora es una cuenta manual.",
|
||||||
"Link": "Enlace",
|
"Link": "Enlace",
|
||||||
"Submit": "Enviar",
|
"Submit": "Enviar",
|
||||||
"Pending review": "Pendiente de revisión",
|
"Pending review": "Pendiente de revisión",
|
||||||
|
|
|
||||||
4226
lang/fr.json
4226
lang/fr.json
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,437 @@
|
||||||
|
import { AccountName } from '@/components/accounts/account-name';
|
||||||
|
import { BankLogo } from '@/components/bank-logo';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import AppLayout from '@/layouts/app-layout';
|
||||||
|
import SettingsLayout from '@/layouts/settings/layout';
|
||||||
|
import type { SharedData } from '@/types';
|
||||||
|
import type { Account } from '@/types/account';
|
||||||
|
import { filterTransactionalAccounts } from '@/types/account';
|
||||||
|
import type { BankingConnection, DiscoveredBankAccount } from '@/types/banking';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
|
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
MoreHorizontal,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Unplug,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connection: BankingConnection;
|
||||||
|
syncedAccounts: Account[];
|
||||||
|
availableAccounts: Account[];
|
||||||
|
discoveredAccounts: DiscoveredBankAccount[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManageAccountsPage({
|
||||||
|
connection,
|
||||||
|
syncedAccounts,
|
||||||
|
availableAccounts,
|
||||||
|
discoveredAccounts,
|
||||||
|
}: Props) {
|
||||||
|
const { flash } = usePage<SharedData>().props;
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [hasRefreshed, setHasRefreshed] = useState(false);
|
||||||
|
const [discovered, setDiscovered] = useState<DiscoveredBankAccount[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (flash?.error) {
|
||||||
|
toast.error(flash.error);
|
||||||
|
}
|
||||||
|
if (flash?.success) {
|
||||||
|
toast.success(flash.success);
|
||||||
|
}
|
||||||
|
}, [flash?.error, flash?.success]);
|
||||||
|
|
||||||
|
// The discovered list only arrives when explicitly refreshed; keep it in
|
||||||
|
// local state so per-account actions (which reload without ?refresh) don't
|
||||||
|
// wipe it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (discoveredAccounts) {
|
||||||
|
setDiscovered(discoveredAccounts);
|
||||||
|
}
|
||||||
|
}, [discoveredAccounts]);
|
||||||
|
|
||||||
|
const mapUrl = `/open-banking/connections/${connection.id}/accounts/map`;
|
||||||
|
|
||||||
|
function compatibleAccounts(currency: string | null): Account[] {
|
||||||
|
return filterTransactionalAccounts(
|
||||||
|
availableAccounts.filter((a) => a.currency_code === currency),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function postAction(url: string, data: Record<string, string | null>) {
|
||||||
|
router.post(url, data, { preserveState: true, preserveScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
router.reload({
|
||||||
|
only: ['discoveredAccounts'],
|
||||||
|
data: { refresh: 1 },
|
||||||
|
onStart: () => setRefreshing(true),
|
||||||
|
onFinish: () => {
|
||||||
|
setRefreshing(false);
|
||||||
|
setHasRefreshed(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeDestination(account: Account, targetId: string) {
|
||||||
|
if (!account.external_account_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postAction(mapUrl, {
|
||||||
|
bank_account_uid: account.external_account_id,
|
||||||
|
action: 'link',
|
||||||
|
existing_account_id: targetId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSyncing(account: Account) {
|
||||||
|
postAction(
|
||||||
|
`/open-banking/connections/${connection.id}/accounts/${account.id}/unlink`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDiscovered(
|
||||||
|
discoveredAccount: DiscoveredBankAccount,
|
||||||
|
existingAccountId: string | null,
|
||||||
|
) {
|
||||||
|
postAction(mapUrl, {
|
||||||
|
bank_account_uid: discoveredAccount.uid,
|
||||||
|
action: existingAccountId ? 'link' : 'create',
|
||||||
|
existing_account_id: existingAccountId,
|
||||||
|
name: discoveredAccount.name,
|
||||||
|
currency: discoveredAccount.currency,
|
||||||
|
iban: discoveredAccount.iban,
|
||||||
|
});
|
||||||
|
setDiscovered((prev) =>
|
||||||
|
prev.filter((d) => d.uid !== discoveredAccount.uid),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = connection.status === 'active';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<Head title={__('Manage Accounts')} />
|
||||||
|
|
||||||
|
<SettingsLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/settings/connections"
|
||||||
|
className="mb-2 inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
{__('Connections')}
|
||||||
|
</Link>
|
||||||
|
<h3 className="text-lg font-medium">
|
||||||
|
{connection.aspsp_name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__(
|
||||||
|
'Choose which accounts from this bank are synced and where their transactions go.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{syncedAccounts.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{__('No accounts are syncing yet.')}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
syncedAccounts.map((account) => (
|
||||||
|
<SyncedAccountCard
|
||||||
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
targets={compatibleAccounts(
|
||||||
|
account.currency_code,
|
||||||
|
)}
|
||||||
|
onChangeDestination={changeDestination}
|
||||||
|
onStopSyncing={stopSyncing}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium">
|
||||||
|
{__('Add accounts')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__(
|
||||||
|
'Load the latest list of accounts from your bank to sync more of them.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={!isActive || refreshing}
|
||||||
|
>
|
||||||
|
{refreshing ? (
|
||||||
|
<Spinner className="mr-1.5 size-3" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-1.5 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{__('Load accounts')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isActive && (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
{__(
|
||||||
|
'Reconnect this bank to load its accounts again.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{discovered.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{discovered.map((discoveredAccount) => (
|
||||||
|
<DiscoveredAccountCard
|
||||||
|
key={discoveredAccount.uid}
|
||||||
|
discoveredAccount={discoveredAccount}
|
||||||
|
targets={compatibleAccounts(
|
||||||
|
discoveredAccount.currency,
|
||||||
|
)}
|
||||||
|
onAdd={addDiscovered}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasRefreshed &&
|
||||||
|
!refreshing &&
|
||||||
|
discovered.length === 0 && (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
|
{__('No new accounts available to add.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncedAccountCard({
|
||||||
|
account,
|
||||||
|
targets,
|
||||||
|
onChangeDestination,
|
||||||
|
onStopSyncing,
|
||||||
|
}: {
|
||||||
|
account: Account;
|
||||||
|
targets: Account[];
|
||||||
|
onChangeDestination: (account: Account, targetId: string) => void;
|
||||||
|
onStopSyncing: (account: Account) => void;
|
||||||
|
}) {
|
||||||
|
const [changing, setChanging] = useState(false);
|
||||||
|
const [confirmingStop, setConfirmingStop] = useState(false);
|
||||||
|
const otherTargets = targets.filter((t) => t.id !== account.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BankLogo
|
||||||
|
src={account.bank?.logo}
|
||||||
|
name={account.bank?.name}
|
||||||
|
className="size-5"
|
||||||
|
fallback="letter"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
<AccountName account={account} />
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{account.currency_code} · {__('Syncing')}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{account.external_account_id &&
|
||||||
|
otherTargets.length > 0 && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => setChanging(true)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
{__('Change destination')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => setConfirmingStop(true)}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<Unplug className="mr-2 h-4 w-4" />
|
||||||
|
{__('Stop syncing')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</CardHeader>
|
||||||
|
<AlertDialog open={confirmingStop} onOpenChange={setConfirmingStop}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{__('Stop syncing this account?')}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__(
|
||||||
|
'This account will stop syncing with your bank and become a manual account. Your existing transactions are kept, but new ones from the bank will no longer be imported. You can start syncing it again later.',
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => onStopSyncing(account)}
|
||||||
|
>
|
||||||
|
{__('Stop syncing')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
{changing && (
|
||||||
|
<CardContent>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onChangeDestination(account, value);
|
||||||
|
setChanging(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={__('Move syncing to…')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{otherTargets.map((target) => (
|
||||||
|
<SelectItem key={target.id} value={target.id}>
|
||||||
|
<AccountName account={target} />
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscoveredAccountCard({
|
||||||
|
discoveredAccount,
|
||||||
|
targets,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
discoveredAccount: DiscoveredBankAccount;
|
||||||
|
targets: Account[];
|
||||||
|
onAdd: (
|
||||||
|
discoveredAccount: DiscoveredBankAccount,
|
||||||
|
existingAccountId: string | null,
|
||||||
|
) => void;
|
||||||
|
}) {
|
||||||
|
const [linking, setLinking] = useState(false);
|
||||||
|
const displayName =
|
||||||
|
discoveredAccount.name || discoveredAccount.iban || __('Bank Account');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{displayName}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{discoveredAccount.currency}
|
||||||
|
{discoveredAccount.iban &&
|
||||||
|
discoveredAccount.name &&
|
||||||
|
` · ${discoveredAccount.iban}`}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{targets.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLinking((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{__('Link to existing')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAdd(discoveredAccount, null)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1.5 h-3 w-3" />
|
||||||
|
{__('Create account')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{linking && targets.length > 0 && (
|
||||||
|
<CardContent>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onAdd(discoveredAccount, value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={__('Select an account')}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targets.map((target) => (
|
||||||
|
<SelectItem key={target.id} value={target.id}>
|
||||||
|
<AccountName account={target} />
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,12 @@ vi.mock('@inertiajs/react', () => ({
|
||||||
},
|
},
|
||||||
flash: {},
|
flash: {},
|
||||||
subscriptionsEnabled: false,
|
subscriptionsEnabled: false,
|
||||||
|
features: {
|
||||||
|
cashflow: true,
|
||||||
|
calculateBalancesOnImport: false,
|
||||||
|
transactionAnalysis: false,
|
||||||
|
manageBankAccounts: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
usePoll: () => ({
|
usePoll: () => ({
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Unplug,
|
Unplug,
|
||||||
|
Wallet,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
@ -43,7 +44,8 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConnectionsPage({ connections }: Props) {
|
export default function ConnectionsPage({ connections }: Props) {
|
||||||
const { auth, flash, subscriptionsEnabled } = usePage<SharedData>().props;
|
const { auth, flash, subscriptionsEnabled, features } =
|
||||||
|
usePage<SharedData>().props;
|
||||||
const isDemoAccount = auth?.isDemoAccount ?? false;
|
const isDemoAccount = auth?.isDemoAccount ?? false;
|
||||||
const isFreePlan = subscriptionsEnabled && !auth?.hasProPlan;
|
const isFreePlan = subscriptionsEnabled && !auth?.hasProPlan;
|
||||||
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
|
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
|
||||||
|
|
@ -157,6 +159,13 @@ export default function ConnectionsPage({ connections }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canManageAccounts(connection: BankingConnection): boolean {
|
||||||
|
return (
|
||||||
|
connection.provider === 'enablebanking' &&
|
||||||
|
['active', 'expired', 'error'].includes(connection.status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string | null): string {
|
function formatDate(dateString: string | null): string {
|
||||||
if (!dateString) return __('Never');
|
if (!dateString) return __('Never');
|
||||||
return new Date(dateString).toLocaleDateString(undefined, {
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
|
@ -278,6 +287,23 @@ export default function ConnectionsPage({ connections }: Props) {
|
||||||
{__('Map Accounts')}
|
{__('Map Accounts')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{features.manageBankAccounts &&
|
||||||
|
canManageAccounts(
|
||||||
|
connection,
|
||||||
|
) && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
router.visit(
|
||||||
|
`/open-banking/connections/${connection.id}/accounts`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Wallet className="mr-2 h-4 w-4" />
|
||||||
|
{__(
|
||||||
|
'Manage Accounts',
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{hasAuthError(
|
{hasAuthError(
|
||||||
connection,
|
connection,
|
||||||
) && (
|
) && (
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ export interface PendingBankAccount {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredBankAccount {
|
||||||
|
uid: string;
|
||||||
|
name: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
iban: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnableBankingInstitution {
|
export interface EnableBankingInstitution {
|
||||||
name: string;
|
name: string;
|
||||||
country: string;
|
country: string;
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export interface Features {
|
||||||
cashflow: boolean;
|
cashflow: boolean;
|
||||||
calculateBalancesOnImport: boolean;
|
calculateBalancesOnImport: boolean;
|
||||||
transactionAnalysis: boolean;
|
transactionAnalysis: boolean;
|
||||||
|
manageBankAccounts: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExpiredBankingConnectionNotification {
|
export interface ExpiredBankingConnectionNotification {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use App\Http\Controllers\OpenBanking\AuthorizationController;
|
||||||
use App\Http\Controllers\OpenBanking\BinanceController;
|
use App\Http\Controllers\OpenBanking\BinanceController;
|
||||||
use App\Http\Controllers\OpenBanking\BitpandaController;
|
use App\Http\Controllers\OpenBanking\BitpandaController;
|
||||||
use App\Http\Controllers\OpenBanking\CoinbaseController;
|
use App\Http\Controllers\OpenBanking\CoinbaseController;
|
||||||
|
use App\Http\Controllers\OpenBanking\ConnectionAccountController;
|
||||||
use App\Http\Controllers\OpenBanking\IndexaCapitalController;
|
use App\Http\Controllers\OpenBanking\IndexaCapitalController;
|
||||||
use App\Http\Controllers\OpenBanking\InstitutionController;
|
use App\Http\Controllers\OpenBanking\InstitutionController;
|
||||||
use App\Http\Controllers\OpenBanking\WiseController;
|
use App\Http\Controllers\OpenBanking\WiseController;
|
||||||
|
|
@ -163,6 +164,9 @@ Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function
|
||||||
Route::get('connections/{connection}/reconnect', [AuthorizationController::class, 'reconnect'])->name('open-banking.reconnect');
|
Route::get('connections/{connection}/reconnect', [AuthorizationController::class, 'reconnect'])->name('open-banking.reconnect');
|
||||||
Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts');
|
Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts');
|
||||||
Route::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store');
|
Route::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store');
|
||||||
|
Route::get('connections/{connection}/accounts', [ConnectionAccountController::class, 'index'])->name('open-banking.connection-accounts.index');
|
||||||
|
Route::post('connections/{connection}/accounts/map', [ConnectionAccountController::class, 'map'])->name('open-banking.connection-accounts.map');
|
||||||
|
Route::post('connections/{connection}/accounts/{account}/unlink', [ConnectionAccountController::class, 'unlink'])->name('open-banking.connection-accounts.unlink');
|
||||||
Route::post('indexa-capital/connect', [IndexaCapitalController::class, 'store'])->name('open-banking.indexa-capital.connect');
|
Route::post('indexa-capital/connect', [IndexaCapitalController::class, 'store'])->name('open-banking.indexa-capital.connect');
|
||||||
Route::post('binance/connect', [BinanceController::class, 'store'])->name('open-banking.binance.connect');
|
Route::post('binance/connect', [BinanceController::class, 'store'])->name('open-banking.binance.connect');
|
||||||
Route::post('bitpanda/connect', [BitpandaController::class, 'store'])->name('open-banking.bitpanda.connect');
|
Route::post('bitpanda/connect', [BitpandaController::class, 'store'])->name('open-banking.bitpanda.connect');
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ test('shared feature flags do not include coinbase flag', function () {
|
||||||
'cashflow' => true,
|
'cashflow' => true,
|
||||||
'calculateBalancesOnImport' => false,
|
'calculateBalancesOnImport' => false,
|
||||||
'transactionAnalysis' => false,
|
'transactionAnalysis' => false,
|
||||||
|
'manageBankAccounts' => false,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Contracts\BankingProviderInterface;
|
||||||
|
use App\Enums\AccountType;
|
||||||
|
use App\Jobs\SyncBankingConnectionJob;
|
||||||
|
use App\Models\Account;
|
||||||
|
use App\Models\BankingConnection;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config([
|
||||||
|
'services.enablebanking.app_id' => 'test-app-id',
|
||||||
|
'services.enablebanking.private_key_path' => '/tmp/fake-key.pem',
|
||||||
|
'services.enablebanking.redirect_url' => 'https://example.com/callback',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manage-accounts surface is gated behind the ManageBankAccounts feature,
|
||||||
|
* which is only active for the admin user. Mark the created user as the admin so
|
||||||
|
* the gate lets the request through.
|
||||||
|
*/
|
||||||
|
function adminUser(): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->onboarded()->create();
|
||||||
|
config(['mail.admin_email' => $user->email]);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('index renders the manage page with synced and available accounts', function () {
|
||||||
|
$user = adminUser();
|
||||||
|
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
|
||||||
|
|
||||||
|
Account::factory()->for($user)->create([
|
||||||
|
'banking_connection_id' => $connection->id,
|
||||||
|
'external_account_id' => 'uid-1',
|
||||||
|
'currency_code' => 'EUR',
|
||||||
|
]);
|
||||||
|
Account::factory()->for($user)->create([
|
||||||
|
'banking_connection_id' => null,
|
||||||
|
'currency_code' => 'EUR',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('open-banking.connection-accounts.index', $connection))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->component('open-banking/manage-accounts')
|
||||||
|
->has('syncedAccounts', 1)
|
||||||
|
->has('availableAccounts', 1)
|
||||||
|
->where('discoveredAccounts', null)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh discovers bank accounts that are not yet synced', function () {
|
||||||
|
$user = adminUser();
|
||||||
|
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
|
||||||
|
|
||||||
|
Account::factory()->for($user)->create([
|
||||||
|
'banking_connection_id' => $connection->id,
|
||||||
|
'external_account_id' => 'synced-uid',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(BankingProviderInterface::class, function ($mock) {
|
||||||
|
$mock->shouldReceive('getSession')->andReturn([
|
||||||
|
'status' => 'AUTHORIZED',
|
||||||
|
'accounts' => [
|
||||||
|
['uid' => 'synced-uid'],
|
||||||
|
['uid' => 'new-uid'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$mock->shouldReceive('getAccount')->with('new-uid')->andReturn([
|
||||||
|
'uid' => 'new-uid',
|
||||||
|
'account_id' => ['iban' => 'ES9999999999999999999999'],
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'name' => 'Savings',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('open-banking.connection-accounts.index', [$connection, 'refresh' => 1]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn ($page) => $page
|
||||||
|
->has('discoveredAccounts', 1)
|
||||||
|
->where('discoveredAccounts.0.uid', 'new-uid')
|
||||||
|
->where('discoveredAccounts.0.name', 'Savings')
|
||||||
|
->where('discoveredAccounts.0.iban', 'ES9999999999999999999999')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('map create adds a new synced account and dispatches a sync', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$user = adminUser();
|
||||||
|
$connection = BankingConnection::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'aspsp_name' => 'Test Bank',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('open-banking.connection-accounts.map', $connection), [
|
||||||
|
'bank_account_uid' => 'new-uid',
|
||||||
|
'action' => 'create',
|
||||||
|
'name' => 'New Checking',
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'iban' => 'ES1111111111111111111111',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('accounts', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'banking_connection_id' => $connection->id,
|
||||||
|
'external_account_id' => 'new-uid',
|
||||||
|
'name' => 'New Checking',
|
||||||
|
'currency_code' => 'EUR',
|
||||||
|
'iban' => 'ES1111111111111111111111',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Queue::assertPushed(SyncBankingConnectionJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('map link moves syncing to another account and unlinks the previous one', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$user = adminUser();
|
||||||
|
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
|
||||||
|
|
||||||
|
$source = Account::factory()->for($user)->create([
|
||||||
|
'banking_connection_id' => $connection->id,
|
||||||
|
'external_account_id' => 'uid-1',
|
||||||
|
'iban' => 'ES2222222222222222222222',
|
||||||
|
'currency_code' => 'EUR',
|
||||||
|
]);
|
||||||
|
$target = Account::factory()->for($user)->create([
|
||||||
|
'banking_connection_id' => null,
|
||||||
|
'currency_code' => 'EUR',
|
||||||
|
'type' => AccountType::Checking->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('open-banking.connection-accounts.map', $connection), [
|
||||||
|
'bank_account_uid' => 'uid-1',
|
||||||
|
'action' => 'link',
|
||||||
|
'existing_account_id' => $target->id,
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($target->refresh())
|
||||||
|
->banking_connection_id->toBe($connection->id)
|
||||||
|
->external_account_id->toBe('uid-1')
|
||||||
|
->iban->toBe('ES2222222222222222222222')
|
||||||
|
->and($target->linked_at)->not->toBeNull();
|
||||||
|
|
||||||
|
expect($source->refresh())
|
||||||
|
->banking_connection_id->toBeNull()
|
||||||
|
->external_account_id->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unlink stops syncing but keeps the account and its transactions', function () {
|
||||||
|
$user = adminUser();
|
||||||
|
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
|
||||||
|
|
||||||
|
$account = Account::factory()->for($user)->create([
|
||||||
|
'banking_connection_id' => $connection->id,
|
||||||
|
'external_account_id' => 'uid-1',
|
||||||
|
]);
|
||||||
|
$transaction = Transaction::factory()->for($account)->create(['user_id' => $user->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('open-banking.connection-accounts.unlink', [$connection, $account]))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
expect($account->refresh())
|
||||||
|
->banking_connection_id->toBeNull()
|
||||||
|
->external_account_id->toBeNull();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('accounts', ['id' => $account->id, 'deleted_at' => null]);
|
||||||
|
$this->assertDatabaseHas('transactions', ['id' => $transaction->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index is forbidden for another user\'s connection', function () {
|
||||||
|
$user = adminUser();
|
||||||
|
$connection = BankingConnection::factory()->create([
|
||||||
|
'user_id' => User::factory()->onboarded()->create()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('open-banking.connection-accounts.index', $connection))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unlink rejects an account that does not belong to the connection', function () {
|
||||||
|
$user = adminUser();
|
||||||
|
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
|
||||||
|
$account = Account::factory()->for($user)->create([
|
||||||
|
'banking_connection_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('open-banking.connection-accounts.unlink', [$connection, $account]))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manage accounts is forbidden when the feature is disabled', function () {
|
||||||
|
$user = User::factory()->onboarded()->create();
|
||||||
|
config(['mail.admin_email' => 'someone-else@example.com']);
|
||||||
|
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('open-banking.connection-accounts.index', $connection))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('map link rejects a non-transactional target account', function () {
|
||||||
|
$user = adminUser();
|
||||||
|
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
|
||||||
|
$loan = Account::factory()->for($user)->create([
|
||||||
|
'banking_connection_id' => null,
|
||||||
|
'currency_code' => 'EUR',
|
||||||
|
'type' => AccountType::Loan->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('open-banking.connection-accounts.map', $connection), [
|
||||||
|
'bank_account_uid' => 'uid-x',
|
||||||
|
'action' => 'link',
|
||||||
|
'existing_account_id' => $loan->id,
|
||||||
|
])
|
||||||
|
->assertStatus(422);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue