feat: Integrate EnableBanking as open banking provider (#106)

## Summary

- Adds **EnableBanking** as the first open banking provider, allowing
users to connect real bank accounts and automatically sync transactions
and balances
- Uses a **`BankingProviderInterface`** contract so future providers
(Plaid, GoCardless, etc.) can be added by implementing the same
interface
- Feature-flagged behind the **`open-banking`** Pennant flag (default:
off)
- Connected accounts are **unencrypted** and transactions have `source =
'enablebanking'`

### What's included

**Backend:**
- `BankingProviderInterface` contract + `EnableBankingProvider`
implementation (JWT RS256 auth)
- `BankingConnection` model with full lifecycle (pending →
awaiting_mapping → active → expired/revoked/error)
- `TransactionSyncService` — pagination, deduplication by
`external_transaction_id`, amount/date mapping
- `BalanceSyncService` — preferred balance type selection (CLBD → ITAV
fallback)
- Authorization flow: start auth → bank redirect → callback → session
creation → account mapping → sync
- `SyncBankingConnectionJob` (unique per connection, 3 retries) +
scheduled every 6 hours
- `banking:sync` artisan command
- 5 migrations: `banking_connections` table, account fields, transaction
`external_transaction_id`, `pending_accounts_data`, `linked_at`

**Frontend:**
- Manual vs Connected account choice in the create account dialog
- Multi-step bank connection dialog (country → bank selection →
confirmation → redirect)
- Account mapping page — map discovered bank accounts to existing
accounts, create new ones, or skip
- Settings/Connections page with status badges, sync/disconnect actions
- "Connected" badge on linked accounts in settings

**Tests:**
- 49 tests covering feature flags, controllers, account mapping,
transaction sync, balance sync, deduplication, and pagination

### Feature Flags

This PR introduces **two Pennant feature flags**:

1. **`open-banking`** — Gates the entire open banking feature
(institutions endpoint, authorization flow, connections page). When
disabled, all open banking routes return 404.

2. **`account-mapping`** — Controls whether users see an intermediate
account mapping step after connecting a bank. When **enabled**, users
are redirected to a mapping page where they can choose to create new
accounts, link to existing ones, or skip each discovered bank account.
When **disabled**, all discovered accounts are automatically created
(original behavior). Linked accounts only sync transactions from their
last transaction date and only update the current balance from the
provider (no historical balance calculation or daily balance tracking).

Enable per-user:
```bash
php artisan feature:enable open-banking user@example.com
php artisan feature:enable account-mapping user@example.com
```

Enable for all users:
```bash
php artisan feature:enable open-banking all
php artisan feature:enable account-mapping all
```

## Test plan

- [x] Enable feature flag: `php artisan feature:enable open-banking`
- [x] Verify "Connected" option appears in create account dialog
- [x] Start authorization flow and verify redirect to bank
- [x] With `account-mapping` **disabled**: verify callback creates
accounts directly and dispatches sync
- [x] With `account-mapping` **enabled**: verify callback redirects to
mapping page
- [x] Test mapping page: create new, link to existing, and skip actions
- [x] Verify linked accounts sync only from last transaction date and
only update current balance
- [x] Verify connections page shows "Setup Required" badge for
awaiting_mapping status
- [x] Run `php artisan banking:sync` and verify transactions sync
- [x] Verify connections page shows status, sync, and disconnect actions
- [x] Run full test suite: `php artisan test --compact`
This commit is contained in:
Víctor Falcón 2026-02-12 09:09:28 +01:00 committed by GitHub
parent a19a8d52ec
commit db7b6e4da7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 4534 additions and 147 deletions

View File

@ -103,6 +103,11 @@ FORWARD_MAILHOG_DASHBOARD_PORT=8025
# Dev Mode (set to false for full Docker deployment via setup.sh)
DEV_MODE=true
# EnableBanking (Open Banking)
ENABLEBANKING_APP_ID=
ENABLEBANKING_PRIVATE_KEY_PATH=
ENABLEBANKING_REDIRECT_URL="${APP_URL}/open-banking/callback"
# Demo Account Configuration
DEMO_EMAIL=demo@whisper.money
DEMO_PASSWORD=demo

1
.gitignore vendored
View File

@ -8,6 +8,7 @@
/resources/js/routes
/resources/js/wayfinder
/storage/*.key
/storage/keys/
/storage/pail
/vendor
.DS_Store

View File

@ -49,6 +49,6 @@ trait ResolvesFeatures
private function getStringBasedFeatures(): array
{
return ['budgets', 'plaintext-transactions'];
return ['budgets', 'plaintext-transactions', 'open-banking', 'account-mapping'];
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SyncAllBankingConnectionsJob;
use Illuminate\Console\Command;
class SyncBankingConnections extends Command
{
protected $signature = 'banking:sync';
protected $description = 'Sync transactions and balances for all active banking connections';
public function handle(): int
{
SyncAllBankingConnectionsJob::dispatch();
$this->info('Banking sync jobs dispatched.');
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Contracts;
interface BankingProviderInterface
{
/**
* Get available banking institutions for a country.
*
* @return array<int, array{name: string, country: string, logo: string|null, maximum_consent_validity: int|null}>
*/
public function getInstitutions(string $countryCode): array;
/**
* Start a user authorization flow.
*
* @return array{url: string, authorization_id: string}
*/
public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl): array;
/**
* Exchange a callback code for a session with accounts.
*
* @return array{session_id: string, accounts: array, aspsp: array, access: array}
*/
public function createSession(string $code): array;
/**
* Fetch transactions for an account.
*
* @return array{transactions: array, continuation_key: string|null}
*/
public function getTransactions(string $accountId, string $dateFrom, string $dateTo, ?string $continuationKey = null, ?string $strategy = null): array;
/**
* Fetch balances for an account.
*
* @return array{balances: array}
*/
public function getBalances(string $accountId): array;
/**
* Get session details and status.
*
* @return array{status: string, access: array, accounts: array}
*/
public function getSession(string $sessionId): array;
/**
* Revoke a session and its consent.
*/
public function revokeSession(string $sessionId): void;
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Enums;
enum BankingConnectionStatus: string
{
case Pending = 'pending';
case AwaitingMapping = 'awaiting_mapping';
case Active = 'active';
case Expired = 'expired';
case Revoked = 'revoked';
case Error = 'error';
}

View File

@ -6,4 +6,5 @@ enum TransactionSource: string
{
case ManuallyCreated = 'manually_created';
case Imported = 'imported';
case EnableBanking = 'enablebanking';
}

View File

@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers\OpenBanking;
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\OpenBanking\MapAccountsRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Models\BankingConnection;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class AccountMappingController extends Controller
{
public function show(BankingConnection $connection): Response|RedirectResponse
{
if ($connection->user_id !== auth()->id()) {
abort(403);
}
if (! $connection->hasPendingAccounts()) {
return redirect()->route('settings.connections.index');
}
$existingAccounts = auth()->user()
->accounts()
->whereNull('banking_connection_id')
->with('bank')
->get();
return Inertia::render('open-banking/map-accounts', [
'connection' => $connection,
'bankAccounts' => $connection->pending_accounts_data,
'existingAccounts' => $existingAccounts,
]);
}
public function store(MapAccountsRequest $request, BankingConnection $connection): RedirectResponse
{
if ($connection->user_id !== auth()->id()) {
abort(403);
}
$user = auth()->user();
$mappings = $request->validated()['mappings'];
$bank = Bank::firstOrCreate(
['name' => $connection->aspsp_name, 'user_id' => null],
['name' => $connection->aspsp_name, 'logo' => $connection->aspsp_logo],
);
if (! $bank->logo && $connection->aspsp_logo) {
$bank->update(['logo' => $connection->aspsp_logo]);
}
$pendingAccounts = collect($connection->pending_accounts_data)
->keyBy('uid');
foreach ($mappings as $mapping) {
$uid = $mapping['bank_account_uid'];
$action = $mapping['action'];
$accountData = $pendingAccounts->get($uid);
if (! $accountData) {
continue;
}
if ($action === 'create') {
$currency = $accountData['currency'] ?? 'EUR';
$name = $accountData['name']
?? $accountData['account_id']['iban']
?? $connection->aspsp_name.' Account';
$user->accounts()->create([
'name' => $name,
'name_iv' => null,
'encrypted' => false,
'bank_id' => $bank->id,
'currency_code' => $currency,
'type' => AccountType::Checking->value,
'banking_connection_id' => $connection->id,
'external_account_id' => $uid,
]);
} elseif ($action === 'link') {
$existingAccount = $user->accounts()->find($mapping['existing_account_id']);
if ($existingAccount) {
$existingAccount->update([
'banking_connection_id' => $connection->id,
'external_account_id' => $uid,
'bank_id' => $bank->id,
'linked_at' => now(),
]);
}
}
}
$connection->update([
'status' => BankingConnectionStatus::Active,
'pending_accounts_data' => null,
]);
SyncBankingConnectionJob::dispatch($connection);
return redirect()->route('settings.connections.index')
->with('success', 'Bank account connected successfully.');
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace App\Http\Controllers\OpenBanking;
use App\Contracts\BankingProviderInterface;
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\OpenBanking\StartAuthorizationRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Models\BankingConnection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Pennant\Feature;
class AuthorizationController extends Controller
{
/**
* Start the bank authorization flow.
*/
public function store(StartAuthorizationRequest $request, BankingProviderInterface $provider): JsonResponse
{
$user = auth()->user();
$validated = $request->validated();
$redirectUrl = config('services.enablebanking.redirect_url');
$result = $provider->startAuthorization(
$validated['aspsp_name'],
$validated['country'],
$redirectUrl,
);
$connection = $user->bankingConnections()->create([
'provider' => 'enablebanking',
'authorization_id' => $result['authorization_id'],
'aspsp_name' => $validated['aspsp_name'],
'aspsp_country' => $validated['country'],
'aspsp_logo' => $validated['logo'] ?? null,
'status' => BankingConnectionStatus::Pending,
]);
return response()->json([
'redirect_url' => $result['url'],
'connection_id' => $connection->id,
]);
}
/**
* Handle the callback from bank authorization.
*/
public function callback(Request $request, BankingProviderInterface $provider): RedirectResponse
{
if ($request->has('error')) {
Log::warning('EnableBanking authorization error', [
'error' => $request->query('error'),
'description' => $request->query('error_description'),
]);
return redirect()->route('settings.connections.index')
->with('error', $request->query('error_description', 'Authorization was denied or cancelled.'));
}
$code = $request->query('code');
if (! $code) {
return redirect()->route('settings.connections.index')
->with('error', 'No authorization code received.');
}
try {
$sessionData = $provider->createSession($code);
} catch (\Throwable $e) {
Log::error('EnableBanking session creation failed', ['error' => $e->getMessage()]);
return redirect()->route('settings.connections.index')
->with('error', 'Failed to connect to your bank. Please try again.');
}
$user = auth()->user();
$connection = $user->bankingConnections()
->where('status', BankingConnectionStatus::Pending)
->latest()
->first();
if (! $connection) {
return redirect()->route('settings.connections.index')
->with('error', 'No pending connection found.');
}
if (Feature::for($user)->active('account-mapping')) {
$connection->update([
'session_id' => $sessionData['session_id'],
'status' => BankingConnectionStatus::AwaitingMapping,
'valid_until' => $sessionData['access']['valid_until'] ?? null,
'pending_accounts_data' => $sessionData['accounts'] ?? [],
]);
return redirect()->route('open-banking.map-accounts', $connection);
}
$connection->update([
'session_id' => $sessionData['session_id'],
'status' => BankingConnectionStatus::Active,
'valid_until' => $sessionData['access']['valid_until'] ?? null,
]);
$this->createAccountsFromSession($user, $connection, $sessionData);
SyncBankingConnectionJob::dispatch($connection);
return redirect()->route('settings.connections.index')
->with('success', 'Bank account connected successfully.');
}
/**
* Create local accounts from the EnableBanking session data.
*/
private function createAccountsFromSession($user, BankingConnection $connection, array $sessionData): void
{
$bank = Bank::firstOrCreate(
['name' => $connection->aspsp_name, 'user_id' => null],
['name' => $connection->aspsp_name, 'logo' => $connection->aspsp_logo],
);
if (! $bank->logo && $connection->aspsp_logo) {
$bank->update(['logo' => $connection->aspsp_logo]);
}
$accounts = $sessionData['accounts'] ?? [];
foreach ($accounts as $accountData) {
$uid = $accountData['uid'] ?? null;
if (! $uid) {
continue;
}
$existingAccount = $user->accounts()
->where('banking_connection_id', $connection->id)
->where('external_account_id', $uid)
->first();
if ($existingAccount) {
continue;
}
$currency = $accountData['currency'] ?? 'EUR';
$name = $accountData['name']
?? $accountData['account_id']['iban']
?? $connection->aspsp_name.' Account';
$user->accounts()->create([
'name' => $name,
'name_iv' => null,
'encrypted' => false,
'bank_id' => $bank->id,
'currency_code' => $currency,
'type' => AccountType::Checking->value,
'banking_connection_id' => $connection->id,
'external_account_id' => $uid,
]);
}
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\OpenBanking;
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\OpenBanking\DestroyConnectionRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
class ConnectionController extends Controller
{
use AuthorizesRequests;
/**
* Show the user's banking connections.
*/
public function index(): Response
{
$connections = auth()->user()
->bankingConnections()
->withCount('accounts')
->orderByDesc('created_at')
->get()
->each(function ($connection) {
$connection->has_pending_accounts = $connection->hasPendingAccounts();
});
return Inertia::render('settings/connections', [
'connections' => $connections,
]);
}
/**
* Manually trigger a sync for a connection.
*/
public function sync(BankingConnection $connection): RedirectResponse
{
if ($connection->user_id !== auth()->id()) {
abort(403);
}
if (! $connection->isActive()) {
return back()->with('error', 'Connection is not active.');
}
SyncBankingConnectionJob::dispatch($connection);
return back()->with('success', 'Sync started. Transactions will be updated shortly.');
}
/**
* Revoke and delete a banking connection.
*/
public function destroy(DestroyConnectionRequest $request, BankingConnection $connection, BankingProviderInterface $provider): RedirectResponse
{
if ($connection->session_id && $connection->isActive()) {
try {
$provider->revokeSession($connection->session_id);
} catch (\Throwable $e) {
Log::warning('Failed to revoke EnableBanking session', [
'session_id' => $connection->session_id,
'error' => $e->getMessage(),
]);
}
}
if ($request->boolean('delete_accounts')) {
$connection->accounts->each(function ($account) {
$account->transactions()->delete();
$account->balances()->delete();
$account->delete();
});
} else {
$connection->accounts()->update([
'banking_connection_id' => null,
'external_account_id' => null,
]);
}
$connection->update(['status' => BankingConnectionStatus::Revoked]);
$connection->delete();
return redirect()->route('settings.connections.index')
->with('success', 'Banking connection disconnected.');
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\OpenBanking;
use App\Contracts\BankingProviderInterface;
use App\Http\Controllers\Controller;
use App\Http\Requests\OpenBanking\ListInstitutionsRequest;
use Illuminate\Http\JsonResponse;
class InstitutionController extends Controller
{
public function index(ListInstitutionsRequest $request, BankingProviderInterface $provider): JsonResponse
{
$institutions = $provider->getInstitutions($request->validated('country'));
return response()->json($institutions);
}
}

View File

@ -25,7 +25,7 @@ class AccountController extends Controller
->accounts()
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']);
return Inertia::render('settings/accounts', [
'accounts' => $accounts,

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Laravel\Pennant\Feature;
use Symfony\Component\HttpFoundation\Response;
class EnsureOpenBankingFeature
{
/**
* Handle an incoming request.
*
* Returns 404 if user doesn't have open-banking feature enabled.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user || ! Feature::for($user)->active('open-banking')) {
abort(404);
}
return $next($request);
}
}

View File

@ -71,6 +71,8 @@ class HandleInertiaRequests extends Middleware
'cashflow' => true,
'budgets' => $user ? Feature::for($user)->active('budgets') : false,
'plaintext-transactions' => $user ? Feature::for($user)->active('plaintext-transactions') : false,
'open-banking' => $user ? Feature::for($user)->active('open-banking') : false,
'account-mapping' => $user ? Feature::for($user)->active('account-mapping') : false,
],
'accounts' => fn () => $user ? $user->accounts()
->with('bank:id,name,logo')

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest;
class DestroyConnectionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->route('connection')->user_id === $this->user()->id;
}
/**
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return [
'delete_accounts' => ['required', 'boolean'],
'confirmation' => ['required_if:delete_accounts,true', 'nullable', 'string', 'in:delete all'],
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class ListInstitutionsRequest extends FormRequest
{
public function authorize(): bool
{
return Feature::for($this->user())->active('open-banking');
}
/**
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return [
'country' => ['required', 'string', 'size:2'],
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class MapAccountsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->route('connection')->user_id === $this->user()->id;
}
/**
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return [
'mappings' => ['required', 'array', 'min:1'],
'mappings.*.bank_account_uid' => ['required', 'string'],
'mappings.*.action' => ['required', 'in:create,link,skip'],
'mappings.*.existing_account_id' => [
'nullable',
'uuid',
'required_if:mappings.*.action,link',
Rule::exists('accounts', 'id')->where('user_id', $this->user()->id),
],
];
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class StartAuthorizationRequest extends FormRequest
{
public function authorize(): bool
{
return Feature::for($this->user())->active('open-banking');
}
/**
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return [
'aspsp_name' => ['required', 'string'],
'country' => ['required', 'string', 'size:2'],
'logo' => ['nullable', 'string', 'url'],
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Jobs;
use App\Enums\BankingConnectionStatus;
use App\Models\BankingConnection;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SyncAllBankingConnectionsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
BankingConnection::query()
->where('status', BankingConnectionStatus::Active)
->where(function ($query) {
$query->whereNull('valid_until')
->orWhere('valid_until', '>', now());
})
->each(function (BankingConnection $connection) {
SyncBankingConnectionJob::dispatch($connection);
});
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Jobs;
use App\Enums\BankingConnectionStatus;
use App\Models\BankingConnection;
use App\Services\Banking\BalanceSyncService;
use App\Services\Banking\TransactionSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(
public BankingConnection $bankingConnection,
) {}
public function uniqueId(): string
{
return $this->bankingConnection->id;
}
public function handle(TransactionSyncService $transactionSync, BalanceSyncService $balanceSync): void
{
$connection = $this->bankingConnection;
if ($connection->isExpired()) {
$connection->update(['status' => BankingConnectionStatus::Expired]);
Log::info('Banking connection expired, skipping sync', ['connection_id' => $connection->id]);
return;
}
if (! $connection->isActive()) {
return;
}
$isFirstSync = ! $connection->last_synced_at;
$dateFrom = $isFirstSync
? now()->subYear()->toDateString()
: $connection->last_synced_at->toDateString();
$dateTo = now()->toDateString();
$strategy = $isFirstSync ? 'longest' : null;
try {
foreach ($connection->accounts as $account) {
if ($account->isLinked()) {
$lastTransaction = $account->transactions()
->latest('transaction_date')
->first();
$linkedDateFrom = $lastTransaction
? $lastTransaction->transaction_date->toDateString()
: $dateFrom;
$transactionSync->sync($account, $linkedDateFrom, $dateTo, $strategy, saveDailyBalances: false);
$balanceSync->sync($account);
} else {
$transactionSync->sync($account, $dateFrom, $dateTo, $strategy);
$balanceSync->sync($account);
if ($isFirstSync) {
$balanceSync->calculateHistoricalBalances($account);
}
}
}
$connection->update([
'last_synced_at' => now(),
'error_message' => null,
]);
} catch (\Throwable $e) {
Log::error('Banking sync failed', [
'connection_id' => $connection->id,
'error' => $e->getMessage(),
]);
$connection->update([
'status' => BankingConnectionStatus::Error,
'error_message' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@ -23,6 +23,9 @@ class Account extends Model
'currency_code',
'type',
'encrypted',
'banking_connection_id',
'external_account_id',
'linked_at',
];
protected function casts(): array
@ -30,6 +33,7 @@ class Account extends Model
return [
'type' => AccountType::class,
'encrypted' => 'boolean',
'linked_at' => 'datetime',
];
}
@ -52,4 +56,19 @@ class Account extends Model
{
return $this->hasMany(AccountBalance::class);
}
public function bankingConnection(): BelongsTo
{
return $this->belongsTo(BankingConnection::class);
}
public function isConnected(): bool
{
return $this->banking_connection_id !== null;
}
public function isLinked(): bool
{
return $this->linked_at !== null;
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Models;
use App\Enums\BankingConnectionStatus;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class BankingConnection extends Model
{
/** @use HasFactory<\Database\Factories\BankingConnectionFactory> */
use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'user_id',
'provider',
'authorization_id',
'session_id',
'aspsp_name',
'aspsp_country',
'aspsp_logo',
'status',
'valid_until',
'last_synced_at',
'error_message',
'pending_accounts_data',
];
protected function casts(): array
{
return [
'status' => BankingConnectionStatus::class,
'valid_until' => 'datetime',
'last_synced_at' => 'datetime',
'pending_accounts_data' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function accounts(): HasMany
{
return $this->hasMany(Account::class);
}
public function isActive(): bool
{
return $this->status === BankingConnectionStatus::Active;
}
public function hasPendingAccounts(): bool
{
return ! empty($this->pending_accounts_data);
}
public function isExpired(): bool
{
return $this->status === BankingConnectionStatus::Expired
|| ($this->valid_until && $this->valid_until->isPast());
}
}

View File

@ -38,6 +38,8 @@ class Transaction extends Model
'notes',
'notes_iv',
'source',
'external_transaction_id',
'raw_data',
];
protected function casts(): array
@ -46,6 +48,7 @@ class Transaction extends Model
'transaction_date' => 'date:Y-m-d',
'amount' => 'integer',
'source' => TransactionSource::class,
'raw_data' => 'array',
];
}

View File

@ -118,6 +118,11 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
return $this->hasMany(Budget::class);
}
public function bankingConnections(): HasMany
{
return $this->hasMany(BankingConnection::class);
}
public function hasReceivedEmail(DripEmailType $type): bool
{
return $this->mailLogs()->where('email_type', $type)->exists();

View File

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Contracts\BankingProviderInterface;
use App\Events\TransactionCreated;
use App\Events\TransactionDeleted;
use App\Events\TransactionUpdated;
@ -9,6 +10,7 @@ use App\Http\Responses\RegisterResponse;
use App\Listeners\AssignTransactionToBudget;
use App\Listeners\UnassignTransactionFromBudget;
use App\Models\User;
use App\Services\Banking\EnableBankingProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
@ -24,6 +26,13 @@ class AppServiceProvider extends ServiceProvider
public function register(): void
{
$this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
$this->app->bind(BankingProviderInterface::class, function ($app) {
return new EnableBankingProvider(
config('services.enablebanking.app_id'),
base_path(config('services.enablebanking.private_key_path')),
);
});
}
/**
@ -41,5 +50,7 @@ class AppServiceProvider extends ServiceProvider
Feature::define('budgets', fn (User $user) => true);
Feature::define('plaintext-transactions', fn (User $user) => false);
Feature::define('open-banking', fn (User $user) => false);
Feature::define('account-mapping', fn (User $user) => false);
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Services\Banking;
use App\Contracts\BankingProviderInterface;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
class BalanceSyncService
{
/** Balance types in preference order */
private const PREFERRED_BALANCE_TYPES = ['CLBD', 'ITAV', 'ITBD', 'OPBD', 'XPCD'];
public function __construct(
private BankingProviderInterface $provider,
) {}
/**
* Sync balances for a connected account.
*/
public function sync(Account $account): void
{
if (! $account->external_account_id) {
return;
}
$result = $this->provider->getBalances($account->external_account_id);
$balances = $result['balances'] ?? [];
if (empty($balances)) {
return;
}
$balance = $this->selectPreferredBalance($balances);
if (! $balance) {
return;
}
$amount = (int) round(floatval($balance['balance_amount']['amount']) * 100);
$date = $balance['reference_date'] ?? now()->toDateString();
$account->balances()->updateOrCreate(
['balance_date' => $date],
['balance' => $amount],
);
Log::info('Synced balance', [
'account_id' => $account->id,
'balance' => $amount,
'date' => $date,
'type' => $balance['balance_type'],
]);
}
/**
* Calculate historical daily balances by working backwards from the latest known balance.
* Uses transaction amounts to derive end-of-day balances for dates without direct balance data.
*/
public function calculateHistoricalBalances(Account $account): void
{
$referenceBalance = $account->balances()
->orderByDesc('balance_date')
->first();
if (! $referenceBalance) {
return;
}
$existingDates = $account->balances()
->pluck('balance_date')
->map(fn ($date) => $date->toDateString())
->flip()
->all();
$dailyTotals = $account->transactions()
->where('transaction_date', '<=', $referenceBalance->balance_date)
->selectRaw('transaction_date, SUM(amount) as daily_total')
->groupBy('transaction_date')
->orderByDesc('transaction_date')
->pluck('daily_total', 'transaction_date');
if ($dailyTotals->isEmpty()) {
return;
}
$runningBalance = $referenceBalance->balance;
$referenceDate = $referenceBalance->balance_date->toDateString();
foreach ($dailyTotals as $date => $sum) {
if ($date < $referenceDate && ! isset($existingDates[$date])) {
$account->balances()->create([
'balance_date' => $date,
'balance' => $runningBalance,
]);
}
$runningBalance -= (int) $sum;
}
Log::info('Calculated historical balances', [
'account_id' => $account->id,
'reference_date' => $referenceDate,
'reference_balance' => $referenceBalance->balance,
]);
}
/**
* Select the most useful balance from the list based on preferred types.
*/
private function selectPreferredBalance(array $balances): ?array
{
foreach (self::PREFERRED_BALANCE_TYPES as $type) {
foreach ($balances as $balance) {
if (($balance['balance_type'] ?? null) === $type) {
return $balance;
}
}
}
return $balances[0] ?? null;
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace App\Services\Banking;
use App\Contracts\BankingProviderInterface;
use Firebase\JWT\JWT;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class EnableBankingProvider implements BankingProviderInterface
{
private const BASE_URL = 'https://api.enablebanking.com';
public function __construct(
private string $appId,
private string $privateKeyPath,
) {}
public function getInstitutions(string $countryCode): array
{
$response = $this->client()->get('/aspsps', [
'country' => $countryCode,
'psu_type' => 'personal',
]);
$response->throw();
return collect($response->json('aspsps', []))
->map(fn (array $aspsp) => [
'name' => $aspsp['name'],
'country' => $aspsp['country'],
'logo' => $aspsp['logo'] ?? null,
'maximum_consent_validity' => $aspsp['maximum_consent_validity'] ?? null,
])
->all();
}
public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl): array
{
$response = $this->client()->post('/auth', [
'access' => [
'valid_until' => now()->addDays(90)->toIso8601String(),
'balances' => true,
'transactions' => true,
],
'aspsp' => [
'name' => $aspspName,
'country' => $countryCode,
],
'state' => csrf_token(),
'redirect_url' => $redirectUrl,
'psu_type' => 'personal',
]);
$response->throw();
$data = $response->json();
return [
'url' => $data['url'],
'authorization_id' => $data['authorization_id'],
];
}
public function createSession(string $code): array
{
$response = $this->client()->post('/sessions', [
'code' => $code,
]);
$response->throw();
return $response->json();
}
public function getTransactions(string $accountId, string $dateFrom, string $dateTo, ?string $continuationKey = null, ?string $strategy = null): array
{
$query = [
'date_from' => $dateFrom,
'date_to' => $dateTo,
];
if ($continuationKey) {
$query['continuation_key'] = $continuationKey;
}
if ($strategy) {
$query['strategy'] = $strategy;
}
$response = $this->client()->get("/accounts/{$accountId}/transactions", $query);
$response->throw();
$data = $response->json();
return [
'transactions' => $data['transactions'] ?? [],
'continuation_key' => $data['continuation_key'] ?? null,
];
}
public function getBalances(string $accountId): array
{
$response = $this->client()->get("/accounts/{$accountId}/balances");
$response->throw();
return $response->json();
}
public function getSession(string $sessionId): array
{
$response = $this->client()->get("/sessions/{$sessionId}");
$response->throw();
return $response->json();
}
public function revokeSession(string $sessionId): void
{
$response = $this->client()->delete("/sessions/{$sessionId}");
$response->throw();
}
private function client(): PendingRequest
{
return Http::baseUrl(self::BASE_URL)
->withToken($this->generateJwt())
->acceptJson()
->throw(function ($response, $exception) {
Log::error('EnableBanking API error', [
'status' => $response->status(),
'body' => $response->json(),
]);
});
}
private function generateJwt(): string
{
$now = time();
$payload = [
'iss' => 'enablebanking.com',
'aud' => 'api.enablebanking.com',
'iat' => $now,
'exp' => $now + 3600,
];
$privateKey = file_get_contents($this->privateKeyPath);
return JWT::encode($payload, $privateKey, 'RS256', $this->appId);
}
}

View File

@ -0,0 +1,185 @@
<?php
namespace App\Services\Banking;
use App\Contracts\BankingProviderInterface;
use App\Enums\TransactionSource;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
class TransactionSyncService
{
public function __construct(
private BankingProviderInterface $provider,
) {}
/**
* Sync transactions for a connected account.
*
* @return int Number of new transactions created
*/
public function sync(Account $account, string $dateFrom, string $dateTo, ?string $strategy = null, bool $saveDailyBalances = true): int
{
if (! $account->external_account_id) {
return 0;
}
$created = 0;
$continuationKey = null;
$dailyBalances = [];
do {
$result = $this->provider->getTransactions(
$account->external_account_id,
$dateFrom,
$dateTo,
$continuationKey,
$strategy,
);
foreach ($result['transactions'] as $transaction) {
if ($this->importTransaction($account, $transaction)) {
$created++;
}
if ($saveDailyBalances) {
$this->trackDailyBalance($transaction, $dailyBalances);
}
}
$continuationKey = $result['continuation_key'];
} while ($continuationKey);
if ($saveDailyBalances) {
$this->saveDailyBalances($account, $dailyBalances);
}
Log::info('Synced transactions', [
'account_id' => $account->id,
'new_transactions' => $created,
'date_from' => $dateFrom,
'date_to' => $dateTo,
]);
return $created;
}
/**
* Import a single transaction, skipping duplicates.
*/
private function importTransaction(Account $account, array $data): bool
{
$externalId = $data['transaction_id'] ?? $data['entry_reference'] ?? null;
if ($externalId) {
$exists = $account->transactions()
->where('external_transaction_id', $externalId)
->exists();
if ($exists) {
return false;
}
}
$amount = $this->parseAmount($data);
$description = $this->parseDescription($data);
$transactionDate = $this->parseDate($data);
$currency = $data['transaction_amount']['currency'] ?? $account->currency_code;
$account->transactions()->create([
'user_id' => $account->user_id,
'description' => $description,
'description_iv' => null,
'transaction_date' => $transactionDate,
'amount' => $amount,
'currency_code' => $currency,
'notes' => null,
'notes_iv' => null,
'source' => TransactionSource::EnableBanking,
'external_transaction_id' => $externalId,
'raw_data' => $data,
]);
return true;
}
/**
* Parse amount from EnableBanking transaction data.
* Returns amount in cents (bigint). Debits are negative.
*/
private function parseAmount(array $data): int
{
$rawAmount = $data['transaction_amount']['amount'] ?? '0';
$cents = (int) round(floatval($rawAmount) * 100);
$indicator = $data['credit_debit_indicator'] ?? null;
if ($indicator === 'DBIT') {
return -abs($cents);
}
return abs($cents);
}
/**
* Parse description from EnableBanking transaction data.
*/
private function parseDescription(array $data): string
{
$remittanceInfo = $data['remittance_information'] ?? [];
if (! empty($remittanceInfo)) {
return implode(' ', $remittanceInfo);
}
return $data['creditor']['name']
?? $data['debtor']['name']
?? 'Bank transaction';
}
/**
* Parse transaction date, preferring booking_date.
*/
private function parseDate(array $data): string
{
return $data['booking_date']
?? $data['transaction_date']
?? $data['value_date']
?? now()->toDateString();
}
/**
* Track the balance after transaction for each day.
* Overwrites so only the last transaction's balance per day is kept.
*
* @param array<string, int> $dailyBalances
*/
private function trackDailyBalance(array $transaction, array &$dailyBalances): void
{
$balanceAfter = $transaction['balance_after_transaction'] ?? null;
if (! $balanceAfter || ! isset($balanceAfter['amount'])) {
return;
}
$date = $this->parseDate($transaction);
$amount = (int) round(floatval($balanceAfter['amount']) * 100);
$dailyBalances[$date] = $amount;
}
/**
* Save tracked daily balances to the account.
*
* @param array<string, int> $dailyBalances
*/
private function saveDailyBalances(Account $account, array $dailyBalances): void
{
foreach ($dailyBalances as $date => $balance) {
$account->balances()->updateOrCreate(
['balance_date' => $date],
['balance' => $balance],
);
}
}
}

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\EnsureBudgetsFeature;
use App\Http\Middleware\EnsureOpenBankingFeature;
use App\Http\Middleware\EnsureUserIsSubscribed;
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
@ -42,6 +43,7 @@ return Application::configure(basePath: dirname(__DIR__))
'onboarded' => \App\Http\Middleware\EnsureOnboardingComplete::class,
'block-demo' => \App\Http\Middleware\BlockDemoAccountActions::class,
'budgets' => EnsureBudgetsFeature::class,
'open-banking' => EnsureOpenBankingFeature::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -10,6 +10,7 @@
"license": "MIT",
"require": {
"php": "^8.2",
"firebase/php-jwt": "^7.0",
"inertiajs/inertia-laravel": "^2.0",
"laravel/cashier": "^16.1",
"laravel/fortify": "^1.30",

67
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d01684feb019023ee08971e154374c89",
"content-hash": "98b80c7fbbd588b5674d2a769d135250",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -613,6 +613,69 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v7.0.2",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v7.0.2"
},
"time": "2025-12-16T22:17:28+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.4.0",
@ -12399,5 +12462,5 @@
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}

View File

@ -35,4 +35,10 @@ return [
],
],
'enablebanking' => [
'app_id' => env('ENABLEBANKING_APP_ID'),
'private_key_path' => env('ENABLEBANKING_PRIVATE_KEY_PATH'),
'redirect_url' => env('ENABLEBANKING_REDIRECT_URL'),
],
];

View File

@ -4,6 +4,7 @@ namespace Database\Factories;
use App\Enums\AccountType;
use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -29,4 +30,21 @@ class AccountFactory extends Factory
'type' => fake()->randomElement(AccountType::cases()),
];
}
public function connected(): static
{
return $this->state(fn (array $attributes) => [
'banking_connection_id' => BankingConnection::factory(),
'external_account_id' => fake()->uuid(),
]);
}
public function linked(): static
{
return $this->state(fn (array $attributes) => [
'banking_connection_id' => BankingConnection::factory(),
'external_account_id' => fake()->uuid(),
'linked_at' => now(),
]);
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace Database\Factories;
use App\Enums\BankingConnectionStatus;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BankingConnection>
*/
class BankingConnectionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'provider' => 'enablebanking',
'authorization_id' => fake()->uuid(),
'session_id' => fake()->uuid(),
'aspsp_name' => fake()->company(),
'aspsp_country' => fake()->randomElement(['ES', 'DE', 'FR', 'IT', 'NL']),
'status' => BankingConnectionStatus::Active,
'valid_until' => now()->addDays(90),
'last_synced_at' => now(),
'error_message' => null,
];
}
public function pending(): static
{
return $this->state(fn (array $attributes) => [
'status' => BankingConnectionStatus::Pending,
'session_id' => null,
'last_synced_at' => null,
]);
}
public function expired(): static
{
return $this->state(fn (array $attributes) => [
'status' => BankingConnectionStatus::Expired,
'valid_until' => now()->subDay(),
]);
}
public function revoked(): static
{
return $this->state(fn (array $attributes) => [
'status' => BankingConnectionStatus::Revoked,
]);
}
public function awaitingMapping(): static
{
return $this->state(fn (array $attributes) => [
'status' => BankingConnectionStatus::AwaitingMapping,
'last_synced_at' => null,
'pending_accounts_data' => [
[
'uid' => fake()->uuid(),
'currency' => 'EUR',
'name' => 'Test Account',
'account_id' => ['iban' => 'ES1234567890123456789012'],
],
],
]);
}
public function error(): static
{
return $this->state(fn (array $attributes) => [
'status' => BankingConnectionStatus::Error,
'error_message' => 'Connection failed: bank returned an error',
]);
}
}

View File

@ -42,6 +42,16 @@ class TransactionFactory extends Factory
]);
}
public function enableBanking(): static
{
return $this->state(fn (array $attributes) => [
'source' => TransactionSource::EnableBanking,
'external_transaction_id' => fake()->uuid(),
'description_iv' => null,
'notes_iv' => null,
]);
}
public function plaintext(): static
{
return $this->state(fn (array $attributes) => [

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('banking_connections', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->string('provider');
$table->string('authorization_id')->nullable();
$table->string('session_id')->nullable()->unique();
$table->string('aspsp_name');
$table->string('aspsp_country', 2);
$table->string('status');
$table->dateTime('valid_until')->nullable();
$table->dateTime('last_synced_at')->nullable();
$table->text('error_message')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('banking_connections');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->foreignUuid('banking_connection_id')->nullable()->after('type')->constrained()->nullOnDelete();
$table->string('external_account_id')->nullable()->after('banking_connection_id');
$table->index('banking_connection_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->dropForeign(['banking_connection_id']);
$table->dropColumn(['banking_connection_id', 'external_account_id']);
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->string('external_transaction_id')->nullable()->after('source');
$table->index(['account_id', 'external_transaction_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropIndex(['account_id', 'external_transaction_id']);
$table->dropColumn('external_transaction_id');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('banking_connections', function (Blueprint $table) {
$table->string('aspsp_logo')->nullable()->after('aspsp_country');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('banking_connections', function (Blueprint $table) {
$table->dropColumn('aspsp_logo');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->json('raw_data')->nullable()->after('external_transaction_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn('raw_data');
});
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('banking_connections', function (Blueprint $table) {
$table->json('pending_accounts_data')->nullable()->after('error_message');
});
}
public function down(): void
{
Schema::table('banking_connections', function (Blueprint $table) {
$table->dropColumn('pending_accounts_data');
});
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->timestamp('linked_at')->nullable()->after('external_account_id');
});
}
public function down(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->dropColumn('linked_at');
});
}
};

View File

@ -1,5 +1,6 @@
import { show } from '@/actions/App/Http/Controllers/AccountController';
import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
import { AmountTrendIndicator } from '@/components/dashboard/amount-trend-indicator';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent } from '@/components/ui/card';
@ -63,21 +64,12 @@ export function AccountListCard({
className="-my-1 -ml-1.5 flex items-center rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
>
<h3 className="flex items-center gap-2 font-semibold">
{account.bank?.logo ? (
<img
src={account.bank.logo}
alt={account.bank.name}
className="size-4 rounded-full object-contain"
/>
) : (
<div className="flex size-4 items-center justify-center rounded-full bg-muted">
<span className="text-sm font-medium text-muted-foreground">
{account.bank?.name?.charAt(
0,
) || '?'}
</span>
</div>
)}
<BankLogo
src={account.bank?.logo}
name={account.bank?.name}
className="size-4"
fallback="letter"
/>
<AccountName
account={account}
length={{ min: 8, max: 25 }}

View File

@ -1,4 +1,5 @@
import { index as indexBanks } from '@/actions/App/Http/Controllers/Settings/BankController';
import { BankLogo } from '@/components/bank-logo';
import { Button } from '@/components/ui/button';
import {
Command,
@ -110,15 +111,12 @@ export function BankCombobox({
>
{selectedBank ? (
<div className="flex items-center gap-2">
{selectedBank.logo ? (
<img
src={selectedBank.logo}
alt={selectedBank.name}
className="h-4 w-4 rounded object-contain"
/>
) : (
<div className="h-4 w-4 rounded bg-muted" />
)}
<BankLogo
src={selectedBank.logo}
name={selectedBank.name}
className="h-4 w-4"
fallback="empty"
/>
<span>{selectedBank.name}</span>
</div>
) : (
@ -154,15 +152,12 @@ export function BankCombobox({
onSelect={() => handleSelect(bank)}
>
<div className="flex items-center gap-2">
{bank.logo ? (
<img
src={bank.logo}
alt={bank.name}
className="h-4 w-4 rounded object-contain"
/>
) : (
<div className="h-4 w-4 rounded bg-muted" />
)}
<BankLogo
src={bank.logo}
name={bank.name}
className="h-4 w-4"
fallback="empty"
/>
<span>{bank.name}</span>
</div>
<Check

View File

@ -1,6 +1,8 @@
import { store } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { store as storeBank } from '@/actions/App/Http/Controllers/Settings/BankController';
import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog';
import { Button } from '@/components/ui/button';
import { CreateButton } from '@/components/ui/create-button';
import {
Dialog,
DialogContent,
@ -9,14 +11,25 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { SharedData } from '@/types';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { router, usePage } from '@inertiajs/react';
import { Link2, PenLine } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { AccountForm, AccountFormData } from './account-form';
type Mode = 'choice' | 'manual';
export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
const { features } = usePage<SharedData>().props;
const openBankingEnabled = features['open-banking'];
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<Mode>(
openBankingEnabled ? 'choice' : 'manual',
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
const formDataRef = useRef<AccountFormData>({
displayName: '',
bankId: null,
@ -29,6 +42,13 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
formDataRef.current = data;
}, []);
function handleOpenChange(newOpen: boolean) {
setOpen(newOpen);
if (!newOpen) {
setMode(openBankingEnabled ? 'choice' : 'manual');
}
}
async function createBankAndGetId(): Promise<string | null> {
const customBank = formDataRef.current.customBank;
if (!customBank) return null;
@ -113,7 +133,7 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
},
{
onSuccess: () => {
setOpen(false);
handleOpenChange(false);
onSuccess?.();
},
onFinish: () => {
@ -133,41 +153,105 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>{__('Create Account')}</Button>
</DialogTrigger>
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{__('Create Account')}</DialogTitle>
<DialogDescription>
{__(
'Add a new bank account to track your transactions.',
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-2">
<AccountForm onChange={handleFormChange} />
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<CreateButton>{__('Create Account')}</CreateButton>
</DialogTrigger>
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{__('Create Account')}</DialogTitle>
<DialogDescription>
{mode === 'choice'
? __('Choose how you want to add your account.')
: __(
'Add a new bank account to track your transactions.',
)}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{__('Cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting}
data-testid="submit-account"
>
{isSubmitting ? 'Creating...' : 'Create'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{mode === 'choice' && (
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center transition-colors hover:bg-accent"
onClick={() => setMode('manual')}
>
<PenLine className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">
{__('Manual')}
</p>
<p className="text-xs text-muted-foreground">
{__(
'Add an account and import transactions manually.',
)}
</p>
</div>
</button>
<button
type="button"
className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center transition-colors hover:bg-accent"
onClick={() => {
handleOpenChange(false);
setConnectDialogOpen(true);
}}
>
<Link2 className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">
{__('Connected')}
</p>
<p className="text-xs text-muted-foreground">
{__(
'Connect your bank and sync transactions automatically.',
)}
</p>
</div>
</button>
</div>
)}
{mode === 'manual' && (
<form onSubmit={handleSubmit} className="space-y-2">
<AccountForm onChange={handleFormChange} />
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => {
if (openBankingEnabled) {
setMode('choice');
} else {
handleOpenChange(false);
}
}}
disabled={isSubmitting}
>
{openBankingEnabled
? __('Back')
: __('Cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting}
data-testid="submit-account"
>
{isSubmitting
? __('Creating...')
: __('Create')}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
<ConnectAccountDialog
open={connectDialogOpen}
onOpenChange={setConnectDialogOpen}
/>
</>
);
}

View File

@ -1,11 +1,11 @@
import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { type Account } from '@/types/account';
import type { UUID } from '@/types/uuid';
import { __ } from '@/utils/i18n';
import { Building2 } from 'lucide-react';
interface ImportBalanceStepAccountProps {
accounts?: Account[];
@ -48,17 +48,12 @@ export function ImportBalanceStepAccount({
id={`account-${account.id}`}
/>
{account.bank.logo ? (
<img
src={account.bank.logo}
alt={account.bank.name}
className="h-10 w-10 rounded-md object-contain"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
)}
<BankLogo
src={account.bank.logo}
name={account.bank.name}
className="h-10 w-10"
fallback="icon"
/>
<div className="flex flex-1 flex-col gap-1">
<span className="font-medium">
<AccountName

View File

@ -3,6 +3,7 @@ import { RuleBuilder } from '@/components/automation-rules/rule-builder';
import { CategoryCombobox } from '@/components/shared/category-combobox';
import { LabelCombobox } from '@/components/shared/label-combobox';
import { Button } from '@/components/ui/button';
import { CreateButton } from '@/components/ui/create-button';
import {
Dialog,
DialogContent,
@ -125,7 +126,9 @@ export function CreateAutomationRuleDialog({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button disabled={disabled}>{__('Create Rule')}</Button>
<CreateButton disabled={disabled}>
{__('Create Rule')}
</CreateButton>
</DialogTrigger>
<DialogContent className="overflow-x-hidden sm:max-w-[600px]">
<DialogHeader>

View File

@ -0,0 +1,60 @@
import { cn } from '@/lib/utils';
import { Building2 } from 'lucide-react';
interface BankLogoProps {
src?: string | null;
name?: string;
className?: string;
fallback?: 'letter' | 'icon' | 'empty' | 'none';
}
export function BankLogo({
src,
name,
className,
fallback = 'none',
}: BankLogoProps) {
if (src) {
return (
<img
src={src}
alt={name || ''}
className={cn('rounded-full object-contain', className)}
/>
);
}
if (fallback === 'none') {
return null;
}
if (fallback === 'letter') {
return (
<div
className={cn(
'flex items-center justify-center rounded bg-muted',
className,
)}
>
<span className="font-medium text-muted-foreground">
{name?.charAt(0) || '?'}
</span>
</div>
);
}
if (fallback === 'icon') {
return (
<div
className={cn(
'flex items-center justify-center rounded bg-muted',
className,
)}
>
<Building2 className="size-1/2 text-muted-foreground" />
</div>
);
}
return <div className={cn('rounded bg-muted', className)} />;
}

View File

@ -2,6 +2,7 @@ import { store } from '@/actions/App/Http/Controllers/Settings/CategoryControlle
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CreateButton } from '@/components/ui/create-button';
import {
Dialog,
DialogContent,
@ -42,7 +43,7 @@ export function CreateCategoryDialog({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>{__('Create Category')}</Button>
<CreateButton>{__('Create Category')}</CreateButton>
</DialogTrigger>
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>

View File

@ -1,6 +1,7 @@
import { show } from '@/actions/App/Http/Controllers/AccountController';
import { AccountName } from '@/components/accounts/account-name';
import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog';
import { BankLogo } from '@/components/bank-logo';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
@ -48,13 +49,11 @@ export function AccountBalanceCard({
href={show.url(account.id)}
className="-my-1 -ml-1.5 flex items-center rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
>
{account.bank.logo && (
<img
src={account.bank.logo}
alt={account.bank.name}
className="mr-2 inline-block size-5 rounded-full object-contain"
/>
)}
<BankLogo
src={account.bank.logo}
name={account.bank.name}
className="mr-2 inline-block size-5"
/>
<AccountName
account={account}

View File

@ -1,6 +1,7 @@
import { store } from '@/actions/App/Http/Controllers/Settings/LabelController';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CreateButton } from '@/components/ui/create-button';
import {
Dialog,
DialogContent,
@ -29,7 +30,7 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>{__('Create Label')}</Button>
<CreateButton>{__('Create Label')}</CreateButton>
</DialogTrigger>
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>

View File

@ -0,0 +1,324 @@
import { BankLogo } from '@/components/bank-logo';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { EnableBankingInstitution } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { useCallback, useEffect, useState } from 'react';
const COUNTRIES = [
{ code: 'ES', name: 'Spain' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'IT', name: 'Italy' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'PT', name: 'Portugal' },
{ code: 'BE', name: 'Belgium' },
{ code: 'AT', name: 'Austria' },
{ code: 'FI', name: 'Finland' },
{ code: 'IE', name: 'Ireland' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'LV', name: 'Latvia' },
{ code: 'EE', name: 'Estonia' },
{ code: 'SE', name: 'Sweden' },
{ code: 'NO', name: 'Norway' },
{ code: 'DK', name: 'Denmark' },
{ code: 'PL', name: 'Poland' },
{ code: 'GB', name: 'United Kingdom' },
] as const;
interface ConnectAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
type Step = 'country' | 'bank' | 'confirm';
export function ConnectAccountDialog({
open,
onOpenChange,
}: ConnectAccountDialogProps) {
const [step, setStep] = useState<Step>('country');
const [country, setCountry] = useState<string>('');
const [institutions, setInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [filteredInstitutions, setFilteredInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedBank, setSelectedBank] =
useState<EnableBankingInstitution | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const resetState = useCallback(() => {
setStep('country');
setCountry('');
setInstitutions([]);
setFilteredInstitutions([]);
setSearchQuery('');
setSelectedBank(null);
setIsLoading(false);
setIsSubmitting(false);
setError(null);
}, []);
useEffect(() => {
if (!open) {
resetState();
}
}, [open, resetState]);
useEffect(() => {
if (searchQuery) {
setFilteredInstitutions(
institutions.filter((i) =>
i.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
);
} else {
setFilteredInstitutions(institutions);
}
}, [searchQuery, institutions]);
async function fetchInstitutions(countryCode: string) {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/open-banking/institutions?country=${countryCode}`,
{
headers: {
Accept: 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
),
},
},
);
if (!response.ok) {
throw new Error('Failed to fetch banks');
}
const data = await response.json();
setInstitutions(data);
setFilteredInstitutions(data);
setStep('bank');
} catch {
setError(__('Failed to load banks. Please try again.'));
} finally {
setIsLoading(false);
}
}
async function handleAuthorize() {
if (!selectedBank) return;
setIsSubmitting(true);
setError(null);
try {
const response = await fetch('/open-banking/authorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
),
},
body: JSON.stringify({
aspsp_name: selectedBank.name,
country: country,
logo: selectedBank.logo,
}),
});
if (!response.ok) {
throw new Error('Failed to start authorization');
}
const data = await response.json();
window.location.href = data.redirect_url;
} catch {
setError(__('Failed to connect to your bank. Please try again.'));
setIsSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{__('Connect Bank Account')}</DialogTitle>
<DialogDescription>
{step === 'country' &&
__(
'Select the country where your bank is located.',
)}
{step === 'bank' && __('Select your bank.')}
{step === 'confirm' &&
__(
'You will be redirected to your bank to authorize access.',
)}
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
{step === 'country' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>{__('Country')}</Label>
<Select value={country} onValueChange={setCountry}>
<SelectTrigger>
<SelectValue
placeholder={__('Select country')}
/>
</SelectTrigger>
<SelectContent>
{COUNTRIES.map((c) => (
<SelectItem key={c.code} value={c.code}>
{__(c.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{__('Cancel')}
</Button>
<Button
disabled={!country || isLoading}
onClick={() => fetchInstitutions(country)}
>
{isLoading ? __('Loading...') : __('Continue')}
</Button>
</div>
</div>
)}
{step === 'bank' && (
<div className="space-y-4">
<Input
placeholder={__('Search banks...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="max-h-[300px] space-y-1 overflow-y-auto">
{filteredInstitutions.map((institution) => (
<button
key={institution.name}
type="button"
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
selectedBank?.name === institution.name
? 'bg-accent'
: ''
}`}
onClick={() => setSelectedBank(institution)}
>
<BankLogo
src={institution.logo}
className="h-6 w-6"
/>
<span>{institution.name}</span>
</button>
))}
{filteredInstitutions.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
{__('No banks found.')}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setStep('country')}
>
{__('Back')}
</Button>
<Button
disabled={!selectedBank}
onClick={() => setStep('confirm')}
>
{__('Continue')}
</Button>
</div>
</div>
)}
{step === 'confirm' && selectedBank && (
<div className="space-y-4">
<div className="rounded-lg border p-4">
<div className="flex items-center gap-3">
<BankLogo
src={selectedBank.logo}
className="size-16 p-1"
/>
<div>
<p className="font-medium">
{selectedBank.name}
</p>
<p className="text-sm text-muted-foreground">
{__(
'You will be redirected to authorize access to your account data.',
)}
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setStep('bank')}
disabled={isSubmitting}
>
{__('Back')}
</Button>
<Button
onClick={handleAuthorize}
disabled={isSubmitting}
>
{isSubmitting
? __('Connecting...')
: __('Connect')}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,40 @@
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import type { BankingConnection } from '@/types/banking';
import { __ } from '@/utils/i18n';
const statusConfig: Record<
BankingConnection['status'],
{
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
}
> = {
active: { label: 'Active', variant: 'default' },
awaiting_mapping: { label: 'Setup Required', variant: 'secondary' },
pending: { label: 'Pending', variant: 'secondary' },
expired: { label: 'Expired', variant: 'outline' },
revoked: { label: 'Revoked', variant: 'outline' },
error: { label: 'Error', variant: 'destructive' },
};
export function ConnectionStatusBadge({
status,
lastSyncedAt,
}: {
status: BankingConnection['status'];
lastSyncedAt?: string | null;
}) {
if (status === 'active' && !lastSyncedAt) {
return (
<Badge variant="secondary" className="gap-1">
<Spinner className="size-3" />
{__('Syncing')}
</Badge>
);
}
const config = statusConfig[status];
return <Badge variant={config.variant}>{__(config.label)}</Badge>;
}

View File

@ -0,0 +1,170 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { BankingConnection } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { useState } from 'react';
interface DisconnectDialogProps {
connection: BankingConnection;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DisconnectDialog({
connection,
open,
onOpenChange,
}: DisconnectDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteAccounts, setDeleteAccounts] = useState<boolean | null>(null);
const [confirmation, setConfirmation] = useState('');
function handleDisconnect() {
setIsSubmitting(true);
router.delete(`/settings/connections/${connection.id}`, {
data: {
delete_accounts: deleteAccounts ?? false,
confirmation: deleteAccounts ? confirmation : null,
},
onFinish: () => {
setIsSubmitting(false);
onOpenChange(false);
},
});
}
function handleOpenChange(value: boolean) {
if (!value) {
setDeleteAccounts(null);
setConfirmation('');
}
onOpenChange(value);
}
const isConfirmed = deleteAccounts
? confirmation.toLowerCase() === 'delete all'
: deleteAccounts !== null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{__('Disconnect Bank')}</DialogTitle>
<DialogDescription>
{__(
'This will revoke access to your bank account data from :bank.',
{ bank: connection.aspsp_name },
)}
</DialogDescription>
</DialogHeader>
{connection.accounts_count > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium">
{__(
'This connection has :count associated account(s). What would you like to do with them?',
{
count: String(connection.accounts_count),
},
)}
</p>
<div className="space-y-2">
<button
type="button"
onClick={() => {
setDeleteAccounts(false);
setConfirmation('');
}}
className={`w-full rounded-md border p-3 text-left text-sm transition-colors ${
deleteAccounts === false
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/50'
}`}
>
<span className="font-medium">
{__('Keep accounts')}
</span>
<p className="mt-0.5 text-muted-foreground">
{__(
'Accounts will become manual. All transactions and balances will be preserved.',
)}
</p>
</button>
<button
type="button"
onClick={() => setDeleteAccounts(true)}
className={`w-full rounded-md border p-3 text-left text-sm transition-colors ${
deleteAccounts === true
? 'border-destructive bg-destructive/5'
: 'border-border hover:border-muted-foreground/50'
}`}
>
<span className="font-medium text-destructive">
{__('Delete accounts')}
</span>
<p className="mt-0.5 text-muted-foreground">
{__(
'All associated accounts, transactions and balances will be permanently deleted.',
)}
</p>
</button>
</div>
{deleteAccounts === true && (
<div className="space-y-2">
<Label htmlFor="confirmation">
{__('Type "delete all" to confirm:')}
</Label>
<Input
id="confirmation"
value={confirmation}
onChange={(e) =>
setConfirmation(e.target.value)
}
placeholder="delete all"
autoComplete="off"
/>
</div>
)}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
{__('Cancel')}
</Button>
<Button
variant="destructive"
onClick={handleDisconnect}
disabled={
isSubmitting ||
(connection.accounts_count > 0 && !isConfirmed)
}
>
{isSubmitting
? __('Disconnecting...')
: __('Disconnect')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -33,6 +33,7 @@ import { getStoredKey } from '@/lib/key-storage';
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
import { type SharedData } from '@/types';
import {
filterTransactionalAccounts,
type Account,
@ -41,7 +42,6 @@ import {
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import { type SharedData } from '@/types';
import { type DecryptedTransaction } from '@/types/transaction';
import { formatDate } from '@/utils/date';
import { __ } from '@/utils/i18n';

View File

@ -1,11 +1,11 @@
import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { filterTransactionalAccounts, type Account } from '@/types/account';
import type { UUID } from '@/types/uuid';
import { __ } from '@/utils/i18n';
import { Building2 } from 'lucide-react';
import { useEffect } from 'react';
interface ImportStepAccountProps {
@ -59,17 +59,12 @@ export function ImportStepAccount({
id={`account-${account.id}`}
/>
{account.bank.logo ? (
<img
src={account.bank.logo}
alt={account.bank.name}
className="h-10 w-10 rounded-md object-contain"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
)}
<BankLogo
src={account.bank.logo}
name={account.bank.name}
className="h-10 w-10"
fallback="icon"
/>
<div className="flex flex-1 flex-col gap-1">
<span className="font-medium">
<AccountName

View File

@ -5,6 +5,7 @@ import { getYear, parseISO } from 'date-fns';
import { ArrowDown, MoreHorizontal } from 'lucide-react';
import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
import { EncryptedText } from '@/components/encrypted-text';
import { LabelBadges } from '@/components/shared/label-combobox';
import { CategoryCell } from '@/components/transactions/category-cell';
@ -155,13 +156,11 @@ export function createTransactionColumns({
return (
<div className="flex items-center gap-2">
{transaction.bank?.logo && (
<img
src={transaction.bank.logo}
alt={transaction.bank.name}
className="h-5 w-5 rounded-full"
/>
)}
<BankLogo
src={transaction.bank?.logo}
name={transaction.bank?.name}
className="h-5 w-5"
/>
<AccountName
account={transaction.account}
length={{ min: 5, max: 15 }}

View File

@ -349,8 +349,7 @@ export function TransactionList({
if (!transaction.description_iv) {
decryptedDescription =
transaction.description;
decryptedNotes =
transaction.notes || null;
decryptedNotes = transaction.notes || null;
} else if (key) {
try {
decryptedDescription = await decrypt(

View File

@ -0,0 +1,18 @@
import { Plus } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
function CreateButton({
children,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button {...props}>
<Plus />
{children}
</Button>
);
}
export { CreateButton };

View File

@ -23,6 +23,7 @@ import { type PropsWithChildren } from 'react';
const getNavItems = (
subscriptionsEnabled: boolean,
isDemoAccount: boolean,
openBankingEnabled: boolean,
): (NavItem | NavSectionHeader | NavDivider)[] => [
{
type: 'nav-item',
@ -30,6 +31,16 @@ const getNavItems = (
href: accountsIndex(),
icon: null,
},
...(openBankingEnabled
? [
{
type: 'nav-item' as const,
title: 'Connections',
href: '/settings/connections',
icon: null,
},
]
: []),
{
type: 'nav-item',
title: 'Automation rules',
@ -93,8 +104,10 @@ const getNavItems = (
];
export default function SettingsLayout({ children }: PropsWithChildren) {
const { subscriptionsEnabled, auth } = usePage<SharedData>().props;
const { subscriptionsEnabled, auth, features } =
usePage<SharedData>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
const openBankingEnabled = features['open-banking'] ?? false;
// When server-side rendering, we only render the layout on the client...
if (typeof window === 'undefined') {
@ -102,7 +115,11 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
}
const currentPath = window.location.pathname;
const sidebarNavItems = getNavItems(subscriptionsEnabled, isDemoAccount);
const sidebarNavItems = getNavItems(
subscriptionsEnabled,
isDemoAccount,
openBankingEnabled,
);
return (
<div className="px-4 py-6">

View File

@ -6,6 +6,7 @@ import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog
import { EditAccountDialog } from '@/components/accounts/edit-account-dialog';
import { ImportBalancesDrawer } from '@/components/accounts/import-balances-drawer';
import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog';
import { BankLogo } from '@/components/bank-logo';
import HeadingSmall from '@/components/heading-small';
import { TransactionList } from '@/components/transactions/transaction-list';
import { Button } from '@/components/ui/button';
@ -76,19 +77,12 @@ export default function AccountShow({
<div className="space-y-6 p-6">
<div className="sm flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex items-center gap-4 pl-1">
{account.bank?.logo ? (
<img
src={account.bank.logo}
alt={account.bank.name}
className="size-12 rounded-full object-contain"
/>
) : (
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<span className="text-lg font-medium text-muted-foreground">
{account.bank?.name?.charAt(0) || '?'}
</span>
</div>
)}
<BankLogo
src={account.bank?.logo}
name={account.bank?.name}
className="size-12"
fallback="letter"
/>
<HeadingSmall
title={
<AccountName

View File

@ -0,0 +1,261 @@
import { BankLogo } from '@/components/bank-logo';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Account } from '@/types/account';
import type { BankingConnection, PendingBankAccount } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';
interface Mapping {
bank_account_uid: string;
action: 'create' | 'link' | 'skip';
existing_account_id: string | null;
}
interface Props {
connection: BankingConnection;
bankAccounts: PendingBankAccount[];
existingAccounts: Account[];
}
export default function MapAccountsPage({
connection,
bankAccounts,
existingAccounts,
}: Props) {
const [mappings, setMappings] = useState<Mapping[]>(
bankAccounts.map((ba) => ({
bank_account_uid: ba.uid,
action: 'create',
existing_account_id: null,
})),
);
const [processing, setProcessing] = useState(false);
function updateMapping(uid: string, updates: Partial<Mapping>) {
setMappings((prev) =>
prev.map((m) =>
m.bank_account_uid === uid ? { ...m, ...updates } : m,
),
);
}
function getCompatibleAccounts(currency: string) {
return existingAccounts.filter((a) => a.currency_code === currency);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setProcessing(true);
router.post(
`/open-banking/connections/${connection.id}/map-accounts`,
{ mappings },
{
onFinish: () => setProcessing(false),
},
);
}
return (
<div className="flex min-h-svh flex-col items-center justify-center bg-background px-4 py-8">
<Head title={__('Map Bank Accounts')} />
<div className="w-full max-w-2xl">
<div className="mb-6">
<h2 className="text-lg font-semibold">
{__('Map Bank Accounts')}
</h2>
<p className="text-sm text-muted-foreground">
{__(
'Choose how to handle each account from :bank. You can create new accounts, link to existing ones, or skip.',
{ bank: connection.aspsp_name },
)}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{bankAccounts.map((bankAccount) => {
const mapping = mappings.find(
(m) => m.bank_account_uid === bankAccount.uid,
);
const compatibleAccounts = getCompatibleAccounts(
bankAccount.currency,
);
const displayName =
bankAccount.name ||
bankAccount.account_id?.iban ||
__('Bank Account');
return (
<Card key={bankAccount.uid}>
<CardHeader className="pb-3">
<CardTitle className="text-base">
{displayName}
</CardTitle>
<CardDescription>
{bankAccount.currency}
{bankAccount.account_id?.iban &&
bankAccount.name &&
` \u00b7 ${bankAccount.account_id.iban}`}
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup
value={mapping?.action ?? 'create'}
onValueChange={(
value: Mapping['action'],
) =>
updateMapping(bankAccount.uid, {
action: value,
existing_account_id:
value === 'link'
? (mapping?.existing_account_id ??
null)
: null,
})
}
>
<div className="flex items-center gap-2">
<RadioGroupItem
value="create"
id={`${bankAccount.uid}-create`}
/>
<Label
htmlFor={`${bankAccount.uid}-create`}
>
{__('Create new account')}
</Label>
</div>
{compatibleAccounts.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<RadioGroupItem
value="link"
id={`${bankAccount.uid}-link`}
/>
<Label
htmlFor={`${bankAccount.uid}-link`}
>
{__(
'Link to existing account',
)}
</Label>
</div>
{mapping?.action === 'link' && (
<div className="ml-6">
<Select
value={
mapping.existing_account_id ??
undefined
}
onValueChange={(
value,
) =>
updateMapping(
bankAccount.uid,
{
existing_account_id:
value,
},
)
}
>
<SelectTrigger>
<SelectValue
placeholder={__(
'Select an account',
)}
/>
</SelectTrigger>
<SelectContent>
{compatibleAccounts.map(
(
account,
) => (
<SelectItem
key={
account.id
}
value={
account.id
}
>
<div className="flex items-center gap-2">
<BankLogo
src={
account
.bank
?.logo
}
name={
account
.bank
?.name
}
className="size-4"
fallback="letter"
/>
{
account.name
}
</div>
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
)}
</div>
)}
<div className="flex items-center gap-2">
<RadioGroupItem
value="skip"
id={`${bankAccount.uid}-skip`}
/>
<Label
htmlFor={`${bankAccount.uid}-skip`}
>
{__('Skip')}
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
);
})}
<div className="flex items-center justify-end gap-3">
<Link href="/settings/connections">
<Button type="button" variant="outline">
{__('Cancel')}
</Button>
</Link>
<Button type="submit" disabled={processing}>
{processing ? __('Saving...') : __('Save & Sync')}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -14,7 +14,7 @@ import {
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
import { ArrowUpDown, Link2, MoreHorizontal } from 'lucide-react';
import { useState } from 'react';
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
@ -22,6 +22,7 @@ import { AccountName } from '@/components/accounts/account-name';
import { CreateAccountDialog } from '@/components/accounts/create-account-dialog';
import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog';
import { EditAccountDialog } from '@/components/accounts/edit-account-dialog';
import { BankLogo } from '@/components/bank-logo';
import HeadingSmall from '@/components/heading-small';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -213,7 +214,7 @@ export default function Accounts({ accounts }: AccountsPageProps) {
},
cell: ({ row }) => {
return (
<div className="pl-3 font-medium">
<div className="flex items-center gap-2 pl-3 font-medium">
<AccountName
account={row.original}
length={{ min: 10, max: 20 }}
@ -229,15 +230,12 @@ export default function Accounts({ accounts }: AccountsPageProps) {
const bank = row.original.bank;
return (
<div className="flex items-center gap-2">
{bank.logo ? (
<img
src={bank.logo}
alt={bank.name}
className="bg-red h-6 w-6 rounded-full object-contain"
/>
) : (
<div className="h-6 w-6 rounded bg-muted" />
)}
<BankLogo
src={bank.logo}
name={bank.name}
className="h-6 w-6"
fallback="empty"
/>
<span>{bank.name}</span>
</div>
);
@ -247,10 +245,20 @@ export default function Accounts({ accounts }: AccountsPageProps) {
accessorKey: 'type',
header: () => __('Type'),
cell: ({ row }) => {
const isConnected = !!row.original.banking_connection_id;
return (
<Badge variant="outline">
{formatAccountType(row.getValue('type'))}
</Badge>
<div className="flex items-center gap-2">
<Badge variant="outline">
{formatAccountType(row.getValue('type'))}
</Badge>
{isConnected && (
<Link2
className="size-4 text-emerald-600 dark:text-emerald-400"
aria-label={__('Connected account')}
/>
)}
</div>
);
},
},

View File

@ -0,0 +1,244 @@
import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog';
import { ConnectionStatusBadge } from '@/components/open-banking/connection-status-badge';
import { DisconnectDialog } from '@/components/open-banking/disconnect-dialog';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { CreateButton } from '@/components/ui/create-button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
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 { BankingConnection } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { Head, router, usePage, usePoll } from '@inertiajs/react';
import { ArrowRight, MoreHorizontal, RefreshCw, Unplug } from 'lucide-react';
import { useEffect, useState } from 'react';
interface Props {
connections: BankingConnection[];
}
export default function ConnectionsPage({ connections }: Props) {
const { auth } = usePage<SharedData>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
const [disconnectConnection, setDisconnectConnection] =
useState<BankingConnection | null>(null);
const hasSyncing = connections.some(
(c) => c.status === 'active' && !c.last_synced_at,
);
const { start, stop } = usePoll(5000, {}, { autoStart: false });
useEffect(() => {
if (hasSyncing) {
start();
} else {
stop();
}
}, [hasSyncing, start, stop]);
function handleSync(connection: BankingConnection) {
router.post(`/settings/connections/${connection.id}/sync`);
}
function formatDate(dateString: string | null): string {
if (!dateString) return __('Never');
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
return (
<AppLayout>
<Head title={__('Connections')} />
<SettingsLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">
{__('Bank Connections')}
</h3>
<p className="text-sm text-muted-foreground">
{__(
'Manage your connected bank accounts for automatic transaction syncing.',
)}
</p>
</div>
<CreateButton
onClick={() => setConnectDialogOpen(true)}
disabled={isDemoAccount}
>
{__('Connect Bank')}
</CreateButton>
</div>
{connections.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground">
{__(
'No bank connections yet. Connect a bank to automatically sync your transactions.',
)}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{connections.map((connection) => (
<Card key={connection.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-base">
{connection.aspsp_name}
</CardTitle>
<CardDescription>
{connection.aspsp_country}{' '}
&middot;{' '}
{connection.accounts_count}{' '}
{connection.accounts_count === 1
? __('account')
: __('accounts')}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<ConnectionStatusBadge
status={connection.status}
lastSyncedAt={
connection.last_synced_at
}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{connection.status ===
'awaiting_mapping' && (
<DropdownMenuItem
onClick={() =>
router.visit(
`/open-banking/connections/${connection.id}/map-accounts`,
)
}
>
<ArrowRight className="mr-2 h-4 w-4" />
{__('Map Accounts')}
</DropdownMenuItem>
)}
{connection.status ===
'active' && (
<DropdownMenuItem
onClick={() =>
handleSync(
connection,
)
}
>
<RefreshCw className="mr-2 h-4 w-4" />
{__('Sync Now')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() =>
setDisconnectConnection(
connection,
)
}
className="text-destructive"
>
<Unplug className="mr-2 h-4 w-4" />
{__('Disconnect')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-6 text-sm text-muted-foreground">
{connection.status ===
'awaiting_mapping' ? (
<span>
{__(
'Accounts need to be mapped before syncing can begin.',
)}
</span>
) : connection.status ===
'active' &&
!connection.last_synced_at ? (
<span className="flex items-center gap-1.5">
<Spinner className="size-3" />
{__(
'Syncing transactions and balances…',
)}
</span>
) : (
<span>
{__('Last synced')}:{' '}
{formatDate(
connection.last_synced_at,
)}
</span>
)}
{connection.valid_until && (
<span>
{__('Expires')}:{' '}
{formatDate(
connection.valid_until,
)}
</span>
)}
</div>
{connection.error_message && (
<p className="mt-2 text-sm text-destructive">
{connection.error_message}
</p>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
<ConnectAccountDialog
open={connectDialogOpen}
onOpenChange={setConnectDialogOpen}
/>
{disconnectConnection && (
<DisconnectDialog
connection={disconnectConnection}
open={!!disconnectConnection}
onOpenChange={(open) => {
if (!open) setDisconnectConnection(null);
}}
/>
)}
</SettingsLayout>
</AppLayout>
);
}

View File

@ -43,6 +43,9 @@ export interface Account {
bank: Bank;
type: AccountType;
currency_code: CurrencyCode;
banking_connection_id: UUID | null;
external_account_id: string | null;
linked_at: string | null;
}
export interface AccountBalance {

View File

@ -0,0 +1,38 @@
import { UUID } from './uuid';
export interface BankingConnection {
id: UUID;
provider: string;
aspsp_name: string;
aspsp_country: string;
status:
| 'pending'
| 'awaiting_mapping'
| 'active'
| 'expired'
| 'revoked'
| 'error';
valid_until: string | null;
last_synced_at: string | null;
error_message: string | null;
accounts_count: number;
has_pending_accounts?: boolean;
created_at: string;
updated_at: string;
}
export interface PendingBankAccount {
uid: string;
currency: string;
name?: string;
account_id?: {
iban?: string;
};
}
export interface EnableBankingInstitution {
name: string;
country: string;
logo: string | null;
maximum_consent_validity: number | null;
}

View File

@ -42,6 +42,8 @@ export interface Features {
cashflow: boolean;
budgets: boolean;
'plaintext-transactions': boolean;
'open-banking': boolean;
'account-mapping': boolean;
}
export interface SharedData {

View File

@ -5,3 +5,4 @@ use Illuminate\Support\Facades\Schedule;
Schedule::command('demo:reset')->twiceDaily();
Schedule::command('horizon:snapshot')->everyFiveMinutes();
Schedule::command('budgets:generate-periods')->daily();
Schedule::command('banking:sync')->everySixHours();

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\OpenBanking\ConnectionController;
use App\Http\Controllers\Settings\AccountController;
use App\Http\Controllers\Settings\BankController;
use App\Http\Controllers\Settings\CategoryController;
@ -63,4 +64,11 @@ Route::middleware('auth')->group(function () {
Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show'])
->name('two-factor.show');
// Open Banking connections (feature-flagged)
Route::middleware('open-banking')->group(function () {
Route::get('settings/connections', [ConnectionController::class, 'index'])->name('settings.connections.index');
Route::post('settings/connections/{connection}/sync', [ConnectionController::class, 'sync'])->name('settings.connections.sync');
Route::delete('settings/connections/{connection}', [ConnectionController::class, 'destroy'])->name('settings.connections.destroy');
});
});

View File

@ -5,6 +5,9 @@ use App\Http\Controllers\BudgetController;
use App\Http\Controllers\CashflowController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\OnboardingController;
use App\Http\Controllers\OpenBanking\AccountMappingController;
use App\Http\Controllers\OpenBanking\AuthorizationController;
use App\Http\Controllers\OpenBanking\InstitutionController;
use App\Http\Controllers\RobotsController;
use App\Http\Controllers\SitemapController;
use App\Http\Controllers\SubscriptionController;
@ -61,6 +64,14 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi
Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy');
});
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed', 'open-banking'])->prefix('open-banking')->group(function () {
Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions');
Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize');
Route::get('callback', [AuthorizationController::class, 'callback'])->name('open-banking.callback');
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::middleware(['auth', 'verified', 'onboarded', 'subscribed', 'budgets'])->group(function () {
Route::get('budgets', [BudgetController::class, 'index'])->name('budgets.index');
Route::post('budgets', [BudgetController::class, 'store'])->name('budgets.store');

View File

@ -0,0 +1,312 @@
<?php
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Account;
use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
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',
]);
});
test('show returns mapping page with correct props', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
]);
$response = $this->actingAs($user)
->get(route('open-banking.map-accounts', $connection));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('open-banking/map-accounts')
->has('connection')
->has('bankAccounts')
->has('existingAccounts')
);
});
test('show redirects if no pending accounts', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'pending_accounts_data' => null,
]);
$response = $this->actingAs($user)
->get(route('open-banking.map-accounts', $connection));
$response->assertRedirect(route('settings.connections.index'));
});
test('show returns 403 for other user\'s connection', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $otherUser->id,
]);
$response = $this->actingAs($user)
->get(route('open-banking.map-accounts', $connection));
$response->assertForbidden();
});
test('store with action create creates new accounts', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Test Checking',
'account_id' => ['iban' => 'ES1234567890'],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'create',
'existing_account_id' => null,
],
],
]);
$response->assertRedirect(route('settings.connections.index'));
$this->assertDatabaseHas('accounts', [
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-1',
'name' => 'Test Checking',
'currency_code' => 'EUR',
]);
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
expect($connection->pending_accounts_data)->toBeNull();
Queue::assertPushed(SyncBankingConnectionJob::class);
});
test('store with action link links existing account', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$bank = Bank::factory()->create();
$existingAccount = Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'currency_code' => 'EUR',
'banking_connection_id' => null,
]);
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Bank Account',
'account_id' => ['iban' => 'ES1234567890'],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'link',
'existing_account_id' => $existingAccount->id,
],
],
]);
$response->assertRedirect(route('settings.connections.index'));
$existingAccount->refresh();
expect($existingAccount->banking_connection_id)->toBe($connection->id);
expect($existingAccount->external_account_id)->toBe('ext-1');
expect($existingAccount->linked_at)->not->toBeNull();
Queue::assertPushed(SyncBankingConnectionJob::class);
});
test('store with action skip does nothing', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Skipped Account',
'account_id' => [],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'skip',
'existing_account_id' => null,
],
],
]);
$response->assertRedirect(route('settings.connections.index'));
$this->assertDatabaseMissing('accounts', [
'banking_connection_id' => $connection->id,
]);
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
});
test('store with mixed actions works correctly', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$bank = Bank::factory()->create();
$existingAccount = Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'currency_code' => 'EUR',
'banking_connection_id' => null,
]);
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Account to Create',
'account_id' => [],
],
[
'uid' => 'ext-2',
'currency' => 'EUR',
'name' => 'Account to Link',
'account_id' => [],
],
[
'uid' => 'ext-3',
'currency' => 'EUR',
'name' => 'Account to Skip',
'account_id' => [],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'create',
'existing_account_id' => null,
],
[
'bank_account_uid' => 'ext-2',
'action' => 'link',
'existing_account_id' => $existingAccount->id,
],
[
'bank_account_uid' => 'ext-3',
'action' => 'skip',
'existing_account_id' => null,
],
],
]);
$response->assertRedirect(route('settings.connections.index'));
// Created account exists
$this->assertDatabaseHas('accounts', [
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-1',
]);
// Linked account is updated
$existingAccount->refresh();
expect($existingAccount->external_account_id)->toBe('ext-2');
expect($existingAccount->linked_at)->not->toBeNull();
// Skipped account was not created
$this->assertDatabaseMissing('accounts', [
'external_account_id' => 'ext-3',
'banking_connection_id' => $connection->id,
]);
});
test('validation fails when linking without existing_account_id', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id,
'pending_accounts_data' => [
[
'uid' => 'ext-1',
'currency' => 'EUR',
'name' => 'Test',
'account_id' => [],
],
],
]);
$response = $this->actingAs($user)
->post(route('open-banking.map-accounts.store', $connection), [
'mappings' => [
[
'bank_account_uid' => 'ext-1',
'action' => 'link',
'existing_account_id' => null,
],
],
]);
$response->assertSessionHasErrors('mappings.0.existing_account_id');
});

View File

@ -0,0 +1,181 @@
<?php
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
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',
]);
});
test('users can start bank authorization', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('startAuthorization')
->once()
->andReturn([
'url' => 'https://bank.example.com/authorize',
'authorization_id' => 'auth-123',
]);
$this->app->instance(BankingProviderInterface::class, $mockProvider);
$response = $this->actingAs($user)->postJson('/open-banking/authorize', [
'aspsp_name' => 'Test Bank',
'country' => 'ES',
]);
$response->assertOk();
$response->assertJsonStructure(['redirect_url', 'connection_id']);
$this->assertDatabaseHas('banking_connections', [
'user_id' => $user->id,
'provider' => 'enablebanking',
'aspsp_name' => 'Test Bank',
'aspsp_country' => 'ES',
'status' => BankingConnectionStatus::Pending->value,
]);
});
test('authorization requires aspsp_name and country', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->postJson('/open-banking/authorize', []);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['aspsp_name', 'country']);
});
test('callback with error redirects with error message', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)
->get('/open-banking/callback?error=access_denied&error_description=User+denied+access');
$response->assertRedirect(route('settings.connections.index'));
$response->assertSessionHas('error');
});
test('callback without code redirects with error', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->get('/open-banking/callback');
$response->assertRedirect(route('settings.connections.index'));
$response->assertSessionHas('error');
});
test('callback with valid code creates accounts directly when account-mapping is disabled', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
'aspsp_country' => 'ES',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('createSession')
->with('test-code')
->once()
->andReturn([
'session_id' => 'session-123',
'accounts' => [
[
'uid' => 'ext-account-1',
'currency' => 'EUR',
'name' => 'My Checking Account',
'account_id' => ['iban' => 'ES1234567890123456789012'],
],
],
'aspsp' => ['name' => 'Test Bank', 'country' => 'ES'],
'access' => ['valid_until' => now()->addDays(90)->toIso8601String()],
]);
$this->app->instance(BankingProviderInterface::class, $mockProvider);
$response = $this->actingAs($user)->get('/open-banking/callback?code=test-code');
$response->assertRedirect(route('settings.connections.index'));
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
expect($connection->session_id)->toBe('session-123');
$this->assertDatabaseHas('accounts', [
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-account-1',
'encrypted' => false,
]);
Queue::assertPushed(SyncBankingConnectionJob::class);
});
test('callback with valid code stores pending accounts and redirects to mapping when account-mapping is enabled', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Feature::for($user)->activate('account-mapping');
$connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
'aspsp_country' => 'ES',
]);
$accounts = [
[
'uid' => 'ext-account-1',
'currency' => 'EUR',
'name' => 'My Checking Account',
'account_id' => ['iban' => 'ES1234567890123456789012'],
],
];
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('createSession')
->with('test-code')
->once()
->andReturn([
'session_id' => 'session-123',
'accounts' => $accounts,
'aspsp' => ['name' => 'Test Bank', 'country' => 'ES'],
'access' => ['valid_until' => now()->addDays(90)->toIso8601String()],
]);
$this->app->instance(BankingProviderInterface::class, $mockProvider);
$response = $this->actingAs($user)->get('/open-banking/callback?code=test-code');
$response->assertRedirect(route('open-banking.map-accounts', $connection));
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping);
expect($connection->session_id)->toBe('session-123');
expect($connection->pending_accounts_data)->toEqual($accounts);
$this->assertDatabaseMissing('accounts', [
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
]);
Queue::assertNothingPushed();
});

View File

@ -0,0 +1,183 @@
<?php
use App\Contracts\BankingProviderInterface;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\BankingConnection;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Banking\BalanceSyncService;
test('calculateHistoricalBalances derives balances from transactions', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
// Reference balance: end of Feb 10, balance = 100000 (€1,000.00)
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => '2026-02-10',
'balance' => 100000,
]);
// Transactions: Feb 10 had -5000 (debit), Feb 8 had +20000 (credit), Feb 5 had -10000 (debit)
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'transaction_date' => '2026-02-10',
'amount' => -5000,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'transaction_date' => '2026-02-08',
'amount' => 20000,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'transaction_date' => '2026-02-05',
'amount' => -10000,
]);
$service = new BalanceSyncService(Mockery::mock(BankingProviderInterface::class));
$service->calculateHistoricalBalances($account);
// End of Feb 10: 100000 (reference)
// End of Feb 8: 100000 - (-5000) = 105000 (before Feb 10 transactions)
// End of Feb 5: 105000 - 20000 = 85000 (before Feb 8 transactions)
expect($account->balances()->count())->toBe(3);
$feb8 = $account->balances()->where('balance_date', '2026-02-08')->first();
expect($feb8->balance)->toBe(105000);
$feb5 = $account->balances()->where('balance_date', '2026-02-05')->first();
expect($feb5->balance)->toBe(85000);
});
test('calculateHistoricalBalances handles multiple transactions per day', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => '2026-02-10',
'balance' => 100000,
]);
// Two transactions on Feb 8: -3000 and -7000 = total -10000
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'transaction_date' => '2026-02-08',
'amount' => -3000,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'transaction_date' => '2026-02-08',
'amount' => -7000,
]);
$service = new BalanceSyncService(Mockery::mock(BankingProviderInterface::class));
$service->calculateHistoricalBalances($account);
// End of Feb 8: 100000 (no transactions between Feb 8 and Feb 10 on the reference date)
// Wait - there are no transactions on Feb 10, so running_balance stays 100000
// End of Feb 8: 100000
expect($account->balances()->count())->toBe(2);
$feb8 = $account->balances()->where('balance_date', '2026-02-08')->first();
expect($feb8->balance)->toBe(100000);
});
test('calculateHistoricalBalances skips dates with existing balances', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => '2026-02-10',
'balance' => 100000,
]);
// Existing balance from balance_after_transaction (more accurate)
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => '2026-02-05',
'balance' => 77777,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'transaction_date' => '2026-02-08',
'amount' => 20000,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'transaction_date' => '2026-02-05',
'amount' => -10000,
]);
$service = new BalanceSyncService(Mockery::mock(BankingProviderInterface::class));
$service->calculateHistoricalBalances($account);
// Feb 8 should be calculated, Feb 5 should NOT be overwritten
expect($account->balances()->count())->toBe(3);
$feb5 = $account->balances()->where('balance_date', '2026-02-05')->first();
expect($feb5->balance)->toBe(77777); // Preserved original value
});
test('calculateHistoricalBalances does nothing without reference balance', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create([
'user_id' => $user->id,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'transaction_date' => '2026-02-08',
'amount' => -5000,
]);
$service = new BalanceSyncService(Mockery::mock(BankingProviderInterface::class));
$service->calculateHistoricalBalances($account);
expect($account->balances()->count())->toBe(0);
});
test('calculateHistoricalBalances does nothing without transactions', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create([
'user_id' => $user->id,
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => '2026-02-10',
'balance' => 100000,
]);
$service = new BalanceSyncService(Mockery::mock(BankingProviderInterface::class));
$service->calculateHistoricalBalances($account);
expect($account->balances()->count())->toBe(1);
});

View File

@ -0,0 +1,192 @@
<?php
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\BankingConnection;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
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',
]);
});
test('users can view their connections page', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get('/settings/connections');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/connections')
->has('connections', 1)
);
});
test('connections page only shows own connections', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->create(['user_id' => $user->id]);
BankingConnection::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->get('/settings/connections');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('connections', 1)
);
});
test('users can disconnect a banking connection and keep accounts as manual', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
$balance = AccountBalance::factory()->create([
'account_id' => $account->id,
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('revokeSession')->once();
$this->app->instance(BankingProviderInterface::class, $mockProvider);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
'delete_accounts' => false,
'confirmation' => null,
]);
$response->assertRedirect(route('settings.connections.index'));
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Revoked);
expect($connection->trashed())->toBeTrue();
$account->refresh();
expect($account->banking_connection_id)->toBeNull();
expect($account->external_account_id)->toBeNull();
expect($account->trashed())->toBeFalse();
expect(Transaction::find($transaction->id))->not->toBeNull();
expect(AccountBalance::find($balance->id))->not->toBeNull();
});
test('users can disconnect a banking connection and delete accounts', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'account_id' => $account->id,
]);
$balance = AccountBalance::factory()->create([
'account_id' => $account->id,
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('revokeSession')->once();
$this->app->instance(BankingProviderInterface::class, $mockProvider);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
'delete_accounts' => true,
'confirmation' => 'delete all',
]);
$response->assertRedirect(route('settings.connections.index'));
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Revoked);
expect($connection->trashed())->toBeTrue();
expect(Account::withTrashed()->find($account->id)->trashed())->toBeTrue();
expect(Transaction::withTrashed()->find($transaction->id)->trashed())->toBeTrue();
expect(AccountBalance::find($balance->id))->toBeNull();
});
test('deleting accounts requires confirmation text', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
'delete_accounts' => true,
'confirmation' => 'wrong text',
]);
$response->assertSessionHasErrors('confirmation');
expect($connection->fresh()->trashed())->toBeFalse();
});
test('users cannot disconnect another users connection', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
'delete_accounts' => false,
]);
$response->assertForbidden();
});
test('users can trigger manual sync on active connection', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'status' => BankingConnectionStatus::Active,
]);
$response = $this->actingAs($user)->post("/settings/connections/{$connection->id}/sync");
$response->assertRedirect();
$response->assertSessionHas('success');
});
test('users cannot sync expired connection', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->expired()->create([
'user_id' => $user->id,
]);
$response = $this->actingAs($user)->post("/settings/connections/{$connection->id}/sync");
$response->assertRedirect();
$response->assertSessionHas('error');
});

View File

@ -0,0 +1,51 @@
<?php
use App\Contracts\BankingProviderInterface;
use App\Models\User;
use Laravel\Pennant\Feature;
test('authenticated users with feature flag can list institutions', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getInstitutions')
->with('ES')
->once()
->andReturn([
['name' => 'Test Bank', 'country' => 'ES', 'logo' => 'https://example.com/logo.png', 'maximum_consent_validity' => 90],
['name' => 'Another Bank', 'country' => 'ES', 'logo' => null, 'maximum_consent_validity' => 180],
]);
$this->app->instance(BankingProviderInterface::class, $mockProvider);
$response = $this->actingAs($user)->getJson('/open-banking/institutions?country=ES');
$response->assertOk();
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'Test Bank']);
});
test('institutions endpoint requires country parameter', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->getJson('/open-banking/institutions');
$response->assertUnprocessable();
});
test('institutions endpoint requires valid country code length', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->getJson('/open-banking/institutions?country=SPAIN');
$response->assertUnprocessable();
});
test('guests cannot access institutions endpoint', function () {
$response = $this->getJson('/open-banking/institutions?country=ES');
$response->assertUnauthorized();
});

View File

@ -0,0 +1,82 @@
<?php
use App\Models\User;
use Laravel\Pennant\Feature;
test('users without open-banking feature get 404 on institutions', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->deactivate('open-banking');
$response = $this->actingAs($user)->get('/open-banking/institutions?country=ES');
$response->assertNotFound();
});
test('users without open-banking feature get 404 on authorize', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->deactivate('open-banking');
$response = $this->actingAs($user)->post('/open-banking/authorize', [
'aspsp_name' => 'Test Bank',
'country' => 'ES',
]);
$response->assertNotFound();
});
test('users without open-banking feature get 404 on callback', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->deactivate('open-banking');
$response = $this->actingAs($user)->get('/open-banking/callback?code=test');
$response->assertNotFound();
});
test('users without open-banking feature get 404 on connections index', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->deactivate('open-banking');
$response = $this->actingAs($user)->get('/settings/connections');
$response->assertNotFound();
});
test('open-banking feature flag is shared with frontend when enabled', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('features.open-banking', true)
);
});
test('open-banking feature flag is shared with frontend when disabled', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->deactivate('open-banking');
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('features.open-banking', false)
);
});
test('guests see open-banking feature as false', function () {
$response = $this->get('/');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('features.open-banking', false)
);
});

View File

@ -0,0 +1,120 @@
<?php
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Banking\BalanceSyncService;
use App\Services\Banking\TransactionSyncService;
test('first sync calculates historical balances', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'last_synced_at' => null,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$transactionSync = Mockery::mock(TransactionSyncService::class);
$transactionSync->shouldReceive('sync')->once();
$balanceSync = Mockery::mock(BalanceSyncService::class);
$balanceSync->shouldReceive('sync')->once();
$balanceSync->shouldReceive('calculateHistoricalBalances')->once();
$job = new SyncBankingConnectionJob($connection);
$job->handle($transactionSync, $balanceSync);
});
test('subsequent syncs do not calculate historical balances', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'last_synced_at' => now()->subDay(),
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$transactionSync = Mockery::mock(TransactionSyncService::class);
$transactionSync->shouldReceive('sync')->once();
$balanceSync = Mockery::mock(BalanceSyncService::class);
$balanceSync->shouldReceive('sync')->once();
$balanceSync->shouldNotReceive('calculateHistoricalBalances');
$job = new SyncBankingConnectionJob($connection);
$job->handle($transactionSync, $balanceSync);
});
test('linked accounts sync from last transaction date and skip historical balances', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'last_synced_at' => null,
]);
$account = Account::factory()->linked()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'transaction_date' => '2025-12-15',
]);
$transactionSync = Mockery::mock(TransactionSyncService::class);
$transactionSync->shouldReceive('sync')
->once()
->withArgs(function ($acct, $dateFrom, $dateTo, $strategy) {
return $dateFrom === '2025-12-15';
});
$balanceSync = Mockery::mock(BalanceSyncService::class);
$balanceSync->shouldReceive('sync')->once();
$balanceSync->shouldNotReceive('calculateHistoricalBalances');
$job = new SyncBankingConnectionJob($connection);
$job->handle($transactionSync, $balanceSync);
});
test('mixed linked and new accounts in same connection', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'last_synced_at' => null,
]);
$newAccount = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-new',
]);
$linkedAccount = Account::factory()->linked()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-linked',
]);
$transactionSync = Mockery::mock(TransactionSyncService::class);
$transactionSync->shouldReceive('sync')->twice();
$balanceSync = Mockery::mock(BalanceSyncService::class);
$balanceSync->shouldReceive('sync')->twice();
$balanceSync->shouldReceive('calculateHistoricalBalances')
->once()
->with(Mockery::on(fn ($acct) => $acct->id === $newAccount->id));
$job = new SyncBankingConnectionJob($connection);
$job->handle($transactionSync, $balanceSync);
});

View File

@ -0,0 +1,295 @@
<?php
use App\Contracts\BankingProviderInterface;
use App\Enums\TransactionSource;
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Banking\TransactionSyncService;
test('sync creates transactions from provider data', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->with('ext-123', '2025-01-01', '2025-01-31', null, null)
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Grocery Store Purchase'],
],
[
'transaction_id' => 'txn-002',
'transaction_amount' => ['amount' => '1000.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'CRDT',
'booking_date' => '2025-01-20',
'remittance_information' => ['Salary Payment'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
expect($created)->toBe(2);
expect($account->transactions()->count())->toBe(2);
$debit = $account->transactions()->where('external_transaction_id', 'txn-001')->first();
expect($debit->amount)->toBe(-5000);
expect($debit->description)->toBe('Grocery Store Purchase');
expect($debit->source)->toBe(TransactionSource::EnableBanking);
expect($debit->description_iv)->toBeNull();
expect($debit->raw_data)->toEqual([
'booking_date' => '2025-01-15',
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'remittance_information' => ['Grocery Store Purchase'],
]);
$credit = $account->transactions()->where('external_transaction_id', 'txn-002')->first();
expect($credit->amount)->toBe(100000);
expect($credit->description)->toBe('Salary Payment');
});
test('sync deduplicates transactions by external_transaction_id', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'external_transaction_id' => 'txn-001',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Duplicate Transaction'],
],
[
'transaction_id' => 'txn-003',
'transaction_amount' => ['amount' => '25.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-16',
'remittance_information' => ['New Transaction'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
expect($created)->toBe(1);
expect($account->transactions()->count())->toBe(2);
});
test('sync handles pagination with continuation key', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->with('ext-123', '2025-01-01', '2025-01-31', null, null)
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '10.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-01',
'remittance_information' => ['Page 1'],
],
],
'continuation_key' => 'page2',
]);
$mockProvider->shouldReceive('getTransactions')
->with('ext-123', '2025-01-01', '2025-01-31', 'page2', null)
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-002',
'transaction_amount' => ['amount' => '20.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'CRDT',
'booking_date' => '2025-01-02',
'remittance_information' => ['Page 2'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
expect($created)->toBe(2);
expect($account->transactions()->count())->toBe(2);
});
test('sync uses creditor name as fallback description', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '99.99', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => [],
'creditor' => ['name' => 'Amazon EU'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider);
$service->sync($account, '2025-01-01', '2025-01-31');
$transaction = $account->transactions()->first();
expect($transaction->description)->toBe('Amazon EU');
});
test('sync creates daily balances from balance_after_transaction', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Morning purchase'],
'balance_after_transaction' => ['amount' => '950.00', 'currency' => 'EUR'],
],
[
'transaction_id' => 'txn-002',
'transaction_amount' => ['amount' => '30.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Evening purchase'],
'balance_after_transaction' => ['amount' => '920.00', 'currency' => 'EUR'],
],
[
'transaction_id' => 'txn-003',
'transaction_amount' => ['amount' => '1000.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'CRDT',
'booking_date' => '2025-01-20',
'remittance_information' => ['Salary'],
'balance_after_transaction' => ['amount' => '1920.00', 'currency' => 'EUR'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider);
$service->sync($account, '2025-01-01', '2025-01-31');
expect($account->balances()->count())->toBe(2);
$jan15 = $account->balances()->where('balance_date', '2025-01-15')->first();
expect($jan15->balance)->toBe(92000); // Last transaction of the day
$jan20 = $account->balances()->where('balance_date', '2025-01-20')->first();
expect($jan20->balance)->toBe(192000);
});
test('sync skips daily balance when balance_after_transaction is missing', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getTransactions')
->once()
->andReturn([
'transactions' => [
[
'transaction_id' => 'txn-001',
'transaction_amount' => ['amount' => '50.00', 'currency' => 'EUR'],
'credit_debit_indicator' => 'DBIT',
'booking_date' => '2025-01-15',
'remittance_information' => ['Purchase'],
],
],
'continuation_key' => null,
]);
$service = new TransactionSyncService($mockProvider);
$service->sync($account, '2025-01-01', '2025-01-31');
expect($account->balances()->count())->toBe(0);
});
test('sync skips accounts without external_account_id', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create([
'user_id' => $user->id,
'external_account_id' => null,
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldNotReceive('getTransactions');
$service = new TransactionSyncService($mockProvider);
$created = $service->sync($account, '2025-01-01', '2025-01-31');
expect($created)->toBe(0);
});