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:
Víctor Falcón 2026-06-18 16:22:49 +02:00 committed by GitHub
parent c36df98d32
commit a9b90a200e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 3114 additions and 2107 deletions

View File

@ -45,7 +45,7 @@ interface BankingProviderInterface
/**
* 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;

View File

@ -33,4 +33,13 @@ enum AccountType: string
{
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);
}
}

View File

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

View File

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

View File

@ -5,6 +5,7 @@ namespace App\Http\Middleware;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Features\CalculateBalancesOnImport;
use App\Features\ManageBankAccounts;
use App\Features\TransactionAnalysis;
use App\Models\BankingConnection;
use App\Services\CurrencyOptions;
@ -178,18 +179,21 @@ class HandleInertiaRequests extends Middleware
'cashflow' => true,
'calculateBalancesOnImport' => false,
'transactionAnalysis' => false,
'manageBankAccounts' => false,
];
}
$features = Feature::for($user)->values([
CalculateBalancesOnImport::class,
TransactionAnalysis::class,
ManageBankAccounts::class,
]);
return [
'cashflow' => true,
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
'transactionAnalysis' => $features[TransactionAnalysis::class] !== false,
'manageBankAccounts' => $features[ManageBankAccounts::class] !== false,
];
}

View File

@ -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'],
];
}
}

View File

@ -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",
"Submit": "Enviar",
"Pending review": "Pendiente de revisión",

View File

@ -1,4 +1,20 @@
{
"Manage Accounts": "Gérer les comptes",
"Choose which accounts from this bank are synced and where their transactions go.": "Choisissez quels comptes de cette banque sont synchronisés et où vont leurs transactions.",
"No accounts are syncing yet.": "Aucun compte n'est encore synchronisé.",
"Add accounts": "Ajouter des comptes",
"Load the latest list of accounts from your bank to sync more of them.": "Chargez la dernière liste des comptes de votre banque pour en synchroniser davantage.",
"Load accounts": "Charger les comptes",
"Reconnect this bank to load its accounts again.": "Reconnectez cette banque pour charger à nouveau ses comptes.",
"No new accounts available to add.": "Aucun nouveau compte à ajouter.",
"Change destination": "Changer la destination",
"Stop syncing": "Arrêter la synchronisation",
"Stop syncing this account?": "Arrêter la synchronisation de ce compte ?",
"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.": "Ce compte cessera d'être synchronisé avec votre banque et deviendra un compte manuel. Vos transactions existantes sont conservées, mais les nouvelles de la banque ne seront plus importées. Vous pourrez le resynchroniser plus tard.",
"Move syncing to…": "Déplacer la synchronisation vers…",
"Link to existing": "Lier à un compte existant",
"Account synced. Transactions will be updated shortly.": "Compte synchronisé. Les transactions seront mises à jour sous peu.",
"Account is no longer syncing. It is now a manual account.": "Le compte n'est plus synchronisé. C'est désormais un compte manuel.",
"Link": "Lien",
"Submit": "Envoyer",
"Pending review": "En attente de validation",

View File

@ -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} &middot; {__('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>
);
}

View File

@ -25,6 +25,12 @@ vi.mock('@inertiajs/react', () => ({
},
flash: {},
subscriptionsEnabled: false,
features: {
cashflow: true,
calculateBalancesOnImport: false,
transactionAnalysis: false,
manageBankAccounts: true,
},
},
}),
usePoll: () => ({

View File

@ -34,6 +34,7 @@ import {
RefreshCw,
RotateCcw,
Unplug,
Wallet,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
@ -43,7 +44,8 @@ interface 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 isFreePlan = subscriptionsEnabled && !auth?.hasProPlan;
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 {
if (!dateString) return __('Never');
return new Date(dateString).toLocaleDateString(undefined, {
@ -278,6 +287,23 @@ export default function ConnectionsPage({ connections }: Props) {
{__('Map Accounts')}
</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(
connection,
) && (

View File

@ -30,6 +30,13 @@ export interface PendingBankAccount {
};
}
export interface DiscoveredBankAccount {
uid: string;
name: string | null;
currency: string | null;
iban: string | null;
}
export interface EnableBankingInstitution {
name: string;
country: string;

View File

@ -43,6 +43,7 @@ export interface Features {
cashflow: boolean;
calculateBalancesOnImport: boolean;
transactionAnalysis: boolean;
manageBankAccounts: boolean;
}
export interface ExpiredBankingConnectionNotification {

View File

@ -15,6 +15,7 @@ use App\Http\Controllers\OpenBanking\AuthorizationController;
use App\Http\Controllers\OpenBanking\BinanceController;
use App\Http\Controllers\OpenBanking\BitpandaController;
use App\Http\Controllers\OpenBanking\CoinbaseController;
use App\Http\Controllers\OpenBanking\ConnectionAccountController;
use App\Http\Controllers\OpenBanking\IndexaCapitalController;
use App\Http\Controllers\OpenBanking\InstitutionController;
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}/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::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('binance/connect', [BinanceController::class, 'store'])->name('open-banking.binance.connect');
Route::post('bitpanda/connect', [BitpandaController::class, 'store'])->name('open-banking.bitpanda.connect');

View File

@ -44,6 +44,7 @@ test('shared feature flags do not include coinbase flag', function () {
'cashflow' => true,
'calculateBalancesOnImport' => false,
'transactionAnalysis' => false,
'manageBankAccounts' => false,
]);
});

View File

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