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.
|
||||
*
|
||||
* @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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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\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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"Submit": "Enviar",
|
||||
"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: {},
|
||||
subscriptionsEnabled: false,
|
||||
features: {
|
||||
cashflow: true,
|
||||
calculateBalancesOnImport: false,
|
||||
transactionAnalysis: false,
|
||||
manageBankAccounts: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
usePoll: () => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export interface Features {
|
|||
cashflow: boolean;
|
||||
calculateBalancesOnImport: boolean;
|
||||
transactionAnalysis: boolean;
|
||||
manageBankAccounts: boolean;
|
||||
}
|
||||
|
||||
export interface ExpiredBankingConnectionNotification {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ test('shared feature flags do not include coinbase flag', function () {
|
|||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => 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