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

File diff suppressed because it is too large Load Diff

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