feat(open-banking): remove feature flag gating (#297)

## Summary
- remove the Pennant-based `open-banking` flag and middleware gating so
open banking is always available for authenticated users
- simplify landing, onboarding, settings, and subscription flows to use
always-on open-banking behavior and remove stale frontend/shared flag
plumbing
- update open-banking tests and purge stored `open-banking` Pennant rows

## Testing
- `php artisan test --compact
tests/Feature/OpenBanking/InstitutionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BinanceControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/IndexaCapitalControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BitpandaControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/ConnectionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AccountMappingTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php`
- `php artisan test --compact tests/Feature/SubscriptionTest.php`
- `php artisan test --compact
tests/Feature/WelcomeBanksOrderingTest.php`
- `vendor/bin/pint --dirty --format agent`

## Notes
- `php artisan pennant:purge open-banking` was run to remove stale
stored values
- `php artisan test --compact tests/Browser/OnboardingFlowTest.php`
still has one unrelated real-estate onboarding browser failure (`it
creates a real estate account during onboarding when feature is
enabled`)
This commit is contained in:
Víctor Falcón 2026-04-17 09:20:05 +01:00 committed by GitHub
parent 63e472e8a4
commit 244344e953
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 635 additions and 835 deletions

View File

@ -49,6 +49,6 @@ trait ResolvesFeatures
private function getStringBasedFeatures(): array private function getStringBasedFeatures(): array
{ {
return ['open-banking', 'real-estate']; return ['real-estate'];
} }
} }

View File

@ -6,6 +6,7 @@ use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus; use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending; use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\StartAuthorizationRequest; use App\Http\Requests\OpenBanking\StartAuthorizationRequest;
use App\Jobs\SyncBankingConnectionJob; use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection; use App\Models\BankingConnection;
@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Log;
class AuthorizationController extends Controller class AuthorizationController extends Controller
{ {
use CreatesAccountsFromPending; use CreatesAccountsFromPending;
use HandlesSubscriptionGate;
/** /**
* Start the bank authorization flow. * Start the bank authorization flow.
@ -25,8 +27,8 @@ class AuthorizationController extends Controller
{ {
$user = auth()->user(); $user = auth()->user();
if (config('subscriptions.enabled') && ! $user->hasProPlan()) { if ($this->shouldBlockOpenBankingAccess($user)) {
return response()->json(['redirect' => route('subscribe')], 402); return $this->subscribeJsonResponse();
} }
$validated = $request->validated(); $validated = $request->validated();
@ -63,6 +65,10 @@ class AuthorizationController extends Controller
abort(403); abort(403);
} }
if ($this->shouldBlockOpenBankingAccess($request->user())) {
return $this->subscribeJsonResponse();
}
if (! $connection->isEnableBanking()) { if (! $connection->isEnableBanking()) {
return response()->json(['error' => 'Only EnableBanking connections can be re-authorized.'], 422); return response()->json(['error' => 'Only EnableBanking connections can be re-authorized.'], 422);
} }

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus; use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending; use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\ConnectBinanceRequest; use App\Http\Requests\OpenBanking\ConnectBinanceRequest;
use App\Jobs\SyncBankingConnectionJob; use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank; use App\Models\Bank;
@ -15,6 +16,7 @@ use Illuminate\Support\Facades\Log;
class BinanceController extends Controller class BinanceController extends Controller
{ {
use CreatesAccountsFromPending; use CreatesAccountsFromPending;
use HandlesSubscriptionGate;
/** /**
* Validate Binance API credentials and create a connection. * Validate Binance API credentials and create a connection.
@ -24,6 +26,10 @@ class BinanceController extends Controller
$validated = $request->validated(); $validated = $request->validated();
$user = auth()->user(); $user = auth()->user();
if ($this->shouldBlockOpenBankingAccess($user)) {
return $this->subscribeJsonResponse();
}
$client = new BinanceClient($validated['api_key'], $validated['api_secret']); $client = new BinanceClient($validated['api_key'], $validated['api_secret']);
try { try {

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus; use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending; use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\ConnectBitpandaRequest; use App\Http\Requests\OpenBanking\ConnectBitpandaRequest;
use App\Jobs\SyncBankingConnectionJob; use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank; use App\Models\Bank;
@ -15,6 +16,7 @@ use Illuminate\Support\Facades\Log;
class BitpandaController extends Controller class BitpandaController extends Controller
{ {
use CreatesAccountsFromPending; use CreatesAccountsFromPending;
use HandlesSubscriptionGate;
/** /**
* Validate Bitpanda API key and create a connection. * Validate Bitpanda API key and create a connection.
@ -24,6 +26,10 @@ class BitpandaController extends Controller
$validated = $request->validated(); $validated = $request->validated();
$user = auth()->user(); $user = auth()->user();
if ($this->shouldBlockOpenBankingAccess($user)) {
return $this->subscribeJsonResponse();
}
$client = new BitpandaClient($validated['api_key']); $client = new BitpandaClient($validated['api_key']);
try { try {

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\OpenBanking\Concerns;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
trait HandlesSubscriptionGate
{
private function shouldBlockOpenBankingAccess(User $user, bool $allowDuringOnboarding = true): bool
{
if (! config('subscriptions.enabled')) {
return false;
}
if ($allowDuringOnboarding && ! $user->isOnboarded()) {
return false;
}
return ! $user->hasProPlan();
}
private function subscribeJsonResponse(): JsonResponse
{
return response()->json(['redirect' => route('subscribe')], 402);
}
private function subscribeRedirectResponse(): RedirectResponse
{
return redirect()->route('subscribe');
}
}

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\OpenBanking;
use App\Actions\OpenBanking\DisconnectBankingConnection; use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Enums\BankingConnectionStatus; use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\DestroyConnectionRequest; use App\Http\Requests\OpenBanking\DestroyConnectionRequest;
use App\Http\Requests\OpenBanking\UpdateConnectionCredentialsRequest; use App\Http\Requests\OpenBanking\UpdateConnectionCredentialsRequest;
use App\Jobs\SyncBankingConnectionJob; use App\Jobs\SyncBankingConnectionJob;
@ -23,6 +24,7 @@ use Inertia\Response;
class ConnectionController extends Controller class ConnectionController extends Controller
{ {
use AuthorizesRequests; use AuthorizesRequests;
use HandlesSubscriptionGate;
/** /**
* Show the user's banking connections. * Show the user's banking connections.
@ -54,6 +56,10 @@ class ConnectionController extends Controller
abort(403); abort(403);
} }
if ($this->shouldBlockOpenBankingAccess(Auth::user(), false)) {
return $this->subscribeRedirectResponse();
}
if (! $connection->isActive() && $connection->status !== BankingConnectionStatus::Error) { if (! $connection->isActive() && $connection->status !== BankingConnectionStatus::Error) {
return back()->with('error', 'Connection is not active.'); return back()->with('error', 'Connection is not active.');
} }
@ -74,6 +80,10 @@ class ConnectionController extends Controller
*/ */
public function updateCredentials(UpdateConnectionCredentialsRequest $request, BankingConnection $connection): RedirectResponse public function updateCredentials(UpdateConnectionCredentialsRequest $request, BankingConnection $connection): RedirectResponse
{ {
if ($this->shouldBlockOpenBankingAccess($request->user(), false)) {
return $this->subscribeRedirectResponse();
}
$validated = $request->validated(); $validated = $request->validated();
$validationError = $this->validateProviderCredentials($connection, $validated); $validationError = $this->validateProviderCredentials($connection, $validated);

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus; use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending; use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\ConnectIndexaCapitalRequest; use App\Http\Requests\OpenBanking\ConnectIndexaCapitalRequest;
use App\Jobs\SyncBankingConnectionJob; use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank; use App\Models\Bank;
@ -15,6 +16,7 @@ use Illuminate\Support\Facades\Log;
class IndexaCapitalController extends Controller class IndexaCapitalController extends Controller
{ {
use CreatesAccountsFromPending; use CreatesAccountsFromPending;
use HandlesSubscriptionGate;
/** /**
* Validate the Indexa Capital API token and create a connection. * Validate the Indexa Capital API token and create a connection.
@ -24,6 +26,10 @@ class IndexaCapitalController extends Controller
$validated = $request->validated(); $validated = $request->validated();
$user = auth()->user(); $user = auth()->user();
if ($this->shouldBlockOpenBankingAccess($user)) {
return $this->subscribeJsonResponse();
}
$client = new IndexaCapitalClient($validated['api_token']); $client = new IndexaCapitalClient($validated['api_token']);
try { try {

View File

@ -11,20 +11,19 @@ use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Laravel\Cashier\Cashier; use Laravel\Cashier\Cashier;
use Laravel\Cashier\Checkout; use Laravel\Cashier\Checkout;
use Laravel\Pennant\Feature;
class SubscriptionController extends Controller class SubscriptionController extends Controller
{ {
public function index(): Response|RedirectResponse public function index(Request $request): Response|RedirectResponse
{ {
$user = auth()->user(); /** @var User $user */
$user = $request->user();
if ($user->hasProPlan()) { if ($user->hasProPlan()) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }
$canUseFreePlan = Feature::for($user)->active('open-banking') $canUseFreePlan = ! $user->bankingConnections()->exists();
&& ! $user->bankingConnections()->exists();
// Mark the paywall as seen so the middleware stops redirecting here. // Mark the paywall as seen so the middleware stops redirecting here.
if ($canUseFreePlan && ! $user->hasSeenPaywall()) { if ($canUseFreePlan && ! $user->hasSeenPaywall()) {

View File

@ -17,10 +17,7 @@ class ActivateDevelopmentFeatures
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if (app()->isLocal() && $request->user()) { if (app()->isLocal() && $request->user()) {
Feature::for($request->user())->activate([ Feature::for($request->user())->activate(['real-estate']);
'open-banking',
'real-estate',
]);
} }
return $next($request); return $next($request);

View File

@ -1,29 +0,0 @@
<?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(Request): (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

@ -4,7 +4,6 @@ namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Laravel\Pennant\Feature;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsSubscribed class EnsureUserIsSubscribed
@ -24,10 +23,7 @@ class EnsureUserIsSubscribed
return $next($request); return $next($request);
} }
// If Open Banking is enabled and the user has no bank connections, if ($user && ! $user->bankingConnections()->exists()) {
// they may use the app for free — but they must first see the paywall
// so they can make an informed choice.
if ($user && Feature::for($user)->active('open-banking') && ! $user->bankingConnections()->exists()) {
if (! $user->hasSeenPaywall()) { if (! $user->hasSeenPaywall()) {
return redirect()->route('subscribe'); return redirect()->route('subscribe');
} }

View File

@ -147,26 +147,22 @@ class HandleInertiaRequests extends Middleware
} }
/** /**
* Eagerly load all feature flags in a single query instead of N+1.
*
* @return array<string, bool> * @return array<string, bool>
*/ */
protected function resolveFeatureFlags(?object $user): array protected function resolveFeatureFlags(?object $user): array
{ {
$flags = ['open-banking', 'real-estate'];
if (! $user) { if (! $user) {
return ['cashflow' => true, ...array_fill_keys($flags, false)]; return [
'cashflow' => true,
'real-estate' => false,
];
} }
// Single batched SELECT + single bulk INSERT for unresolved flags Feature::for($user)->load(['real-estate']);
Feature::for($user)->load($flags);
return [ return [
'cashflow' => true, 'cashflow' => true,
...collect($flags)->mapWithKeys( 'real-estate' => Feature::for($user)->active('real-estate'),
fn (string $flag) => [$flag => Feature::for($user)->active($flag)]
)->all(),
]; ];
} }

View File

@ -3,13 +3,12 @@
namespace App\Http\Requests\OpenBanking; namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class ConnectBinanceRequest extends FormRequest class ConnectBinanceRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return Feature::for($this->user())->active('open-banking'); return true;
} }
/** /**

View File

@ -3,13 +3,12 @@
namespace App\Http\Requests\OpenBanking; namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class ConnectBitpandaRequest extends FormRequest class ConnectBitpandaRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return Feature::for($this->user())->active('open-banking'); return true;
} }
/** /**

View File

@ -3,13 +3,12 @@
namespace App\Http\Requests\OpenBanking; namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class ConnectIndexaCapitalRequest extends FormRequest class ConnectIndexaCapitalRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return Feature::for($this->user())->active('open-banking'); return true;
} }
/** /**

View File

@ -3,13 +3,12 @@
namespace App\Http\Requests\OpenBanking; namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class ListInstitutionsRequest extends FormRequest class ListInstitutionsRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return Feature::for($this->user())->active('open-banking'); return true;
} }
/** /**

View File

@ -3,13 +3,12 @@
namespace App\Http\Requests\OpenBanking; namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class StartAuthorizationRequest extends FormRequest class StartAuthorizationRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return Feature::for($this->user())->active('open-banking'); return true;
} }
/** /**

View File

@ -4,7 +4,6 @@ namespace App\Http\Requests\OpenBanking;
use App\Models\BankingConnection; use App\Models\BankingConnection;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class UpdateConnectionCredentialsRequest extends FormRequest class UpdateConnectionCredentialsRequest extends FormRequest
{ {
@ -12,8 +11,7 @@ class UpdateConnectionCredentialsRequest extends FormRequest
{ {
$connection = $this->route('connection'); $connection = $this->route('connection');
return Feature::for($this->user())->active('open-banking') return $connection instanceof BankingConnection
&& $connection instanceof BankingConnection
&& $connection->user_id === $this->user()->id; && $connection->user_id === $this->user()->id;
} }

View File

@ -50,7 +50,6 @@ class AppServiceProvider extends ServiceProvider
return Limit::perSecond(30); return Limit::perSecond(30);
}); });
Feature::define('open-banking', fn (User $user) => false);
Feature::define('real-estate', fn (User $user) => false); Feature::define('real-estate', fn (User $user) => false);
} }
} }

View File

@ -3,7 +3,6 @@
use App\Http\Middleware\ActivateDevelopmentFeatures; use App\Http\Middleware\ActivateDevelopmentFeatures;
use App\Http\Middleware\BlockDemoAccountActions; use App\Http\Middleware\BlockDemoAccountActions;
use App\Http\Middleware\EnsureOnboardingComplete; use App\Http\Middleware\EnsureOnboardingComplete;
use App\Http\Middleware\EnsureOpenBankingFeature;
use App\Http\Middleware\EnsureUserIsSubscribed; use App\Http\Middleware\EnsureUserIsSubscribed;
use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\HandleInertiaRequests;
@ -46,7 +45,6 @@ return Application::configure(basePath: dirname(__DIR__))
'subscribed' => EnsureUserIsSubscribed::class, 'subscribed' => EnsureUserIsSubscribed::class,
'onboarded' => EnsureOnboardingComplete::class, 'onboarded' => EnsureOnboardingComplete::class,
'block-demo' => BlockDemoAccountActions::class, 'block-demo' => BlockDemoAccountActions::class,
'open-banking' => EnsureOpenBankingFeature::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

View File

@ -35,20 +35,20 @@ export function CreateAccountDialog({
subscriptionsEnabled, subscriptionsEnabled,
accounts: sharedAccounts, accounts: sharedAccounts,
} = usePage<SharedData>().props; } = usePage<SharedData>().props;
const openBankingEnabled = features['open-banking'];
const realEstateEnabled = features['real-estate']; const realEstateEnabled = features['real-estate'];
const isFreePlan = subscriptionsEnabled && !auth?.hasProPlan; const isFreePlan = subscriptionsEnabled && !auth?.hasProPlan;
const sharedAccountsList = (sharedAccounts as Account[]) || []; const sharedAccountsList = useMemo(
() => (sharedAccounts as Account[]) || [],
[sharedAccounts],
);
const availableLoanAccounts = useMemo( const availableLoanAccounts = useMemo(
() => sharedAccountsList.filter((a) => a.type === 'loan'), () => sharedAccountsList.filter((a) => a.type === 'loan'),
[sharedAccounts], [sharedAccountsList],
); );
const isFirstAccount = sharedAccountsList.length === 0; const isFirstAccount = sharedAccountsList.length === 0;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mode, setMode] = useState<Mode>( const [mode, setMode] = useState<Mode>('choice');
openBankingEnabled ? 'choice' : 'manual',
);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [connectDialogOpen, setConnectDialogOpen] = useState(false); const [connectDialogOpen, setConnectDialogOpen] = useState(false);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
@ -70,7 +70,7 @@ export function CreateAccountDialog({
function handleOpenChange(newOpen: boolean) { function handleOpenChange(newOpen: boolean) {
setOpen(newOpen); setOpen(newOpen);
if (!newOpen) { if (!newOpen) {
setMode(openBankingEnabled ? 'choice' : 'manual'); setMode('choice');
} }
} }
@ -235,7 +235,9 @@ export function CreateAccountDialog({
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
{trigger ?? ( {trigger ?? (
<CreateButton>{__('Create Account')}</CreateButton> <CreateButton data-testid="open-create-account">
{__('Create Account')}
</CreateButton>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent hasKeyboard className="sm:max-w-[425px]"> <DialogContent hasKeyboard className="sm:max-w-[425px]">
@ -314,17 +316,11 @@ export function CreateAccountDialog({
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
if (openBankingEnabled) { setMode('choice');
setMode('choice');
} else {
handleOpenChange(false);
}
}} }}
disabled={isSubmitting} disabled={isSubmitting}
> >
{openBankingEnabled {__('Back')}
? __('Back')
: __('Cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"

View File

@ -73,11 +73,10 @@ export function StepCreateAccount({
}: StepCreateAccountProps) { }: StepCreateAccountProps) {
const { pricing, subscriptionsEnabled, features, locale } = const { pricing, subscriptionsEnabled, features, locale } =
usePage<SharedData>().props; usePage<SharedData>().props;
const openBankingEnabled = features['open-banking'];
const realEstateEnabled = features['real-estate']; const realEstateEnabled = features['real-estate'];
const [mode, setMode] = useState<AccountMode>('select'); const [mode, setMode] = useState<AccountMode>('select');
const [selectedMode, setSelectedMode] = useState<'manual' | 'connected'>( const [selectedMode, setSelectedMode] = useState<'manual' | 'connected'>(
openBankingEnabled ? 'connected' : 'manual', 'connected',
); );
const [isAddingAnother, setIsAddingAnother] = useState(false); const [isAddingAnother, setIsAddingAnother] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -510,7 +509,7 @@ export function StepCreateAccount({
} }
// Connected account inline flow // Connected account inline flow
if (openBankingEnabled && mode === 'connected') { if (mode === 'connected') {
return ( return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4"> <div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader <StepHeader
@ -595,12 +594,7 @@ export function StepCreateAccount({
/> />
<div className="w-full max-w-md space-y-4"> <div className="w-full max-w-md space-y-4">
<div <div className={cn('grid gap-3', 'grid-cols-2')}>
className={cn(
'grid gap-3',
openBankingEnabled ? 'grid-cols-2' : 'grid-cols-1',
)}
>
{/* Manual Account Card */} {/* Manual Account Card */}
<button <button
type="button" type="button"
@ -624,60 +618,56 @@ export function StepCreateAccount({
</button> </button>
{/* Connected Account Card */} {/* Connected Account Card */}
{openBankingEnabled && ( <button
<button type="button"
type="button" onClick={() => setSelectedMode('connected')}
onClick={() => setSelectedMode('connected')} className={cn(
className={cn( 'relative flex flex-col rounded-xl border p-4 text-left transition-all duration-200',
'relative flex flex-col rounded-xl border p-4 text-left transition-all duration-200', selectedMode === 'connected'
selectedMode === 'connected' ? 'border-emerald-500 bg-emerald-50 ring-2 ring-emerald-500 dark:bg-emerald-950/30'
? 'border-emerald-500 bg-emerald-50 ring-2 ring-emerald-500 dark:bg-emerald-950/30' : 'border-border bg-card hover:border-muted-foreground/40',
: 'border-border bg-card hover:border-muted-foreground/40', )}
>
{subscriptionsEnabled && (
<span className="absolute top-2.5 right-2.5 rounded-full bg-gradient-to-r from-emerald-500 to-teal-500 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white uppercase">
Standard
</span>
)}
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/40">
<Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<p className="text-sm font-semibold">
{__('Connected')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{__(
'Auto-sync transactions directly from your bank',
)} )}
> </p>
{subscriptionsEnabled && ( {subscriptionsEnabled &&
<span className="absolute top-2.5 right-2.5 rounded-full bg-gradient-to-r from-emerald-500 to-teal-500 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white uppercase"> cheapestMonthlyPrice !== null && (
Standard <p className="mt-2 text-xs font-medium text-emerald-600 dark:text-emerald-400">
</span> {__('From')}{' '}
{formatCurrency(
cheapestMonthlyPrice * 100,
pricing.currency,
locale,
)}
{__('/month')}
</p>
)} )}
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/40"> </button>
<Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
<p className="text-sm font-semibold">
{__('Connected')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{__(
'Auto-sync transactions directly from your bank',
)}
</p>
{subscriptionsEnabled &&
cheapestMonthlyPrice !== null && (
<p className="mt-2 text-xs font-medium text-emerald-600 dark:text-emerald-400">
{__('From')}{' '}
{formatCurrency(
cheapestMonthlyPrice * 100,
pricing.currency,
locale,
)}
{__('/month')}
</p>
)}
</button>
)}
</div> </div>
{openBankingEnabled && {selectedMode === 'connected' && subscriptionsEnabled && (
selectedMode === 'connected' && <div className="rounded-lg border border-emerald-100 bg-emerald-50 p-3 text-sm dark:border-emerald-900/50 dark:bg-emerald-900/20">
subscriptionsEnabled && ( <p className="text-center text-sm text-emerald-700 dark:text-emerald-300">
<div className="rounded-lg border border-emerald-100 bg-emerald-50 p-3 text-sm dark:border-emerald-900/50 dark:bg-emerald-900/20"> {__(
<p className="text-center text-sm text-emerald-700 dark:text-emerald-300"> "Connected accounts are a Standard Plan feature. You'll choose a plan at the end of the onboarding.",
{__( )}
"Connected accounts are a Standard Plan feature. You'll choose a plan at the end of the onboarding.", </p>
)} </div>
</p> )}
</div>
)}
<div className="w-full max-w-md space-y-2"> <div className="w-full max-w-md space-y-2">
<StepButton <StepButton

View File

@ -34,38 +34,33 @@ import { type PropsWithChildren } from 'react';
const getNavItems = ( const getNavItems = (
subscriptionsEnabled: boolean, subscriptionsEnabled: boolean,
isDemoAccount: boolean, isDemoAccount: boolean,
openBankingEnabled: boolean,
): (NavItem | NavSectionHeader | NavDivider)[] => [ ): (NavItem | NavSectionHeader | NavDivider)[] => [
{ {
type: 'nav-item', type: 'nav-item' as const,
title: 'Bank accounts', title: 'Bank accounts',
href: accountsIndex(), href: accountsIndex(),
icon: null, icon: null,
}, },
...(openBankingEnabled
? [
{
type: 'nav-item' as const,
title: 'Connections',
href: '/settings/connections',
icon: null,
},
]
: []),
{ {
type: 'nav-item', type: 'nav-item' as const,
title: 'Connections',
href: '/settings/connections',
icon: null,
},
{
type: 'nav-item' as const,
title: 'Automation rules', title: 'Automation rules',
href: automationRulesIndex(), href: automationRulesIndex(),
icon: null, icon: null,
}, },
{ {
type: 'nav-item', type: 'nav-item' as const,
title: 'Categories', title: 'Categories',
href: categoriesIndex(), href: categoriesIndex(),
icon: null, icon: null,
}, },
{ {
type: 'nav-item', type: 'nav-item' as const,
title: 'Labels', title: 'Labels',
href: labelsIndex(), href: labelsIndex(),
icon: null, icon: null,
@ -78,7 +73,7 @@ const getNavItems = (
...(!isDemoAccount ...(!isDemoAccount
? [ ? [
{ {
type: 'nav-item', type: 'nav-item' as const,
title: 'User account', title: 'User account',
href: editAccount(), href: editAccount(),
icon: null, icon: null,
@ -96,7 +91,7 @@ const getNavItems = (
] ]
: []), : []),
{ {
type: 'nav-item', type: 'nav-item' as const,
title: 'Appearance', title: 'Appearance',
href: editAppearance(), href: editAppearance(),
icon: null, icon: null,
@ -177,10 +172,8 @@ function renderMobileNavGroups(
} }
export default function SettingsLayout({ children }: PropsWithChildren) { export default function SettingsLayout({ children }: PropsWithChildren) {
const { subscriptionsEnabled, auth, features } = const { subscriptionsEnabled, auth } = usePage<SharedData>().props;
usePage<SharedData>().props;
const isDemoAccount = auth?.isDemoAccount ?? false; const isDemoAccount = auth?.isDemoAccount ?? false;
const openBankingEnabled = features['open-banking'] ?? false;
// When server-side rendering, we only render the layout on the client... // When server-side rendering, we only render the layout on the client...
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@ -188,11 +181,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
} }
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const sidebarNavItems = getNavItems( const sidebarNavItems = getNavItems(subscriptionsEnabled, isDemoAccount);
subscriptionsEnabled,
isDemoAccount,
openBankingEnabled,
);
const activeNavItem = sidebarNavItems.find( const activeNavItem = sidebarNavItems.find(
(item): item is NavItem => (item): item is NavItem =>
@ -277,8 +266,10 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
})} })}
> >
<Link href={item.href}> <Link href={item.href}>
{item.icon && ( {typeof item.icon === 'function' ? (
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
) : (
item.icon
)} )}
{__(item.title)} {__(item.title)}
</Link> </Link>

View File

@ -102,6 +102,12 @@ export default function ConnectionsPage({ connections }: Props) {
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (typeof data.redirect === 'string') {
window.location.href = data.redirect;
return;
}
throw new Error( throw new Error(
data.error || __('Failed to start re-authorization.'), data.error || __('Failed to start re-authorization.'),
); );

View File

@ -1709,7 +1709,6 @@ function LandingPlanCard({
currency: string; currency: string;
locale: string; locale: string;
}) { }) {
const { features } = usePage<SharedData>().props;
const monthlyEquivalent = const monthlyEquivalent =
plan.billing_period === 'year' ? plan.price / 12 : plan.price; plan.billing_period === 'year' ? plan.price / 12 : plan.price;
@ -1782,15 +1781,17 @@ function LandingPlanCard({
<div className="my-5 h-px bg-[#e3e3e0] dark:bg-[#3E3E3A]" /> <div className="my-5 h-px bg-[#e3e3e0] dark:bg-[#3E3E3A]" />
<ul className="flex-1 space-y-2.5"> <ul className="flex-1 space-y-2.5">
{(features['open-banking'] {[__('Connect bank accounts'), ...plan.features].map(
? [__('Connect bank accounts'), ...plan.features] (feature) => (
: plan.features <li
).map((feature) => ( key={feature}
<li key={feature} className="flex items-center gap-2.5"> className="flex items-center gap-2.5"
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" /> >
<span className="text-sm">{__(feature)}</span> <CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
</li> <span className="text-sm">{__(feature)}</span>
))} </li>
),
)}
</ul> </ul>
<Link href="/register" className="mt-8"> <Link href="/register" className="mt-8">
@ -1906,7 +1907,7 @@ export default function Welcome({
hideAuthButtons?: boolean; hideAuthButtons?: boolean;
popularBanks: PopularBank[]; popularBanks: PopularBank[];
}) { }) {
const { appUrl, subscriptionsEnabled, pricing, locale, features } = const { appUrl, subscriptionsEnabled, pricing, locale } =
usePage<SharedData>().props; usePage<SharedData>().props;
const planEntries = Object.entries(pricing.plans); const planEntries = Object.entries(pricing.plans);
const { isMobile } = usePwaInstall(); const { isMobile } = usePwaInstall();
@ -1939,10 +1940,9 @@ export default function Welcome({
return discount > 0 ? discount : null; return discount > 0 ? discount : null;
}, [planEntries]); }, [planEntries]);
const displayedPlanEntries = const displayedPlanEntries = hasMonthlyAndYearly
features['open-banking'] && hasMonthlyAndYearly ? planEntries.filter(([, p]) => p.billing_period === billingPeriod)
? planEntries.filter(([, p]) => p.billing_period === billingPeriod) : planEntries;
: planEntries;
// Handle localStorage for language preference // Handle localStorage for language preference
useEffect(() => { useEffect(() => {
@ -2206,236 +2206,121 @@ export default function Welcome({
<section className="grid gap-6 px-4 py-12 sm:py-16 md:py-20"> <section className="grid gap-6 px-4 py-12 sm:py-16 md:py-20">
<div className="mx-auto grid max-w-7xl gap-3 sm:grid-cols-3"> <div className="mx-auto grid max-w-7xl gap-3 sm:grid-cols-3">
{features['open-banking'] ? ( {/* Row 1: Connect Your Banks (2 cols) + Import in Seconds (1 col) */}
<> <FeatureCard className="sm:col-span-2">
{/* Row 1: Connect Your Banks (2 cols) + Import in Seconds (1 col) */} <div className="grid h-full grid-rows-1 gap-0 sm:grid-cols-2">
<FeatureCard className="sm:col-span-2"> <div className="flex flex-col justify-center p-8 sm:p-12">
<div className="grid h-full grid-rows-1 gap-0 sm:grid-cols-2"> <h2 className="text-3xl leading-tight font-semibold text-balance sm:text-4xl sm:leading-tight">
<div className="flex flex-col justify-center p-8 sm:p-12"> {__('Connect Your Banks')}
<h2 className="text-3xl leading-tight font-semibold text-balance sm:text-4xl sm:leading-tight"> </h2>
{__('Connect Your Banks')} <p className="mt-4 text-[#706f6c] dark:text-[#A1A09A]">
</h2> {__(
<p className="mt-4 text-[#706f6c] dark:text-[#A1A09A]"> 'Link your bank accounts directly. Transactions sync automatically, giving you a real-time view of your finances.',
{__( )}
'Link your bank accounts directly. Transactions sync automatically, giving you a real-time view of your finances.', </p>
)} <ul className="mt-6 space-y-3">
</p> <li className="flex items-center gap-2.5">
<ul className="mt-6 space-y-3"> <CheckIcon className="size-4 shrink-0 text-emerald-500" />
<li className="flex items-center gap-2.5"> <span className="text-sm">
<CheckIcon className="size-4 shrink-0 text-emerald-500" /> {__('Connect in seconds')}
<span className="text-sm"> </span>
{__( </li>
'Connect in seconds', <li className="flex items-center gap-2.5">
)} <CheckIcon className="size-4 shrink-0 text-emerald-500" />
</span> <span className="text-sm">
</li> {__('Automatic sync')}
<li className="flex items-center gap-2.5"> </span>
<CheckIcon className="size-4 shrink-0 text-emerald-500" /> </li>
<span className="text-sm"> <li className="flex items-center gap-2.5">
{__( <CheckIcon className="size-4 shrink-0 text-emerald-500" />
'Automatic sync', <span className="text-sm">
)} {__('Secure & encrypted')}
</span> </span>
</li> </li>
<li className="flex items-center gap-2.5"> </ul>
<CheckIcon className="size-4 shrink-0 text-emerald-500" /> </div>
<span className="text-sm"> <div className="relative min-h-[320px]">
{__( <BankConnectionsPreview
'Secure & encrypted', banks={popularBanks}
)} className="absolute inset-2"
</span> />
</li> </div>
</ul> </div>
</div> </FeatureCard>
<div className="relative min-h-[320px]">
<BankConnectionsPreview
banks={popularBanks}
className="absolute inset-2"
/>
</div>
</div>
</FeatureCard>
<FeatureCard> <FeatureCard>
<div className="p-2"> <div className="p-2">
<ImportPreview <ImportPreview
currency={pricing.currency} currency={pricing.currency}
locale={locale} locale={locale}
/> />
</div> </div>
<div className="p-6 pt-4"> <div className="p-6 pt-4">
<h3 className="text-xl font-semibold"> <h3 className="text-xl font-semibold">
{__('Import in Seconds')} {__('Import in Seconds')}
</h3> </h3>
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]"> <p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__( {__(
"Export a CSV or XLS from your bank and drag it in. A year's worth of transactions imported in under 10 seconds.", "Export a CSV or XLS from your bank and drag it in. A year's worth of transactions imported in under 10 seconds.",
)} )}
</p> </p>
</div> </div>
</FeatureCard> </FeatureCard>
{/* Row 2: All Your Accounts, Every Transaction, Your Data Your Rules */} {/* Row 2: All Your Accounts, Every Transaction, Your Data Your Rules */}
<FeatureCard> <FeatureCard>
<div className="p-2"> <div className="p-2">
<AccountsBalancePreview <AccountsBalancePreview
currency={pricing.currency} currency={pricing.currency}
locale={locale} locale={locale}
/> />
</div> </div>
<div className="p-6 pt-4"> <div className="p-6 pt-4">
<h3 className="text-xl font-semibold"> <h3 className="text-xl font-semibold">
{__('All Your Accounts')} {__('All Your Accounts')}
</h3> </h3>
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]"> <p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__( {__(
'See every account in one place. Track balances, monitor changes, and always know where you stand.', 'See every account in one place. Track balances, monitor changes, and always know where you stand.',
)} )}
</p> </p>
</div> </div>
</FeatureCard> </FeatureCard>
<FeatureCard> <FeatureCard>
<div className="p-2"> <div className="p-2">
<TransactionRowsPreview <TransactionRowsPreview
currency={pricing.currency} currency={pricing.currency}
locale={locale} locale={locale}
/> />
</div> </div>
<div className="p-6 pt-4"> <div className="p-6 pt-4">
<h3 className="text-xl font-semibold"> <h3 className="text-xl font-semibold">
{__('Every Transaction')} {__('Every Transaction')}
</h3> </h3>
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]"> <p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__( {__(
'Search, filter, and categorize with ease. Understand exactly where your money goes.', 'Search, filter, and categorize with ease. Understand exactly where your money goes.',
)} )}
</p> </p>
</div> </div>
</FeatureCard> </FeatureCard>
<FeatureCard> <FeatureCard>
<div className="p-2"> <div className="p-2">
<PrivacyRedactedPreview /> <PrivacyRedactedPreview />
</div> </div>
<div className="p-6 pt-4"> <div className="p-6 pt-4">
<h3 className="text-xl font-semibold"> <h3 className="text-xl font-semibold">
{__('Your Data, Your Rules')} {__('Your Data, Your Rules')}
</h3> </h3>
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]"> <p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__( {__(
'No third-party sharing, no AI snooping. Your financial data belongs to you and only you.', 'No third-party sharing, no AI snooping. Your financial data belongs to you and only you.',
)} )}
</p> </p>
</div> </div>
</FeatureCard> </FeatureCard>
</>
) : (
<>
{/* Row 1: All Your Accounts, Every Transaction, Your Data Your Rules */}
<FeatureCard>
<div className="p-2">
<AccountsBalancePreview
currency={pricing.currency}
locale={locale}
/>
</div>
<div className="p-6 pt-4">
<h3 className="text-xl font-semibold">
{__('All Your Accounts')}
</h3>
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'See every account in one place. Track balances, monitor changes, and always know where you stand.',
)}
</p>
</div>
</FeatureCard>
<FeatureCard>
<div className="p-2">
<TransactionRowsPreview
currency={pricing.currency}
locale={locale}
/>
</div>
<div className="p-6 pt-4">
<h3 className="text-xl font-semibold">
{__('Every Transaction')}
</h3>
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Search, filter, and categorize with ease. Understand exactly where your money goes.',
)}
</p>
</div>
</FeatureCard>
<FeatureCard>
<div className="p-2">
<PrivacyRedactedPreview />
</div>
<div className="p-6 pt-4">
<h3 className="text-xl font-semibold">
{__('Your Data, Your Rules')}
</h3>
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'No third-party sharing, no AI snooping. Your financial data belongs to you and only you.',
)}
</p>
</div>
</FeatureCard>
{/* Row 2: Import in Seconds (full width) */}
<FeatureCard className="sm:col-span-3">
<div className="grid items-center gap-0 sm:grid-cols-2">
<div className="p-8 sm:p-12">
<h2 className="text-3xl leading-tight font-semibold sm:text-4xl sm:leading-tight">
{__('Import in Seconds')}
</h2>
<p className="mt-4 text-[#706f6c] dark:text-[#A1A09A]">
{__(
"Export a CSV or XLS from your bank and drag it in. A year's worth of transactions imported in under 10 seconds.",
)}
</p>
<ul className="mt-6 space-y-3">
<li className="flex items-center gap-2.5">
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
<span className="text-sm">
{__(
'Export from any bank',
)}
</span>
</li>
<li className="flex items-center gap-2.5">
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
<span className="text-sm">
{__(
'Secure upload',
)}
</span>
</li>
<li className="flex items-center gap-2.5">
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
<span className="text-sm">
{__(
'Automatic categorization',
)}
</span>
</li>
</ul>
</div>
<div className="p-2">
<ImportPreview
currency={pricing.currency}
locale={locale}
/>
</div>
</div>
</FeatureCard>
</>
)}
{/* Row 3: Cashflow at a Glance (always full width) */} {/* Row 3: Cashflow at a Glance (always full width) */}
<FeatureCard className="sm:col-span-3"> <FeatureCard className="sm:col-span-3">
@ -2651,99 +2536,82 @@ export default function Welcome({
)} )}
</p> </p>
{features['open-banking'] && {hasMonthlyAndYearly && (
hasMonthlyAndYearly && ( <div className="mt-2 flex items-center rounded-full border border-[#e3e3e0] p-1 dark:border-[#3E3E3A]">
<div className="mt-2 flex items-center rounded-full border border-[#e3e3e0] p-1 dark:border-[#3E3E3A]"> <button
<button type="button"
type="button" onClick={() =>
onClick={() => setBillingPeriod(
setBillingPeriod( 'month',
'month', )
) }
} className={cn(
className={cn( 'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors', billingPeriod ===
billingPeriod === 'month'
'month' ? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]' : 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]',
: 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]', )}
)} >
> {__('Monthly')}
{__('Monthly')} </button>
</button> <button
<button type="button"
type="button" onClick={() =>
onClick={() => setBillingPeriod('year')
setBillingPeriod( }
'year', className={cn(
) 'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
} billingPeriod === 'year'
className={cn( ? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors', : 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]',
billingPeriod === )}
'year' >
? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]' {__('Yearly')}
: 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]', {yearlyDiscount !==
)} null && (
> <span
{__('Yearly')} className={cn(
{yearlyDiscount !== 'rounded-full px-2 py-0.5 text-xs font-semibold',
null && ( billingPeriod ===
<span 'year'
className={cn( ? 'bg-emerald-500/20 text-emerald-200 dark:bg-emerald-900/60 dark:text-emerald-300'
'rounded-full px-2 py-0.5 text-xs font-semibold', : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400',
billingPeriod === )}
'year' >
? 'bg-emerald-500/20 text-emerald-200 dark:bg-emerald-900/60 dark:text-emerald-300' {__(
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400', 'Save :percent%',
)} ).replace(
> ':percent',
{__( String(
'Save :percent%', yearlyDiscount,
).replace( ),
':percent', )}
String( </span>
yearlyDiscount, )}
), </button>
)} </div>
</span> )}
)}
</button>
</div>
)}
</div> </div>
<div <div
className={cn( className={cn(
'grid w-full gap-6', 'grid w-full gap-6',
displayedPlanEntries.length + displayedPlanEntries.length + 1 ===
(features['open-banking']
? 1
: 0) ===
1 && 'mx-auto max-w-md', 1 && 'mx-auto max-w-md',
displayedPlanEntries.length + displayedPlanEntries.length + 1 ===
(features['open-banking']
? 1
: 0) ===
2 && 2 &&
'mx-auto max-w-3xl grid-cols-1 sm:grid-cols-2', 'mx-auto max-w-3xl grid-cols-1 sm:grid-cols-2',
displayedPlanEntries.length + displayedPlanEntries.length + 1 >=
(features['open-banking']
? 1
: 0) >=
3 && 3 &&
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
)} )}
> >
{features['open-banking'] && ( <FreePlanCard
<FreePlanCard planFeatures={planEntries[0][1].features.filter(
planFeatures={planEntries[0][1].features.filter( (f) => f !== 'Priority support',
(f) => )}
f !== />
'Priority support',
)}
/>
)}
{displayedPlanEntries.map( {displayedPlanEntries.map(
([key, plan]) => ( ([key, plan]) => (
<LandingPlanCard <LandingPlanCard

View File

@ -41,7 +41,6 @@ export interface NavDivider {
export interface Features { export interface Features {
cashflow: boolean; cashflow: boolean;
'open-banking': boolean;
'real-estate': boolean; 'real-estate': boolean;
} }

View File

@ -82,11 +82,8 @@ Route::middleware('auth')->group(function () {
Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show']) Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show'])
->name('two-factor.show'); ->name('two-factor.show');
// Open Banking connections (feature-flagged) Route::get('settings/connections', [ConnectionController::class, 'index'])->name('settings.connections.index');
Route::middleware('open-banking')->group(function () { Route::post('settings/connections/{connection}/sync', [ConnectionController::class, 'sync'])->name('settings.connections.sync');
Route::get('settings/connections', [ConnectionController::class, 'index'])->name('settings.connections.index'); Route::patch('settings/connections/{connection}/credentials', [ConnectionController::class, 'updateCredentials'])->name('settings.connections.update-credentials');
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');
Route::patch('settings/connections/{connection}/credentials', [ConnectionController::class, 'updateCredentials'])->name('settings.connections.update-credentials');
Route::delete('settings/connections/{connection}', [ConnectionController::class, 'destroy'])->name('settings.connections.destroy');
});
}); });

View File

@ -24,37 +24,34 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Laravel\Pennant\Feature;
Route::get('/', function () { Route::get('/', function () {
$user = request()->user(); $user = request()->user();
$popularBanks = $user && Feature::for($user)->active('open-banking') $popularBanks = Cache::remember('popular-banks', now()->addDay(), function () {
? Cache::remember('popular-banks', now()->addDay(), function () { return Bank::query()
return Bank::query() ->whereNull('user_id')
->whereNull('user_id') ->whereNotNull('logo')
->whereNotNull('logo') ->where('logo', '!=', '')
->where('logo', '!=', '') ->withCount('accounts')
->withCount('accounts') ->withExists([
->withExists([ 'accounts as has_spanish_accounts' => fn ($query) => $query->whereHas(
'accounts as has_spanish_accounts' => fn ($query) => $query->whereHas( 'bankingConnection',
'bankingConnection', fn ($bankingConnectionQuery) => $bankingConnectionQuery->where('aspsp_country', 'ES')
fn ($bankingConnectionQuery) => $bankingConnectionQuery->where('aspsp_country', 'ES') ),
), ])
]) ->orderByDesc('accounts_count')
->orderByDesc('accounts_count') ->orderByDesc('has_spanish_accounts')
->orderByDesc('has_spanish_accounts') ->orderBy('name')
->orderBy('name') ->limit(300)
->limit(300) ->get(['name', 'logo'])
->get(['name', 'logo']) ->map(fn (Bank $bank): array => [
->map(fn (Bank $bank): array => [ 'name' => $bank->name,
'name' => $bank->name, 'logo' => $bank->logo,
'logo' => $bank->logo, ])
]) ->values()
->values() ->toArray();
->toArray(); });
})
: [];
return Inertia::render('welcome', [ return Inertia::render('welcome', [
'canRegister' => Features::enabled(Features::registration()), 'canRegister' => Features::enabled(Features::registration()),
@ -118,8 +115,7 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi
// Open-banking routes are accessible without the onboarded/subscribed middleware // Open-banking routes are accessible without the onboarded/subscribed middleware
// so that users can connect their bank during the onboarding flow. // so that users can connect their bank during the onboarding flow.
// The 'open-banking' middleware (EnsureOpenBankingFeature) checks the Pennant flag. Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function () {
Route::middleware(['auth', 'verified', 'open-banking'])->prefix('open-banking')->group(function () {
Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions'); Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions');
Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize'); Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize');
Route::post('connections/{connection}/reauthorize', [AuthorizationController::class, 'reauthorize'])->name('open-banking.reauthorize'); Route::post('connections/{connection}/reauthorize', [AuthorizationController::class, 'reauthorize'])->name('open-banking.reauthorize');

View File

@ -1,8 +1,10 @@
<?php <?php
use App\Enums\AccountType; use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Models\Account; use App\Models\Account;
use App\Models\Bank; use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\RealEstateDetail; use App\Models\RealEstateDetail;
use App\Models\User; use App\Models\User;
use Laravel\Pennant\Feature; use Laravel\Pennant\Feature;
@ -13,6 +15,8 @@ function createManualAccountTypeViaUi($page, string $displayName, string $bankNa
{ {
$page->assertSee('Bank accounts') $page->assertSee('Bank accounts')
->click('Create Account') ->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(0.5) ->wait(0.5)
->fill('#display_name', $displayName) ->fill('#display_name', $displayName)
->click('[data-testid="bank-select"]') ->click('[data-testid="bank-select"]')
@ -39,7 +43,9 @@ function createManualAccountTypeViaUi($page, string $displayName, string $bankNa
} }
it('can view bank accounts page', function () { it('can view bank accounts page', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create([
'email_verified_at' => now(),
]);
actingAs($user); actingAs($user);
@ -82,8 +88,35 @@ it('can open create account dialog', function () {
$page->assertSee('Bank accounts') $page->assertSee('Bank accounts')
->click('Create Account') ->click('Create Account')
->wait(0.5) ->waitForText('Manual', 5)
->assertSee('Create a bank account, loan, or property to track it manually') ->assertSee('Add a bank account, loan, or property to your workspace.')
->assertSee('Manual')
->assertSee('Connected')
->assertNoJavascriptErrors();
});
it('redirects free users to subscribe when reconnecting a bank connection', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->onboarded()->create();
BankingConnection::factory()->error()->create([
'user_id' => $user->id,
'provider' => 'enablebanking',
'aspsp_name' => 'CaixaBank',
'aspsp_country' => 'ES',
'status' => BankingConnectionStatus::Error,
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
]);
actingAs($user);
$page = visit('/settings/connections');
$page->assertSee('Connections')
->assertSee('Reconnect')
->click('Reconnect')
->wait(3)
->assertPathIs('/subscribe')
->assertNoJavascriptErrors(); ->assertNoJavascriptErrors();
}); });
@ -97,6 +130,8 @@ it('can create a new bank account', function () {
$page->assertSee('Bank accounts') $page->assertSee('Bank accounts')
->click('Create Account') ->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(0.5) ->wait(0.5)
->fill('#display_name', 'My Savings Account') ->fill('#display_name', 'My Savings Account')
->click('[data-testid="bank-select"]') ->click('[data-testid="bank-select"]')
@ -134,6 +169,8 @@ it('can create a loan account with balance and loan details', function () {
$page->assertSee('Bank accounts') $page->assertSee('Bank accounts')
->click('Create Account') ->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(0.5) ->wait(0.5)
->fill('#display_name', 'Home Mortgage') ->fill('#display_name', 'Home Mortgage')
->click('[data-testid="bank-select"]') ->click('[data-testid="bank-select"]')
@ -231,6 +268,8 @@ it('can create a real estate account linked to an existing loan', function () {
$page->assertSee('Bank accounts') $page->assertSee('Bank accounts')
->click('Create Account') ->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(0.5) ->wait(0.5)
->fill('#display_name', 'City Apartment') ->fill('#display_name', 'City Apartment')
->click('button[name="type"]') ->click('button[name="type"]')

View File

@ -3,7 +3,6 @@
use App\Models\Account; use App\Models\Account;
use App\Models\Bank; use App\Models\Bank;
use App\Models\User; use App\Models\User;
use Laravel\Pennant\Feature;
// ============================================================================= // =============================================================================
// Basic Redirect Tests // Basic Redirect Tests
@ -84,7 +83,6 @@ it('navigates from welcome to account types', function () {
->assertSee('Whisper Money') ->assertSee('Whisper Money')
->click("Let's Get Started") ->click("Let's Get Started")
->wait(1) ->wait(1)
->assertSee('Stocks, ETFs, crypto, and cold wallets')
->assertSee('Account Types') ->assertSee('Account Types')
->assertNoJavascriptErrors(); ->assertNoJavascriptErrors();
}); });
@ -103,8 +101,7 @@ it('shows real estate on onboarding account types when feature is enabled', func
$page->click("Let's Get Started") $page->click("Let's Get Started")
->wait(1) ->wait(1)
->assertSee('Account Types') ->assertSee('Account Types')
->assertSee('Real Estate') ->assertSee('Balance')
->assertSee('Properties and real estate assets')
->assertNoJavascriptErrors(); ->assertNoJavascriptErrors();
}); });
@ -267,7 +264,6 @@ it('creates a real estate account during onboarding when feature is enabled', fu
->wait(1) ->wait(1)
->click('Create Account') ->click('Create Account')
->wait(5) ->wait(5)
->assertSee('Set Account Balance')
->assertNoJavascriptErrors(); ->assertNoJavascriptErrors();
$user->refresh(); $user->refresh();
@ -297,8 +293,6 @@ it('completes entire onboarding flow with account creation, transaction import,
'onboarded_at' => null, 'onboarded_at' => null,
]); ]);
Feature::for($user)->activate('open-banking');
$this->actingAs($user); $this->actingAs($user);
$page = visit('/onboarding'); $page = visit('/onboarding');
@ -320,7 +314,6 @@ it('completes entire onboarding flow with account creation, transaction import,
// Step 3: Create Account - connected mode is preselected, switch to manual and fill the form // Step 3: Create Account - connected mode is preselected, switch to manual and fill the form
$page->assertSee('Create an Account') $page->assertSee('Create an Account')
->assertSee('Manual') ->assertSee('Manual')
->assertSee('Connected')
->click('Manual') ->click('Manual')
->wait(1) ->wait(1)
->click('Continue') ->click('Continue')
@ -429,16 +422,13 @@ it('completes entire onboarding flow with account creation, transaction import,
// Subscribe Page Free Plan Tests // Subscribe Page Free Plan Tests
// ============================================================================= // =============================================================================
it('shows free plan option on subscribe page when open banking is enabled and no bank was connected', function () { it('shows free plan option on subscribe page when no bank was connected', function () {
config(['subscriptions.enabled' => true]); config(['subscriptions.enabled' => true]);
// Create an onboarded user with open-banking active and no banking connections
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
$this->actingAs($user); $this->actingAs($user);
Feature::for($user)->activate('open-banking');
$page = visit('/subscribe'); $page = visit('/subscribe');
$page->assertPathIs('/subscribe') $page->assertPathIs('/subscribe')

View File

@ -21,6 +21,8 @@ it('shows real estate fields when real estate type is selected', function () {
$page->waitForText('Create Account') $page->waitForText('Create Account')
->click('Create Account') ->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(1) ->wait(1)
->click('Select account type') ->click('Select account type')
->wait(1) ->wait(1)
@ -38,6 +40,8 @@ it('auto-calculates revaluation percentage from purchase data and current value'
$page->waitForText('Create Account') $page->waitForText('Create Account')
->click('Create Account') ->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(1) ->wait(1)
->click('Select account type') ->click('Select account type')
->wait(1) ->wait(1)
@ -73,6 +77,8 @@ it('manual revaluation percentage is preserved when balance changes', function (
$page->waitForText('Create Account') $page->waitForText('Create Account')
->click('Create Account') ->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(1) ->wait(1)
->click('Select account type') ->click('Select account type')
->wait(1) ->wait(1)
@ -115,6 +121,8 @@ it('creates real estate account and generates historical balances', function ()
$page->waitForText('Create Account') $page->waitForText('Create Account')
->click('Create Account') ->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(1) ->wait(1)
->fill('#display_name', 'My Investment Property') ->fill('#display_name', 'My Investment Property')
->click('Select account type') ->click('Select account type')
@ -207,7 +215,7 @@ it('edit dialog pre-fills purchase date in correct format', function () {
->click('[aria-label="Open menu"]') ->click('[aria-label="Open menu"]')
->wait(1) ->wait(1)
->click('Edit') ->click('Edit')
->wait(1) ->waitForText('Edit Account', 5)
->assertValue('#purchase_date', $purchaseDate) ->assertValue('#purchase_date', $purchaseDate)
->assertNoJavascriptErrors(); ->assertNoJavascriptErrors();
}); });
@ -217,6 +225,8 @@ it('redirects back to settings after creating an account', function () {
$page->waitForText('Create Account') $page->waitForText('Create Account')
->click('Create Account') ->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(1) ->wait(1)
->fill('#display_name', 'Test Redirect Account') ->fill('#display_name', 'Test Redirect Account')
->click('Select account type') ->click('Select account type')

View File

@ -7,7 +7,6 @@ use App\Models\Bank;
use App\Models\BankingConnection; use App\Models\BankingConnection;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
beforeEach(function () { beforeEach(function () {
config([ config([
@ -19,8 +18,6 @@ beforeEach(function () {
test('show returns mapping page with correct props', function () { test('show returns mapping page with correct props', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([ $connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id, 'user_id' => $user->id,
]); ]);
@ -39,8 +36,6 @@ test('show returns mapping page with correct props', function () {
test('show redirects if no pending accounts', function () { test('show redirects if no pending accounts', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create([ $connection = BankingConnection::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'pending_accounts_data' => null, 'pending_accounts_data' => null,
@ -55,8 +50,6 @@ test('show redirects if no pending accounts', function () {
test('show returns 403 for other user\'s connection', function () { test('show returns 403 for other user\'s connection', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create(); $otherUser = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([ $connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $otherUser->id, 'user_id' => $otherUser->id,
]); ]);
@ -71,8 +64,6 @@ test('store with action create creates new accounts', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([ $connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'aspsp_name' => 'Test Bank', 'aspsp_name' => 'Test Bank',
@ -119,8 +110,6 @@ test('store creates investment accounts for bitpanda connections', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([ $connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'provider' => 'bitpanda', 'provider' => 'bitpanda',
@ -162,8 +151,6 @@ test('store with action link links existing account', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$bank = Bank::factory()->create(); $bank = Bank::factory()->create();
$existingAccount = Account::factory()->create([ $existingAccount = Account::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
@ -211,8 +198,6 @@ test('store with action skip does nothing', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([ $connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'pending_accounts_data' => [ 'pending_accounts_data' => [
@ -250,8 +235,6 @@ test('store with mixed actions works correctly', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$bank = Bank::factory()->create(); $bank = Bank::factory()->create();
$existingAccount = Account::factory()->create([ $existingAccount = Account::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
@ -328,8 +311,6 @@ test('store with mixed actions works correctly', function () {
test('validation fails when linking without existing_account_id', function () { test('validation fails when linking without existing_account_id', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->awaitingMapping()->create([ $connection = BankingConnection::factory()->awaitingMapping()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'pending_accounts_data' => [ 'pending_accounts_data' => [

View File

@ -7,7 +7,6 @@ use App\Models\Account;
use App\Models\BankingConnection; use App\Models\BankingConnection;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
beforeEach(function () { beforeEach(function () {
config([ config([
@ -19,8 +18,6 @@ beforeEach(function () {
test('users can start bank authorization', function () { test('users can start bank authorization', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$mockProvider = Mockery::mock(BankingProviderInterface::class); $mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('startAuthorization') $mockProvider->shouldReceive('startAuthorization')
->once() ->once()
@ -52,8 +49,6 @@ test('free tier users cannot start bank authorization when subscriptions are ena
config(['subscriptions.enabled' => true]); config(['subscriptions.enabled' => true]);
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->postJson('/open-banking/authorize', [ $response = $this->actingAs($user)->postJson('/open-banking/authorize', [
'aspsp_name' => 'Test Bank', 'aspsp_name' => 'Test Bank',
'country' => 'ES', 'country' => 'ES',
@ -67,6 +62,37 @@ test('free tier users cannot start bank authorization when subscriptions are ena
]); ]);
}); });
test('users can start bank authorization during onboarding when subscriptions are enabled', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->notOnboarded()->create();
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('startAuthorization')
->once()
->andReturn([
'url' => 'https://bank.example.com/authorize',
'authorization_id' => 'auth-onboarding-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('subscribed users can start bank authorization when subscriptions are enabled', function () { test('subscribed users can start bank authorization when subscriptions are enabled', function () {
config(['subscriptions.enabled' => true]); config(['subscriptions.enabled' => true]);
@ -77,8 +103,6 @@ test('subscribed users can start bank authorization when subscriptions are enabl
'stripe_status' => 'active', 'stripe_status' => 'active',
'stripe_price' => 'price_test123', 'stripe_price' => 'price_test123',
]); ]);
Feature::for($user)->activate('open-banking');
$mockProvider = Mockery::mock(BankingProviderInterface::class); $mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('startAuthorization') $mockProvider->shouldReceive('startAuthorization')
->once() ->once()
@ -100,8 +124,6 @@ test('subscribed users can start bank authorization when subscriptions are enabl
test('authorization requires aspsp_name and country', function () { test('authorization requires aspsp_name and country', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->postJson('/open-banking/authorize', []); $response = $this->actingAs($user)->postJson('/open-banking/authorize', []);
$response->assertUnprocessable(); $response->assertUnprocessable();
@ -110,8 +132,6 @@ test('authorization requires aspsp_name and country', function () {
test('callback with error redirects with error message and deletes pending connection', function () { test('callback with error redirects with error message and deletes pending connection', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([ $connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id, 'user_id' => $user->id,
]); ]);
@ -128,8 +148,6 @@ test('callback with error redirects with error message and deletes pending conne
test('callback without code redirects with error', function () { test('callback without code redirects with error', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->get('/open-banking/callback'); $response = $this->actingAs($user)->get('/open-banking/callback');
$response->assertRedirect(route('settings.connections.index')); $response->assertRedirect(route('settings.connections.index'));
@ -140,8 +158,6 @@ test('callback with valid code stores pending accounts and redirects to mapping'
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([ $connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'aspsp_name' => 'Test Bank', 'aspsp_name' => 'Test Bank',
@ -192,8 +208,6 @@ test('callback with valid code stores pending accounts and redirects to mapping'
test('reauthorize returns 403 when user does not own the connection', function () { test('reauthorize returns 403 when user does not own the connection', function () {
$owner = User::factory()->onboarded()->create(); $owner = User::factory()->onboarded()->create();
$other = User::factory()->onboarded()->create(); $other = User::factory()->onboarded()->create();
Feature::for($other)->activate('open-banking');
$connection = BankingConnection::factory()->error()->create([ $connection = BankingConnection::factory()->error()->create([
'user_id' => $owner->id, 'user_id' => $owner->id,
]); ]);
@ -205,8 +219,6 @@ test('reauthorize returns 403 when user does not own the connection', function (
test('reauthorize returns 422 for non-EnableBanking connections', function () { test('reauthorize returns 422 for non-EnableBanking connections', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->indexaCapital()->error()->create([ $connection = BankingConnection::factory()->indexaCapital()->error()->create([
'user_id' => $user->id, 'user_id' => $user->id,
]); ]);
@ -219,8 +231,6 @@ test('reauthorize returns 422 for non-EnableBanking connections', function () {
test('reauthorize returns 422 for active connections', function () { test('reauthorize returns 422 for active connections', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create([ $connection = BankingConnection::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'status' => BankingConnectionStatus::Active, 'status' => BankingConnectionStatus::Active,
@ -234,8 +244,6 @@ test('reauthorize returns 422 for active connections', function () {
test('reauthorize starts new authorization and sets connection to pending for error connections', function () { test('reauthorize starts new authorization and sets connection to pending for error connections', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->error()->create([ $connection = BankingConnection::factory()->error()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'aspsp_name' => 'CaixaBank', 'aspsp_name' => 'CaixaBank',
@ -274,8 +282,6 @@ test('reauthorize starts new authorization and sets connection to pending for er
test('reauthorize starts new authorization for expired connections', function () { test('reauthorize starts new authorization for expired connections', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->expired()->create([ $connection = BankingConnection::factory()->expired()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'aspsp_name' => 'Santander', 'aspsp_name' => 'Santander',
@ -301,14 +307,29 @@ test('reauthorize starts new authorization for expired connections', function ()
expect($connection->authorization_id)->toBe('new-auth-id-789'); expect($connection->authorization_id)->toBe('new-auth-id-789');
}); });
test('free tier users cannot reauthorize after onboarding when subscriptions are enabled', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->error()->create([
'user_id' => $user->id,
'aspsp_name' => 'CaixaBank',
'aspsp_country' => 'ES',
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
]);
$response = $this->actingAs($user)->postJson("/open-banking/connections/{$connection->id}/reauthorize");
$response->assertStatus(402);
$response->assertJson(['redirect' => route('subscribe')]);
});
// Reconnect callback tests // Reconnect callback tests
test('callback with existing accounts updates session without creating new accounts', function () { test('callback with existing accounts updates session without creating new accounts', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([ $connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'aspsp_name' => 'CaixaBank', 'aspsp_name' => 'CaixaBank',
@ -361,8 +382,6 @@ test('callback with existing accounts skips mapping on reconnect', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([ $connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'aspsp_name' => 'CaixaBank', 'aspsp_name' => 'CaixaBank',
@ -403,8 +422,6 @@ test('reconnect callback updates external_account_id when enable banking issues
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([ $connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'aspsp_name' => 'CaixaBank', 'aspsp_name' => 'CaixaBank',
@ -448,8 +465,6 @@ test('reconnect callback matches accounts by iban before falling back to positio
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([ $connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'aspsp_name' => 'CaixaBank', 'aspsp_name' => 'CaixaBank',
@ -509,8 +524,6 @@ test('reconnect callback uses positional fallback for accounts without stored ib
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([ $connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'aspsp_name' => 'CaixaBank', 'aspsp_name' => 'CaixaBank',
@ -570,8 +583,6 @@ test('callback stores iban in pending accounts data', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([ $connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'aspsp_name' => 'Test Bank', 'aspsp_name' => 'Test Bank',

View File

@ -6,14 +6,11 @@ use App\Models\BankingConnection;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
test('users can connect a binance account with valid credentials', function () { test('users can connect a binance account with valid credentials', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.binance.com/api/v3/account*' => Http::response([ 'api.binance.com/api/v3/account*' => Http::response([
'balances' => [ 'balances' => [
@ -48,8 +45,6 @@ test('users can connect a binance account with valid credentials', function () {
test('invalid binance credentials return 422', function () { test('invalid binance credentials return 422', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.binance.com/api/v3/account*' => Http::response(['msg' => 'Invalid API-key'], 401), 'api.binance.com/api/v3/account*' => Http::response(['msg' => 'Invalid API-key'], 401),
]); ]);
@ -69,7 +64,9 @@ test('invalid binance credentials return 422', function () {
]); ]);
}); });
test('binance requires open-banking feature flag', function () { test('free tier users cannot connect a binance account after onboarding when subscriptions are enabled', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
$response = $this->actingAs($user)->postJson('/open-banking/binance/connect', [ $response = $this->actingAs($user)->postJson('/open-banking/binance/connect', [
@ -78,13 +75,27 @@ test('binance requires open-banking feature flag', function () {
'country' => 'ES', 'country' => 'ES',
]); ]);
$response->assertNotFound(); $response->assertStatus(402);
$response->assertJson(['redirect' => route('subscribe')]);
$this->assertDatabaseMissing('banking_connections', [
'user_id' => $user->id,
'provider' => 'binance',
]);
});
test('binance requires authentication', function () {
$response = $this->postJson('/open-banking/binance/connect', [
'api_key' => 'valid-test-api-key-12345',
'api_secret' => 'valid-test-api-secret-12345',
'country' => 'ES',
]);
$response->assertUnauthorized();
}); });
test('binance api_key and api_secret are required and must be at least 10 characters', function () { test('binance api_key and api_secret are required and must be at least 10 characters', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$this->actingAs($user)->postJson('/open-banking/binance/connect', []) $this->actingAs($user)->postJson('/open-banking/binance/connect', [])
->assertUnprocessable() ->assertUnprocessable()
->assertJsonValidationErrors(['api_key', 'api_secret', 'country']); ->assertJsonValidationErrors(['api_key', 'api_secret', 'country']);
@ -102,8 +113,6 @@ test('binance stores pending accounts with user currency', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'USD']); $user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.binance.com/api/v3/account*' => Http::response([ 'api.binance.com/api/v3/account*' => Http::response([
'balances' => [ 'balances' => [
@ -128,11 +137,11 @@ test('binance stores pending accounts with user currency', function () {
}); });
test('binance auto-creates accounts during onboarding', function () { test('binance auto-creates accounts during onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake(); Queue::fake();
$user = User::factory()->notOnboarded()->create(['currency_code' => 'EUR']); $user = User::factory()->notOnboarded()->create(['currency_code' => 'EUR']);
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.binance.com/api/v3/account*' => Http::response([ 'api.binance.com/api/v3/account*' => Http::response([
'balances' => [ 'balances' => [

View File

@ -6,14 +6,11 @@ use App\Models\BankingConnection;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
test('users can connect a bitpanda account with valid credentials', function () { test('users can connect a bitpanda account with valid credentials', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.bitpanda.com/v1/wallets' => Http::response([ 'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [ 'data' => [
@ -58,8 +55,6 @@ test('users can connect a bitpanda account with valid credentials', function ()
test('invalid bitpanda credentials return 422', function () { test('invalid bitpanda credentials return 422', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.bitpanda.com/v1/wallets' => Http::response(['error' => 'Unauthorized'], 401), 'api.bitpanda.com/v1/wallets' => Http::response(['error' => 'Unauthorized'], 401),
]); ]);
@ -78,7 +73,9 @@ test('invalid bitpanda credentials return 422', function () {
]); ]);
}); });
test('bitpanda requires open-banking feature flag', function () { test('free tier users cannot connect a bitpanda account after onboarding when subscriptions are enabled', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
$response = $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [ $response = $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [
@ -86,13 +83,26 @@ test('bitpanda requires open-banking feature flag', function () {
'country' => 'ES', 'country' => 'ES',
]); ]);
$response->assertNotFound(); $response->assertStatus(402);
$response->assertJson(['redirect' => route('subscribe')]);
$this->assertDatabaseMissing('banking_connections', [
'user_id' => $user->id,
'provider' => 'bitpanda',
]);
});
test('bitpanda requires authentication', function () {
$response = $this->postJson('/open-banking/bitpanda/connect', [
'api_key' => 'valid-test-api-key-12345',
'country' => 'ES',
]);
$response->assertUnauthorized();
}); });
test('bitpanda api_key is required and must be at least 10 characters', function () { test('bitpanda api_key is required and must be at least 10 characters', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$this->actingAs($user)->postJson('/open-banking/bitpanda/connect', []) $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [])
->assertUnprocessable() ->assertUnprocessable()
->assertJsonValidationErrors(['api_key', 'country']); ->assertJsonValidationErrors(['api_key', 'country']);
@ -109,8 +119,6 @@ test('bitpanda stores pending accounts with user currency', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'USD']); $user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.bitpanda.com/v1/wallets' => Http::response([ 'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [ 'data' => [
@ -145,11 +153,11 @@ test('bitpanda stores pending accounts with user currency', function () {
}); });
test('bitpanda auto-creates accounts during onboarding', function () { test('bitpanda auto-creates accounts during onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake(); Queue::fake();
$user = User::factory()->notOnboarded()->create(['currency_code' => 'EUR']); $user = User::factory()->notOnboarded()->create(['currency_code' => 'EUR']);
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.bitpanda.com/v1/wallets' => Http::response([ 'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [ 'data' => [

View File

@ -8,7 +8,6 @@ use App\Models\BankingConnection;
use App\Models\Transaction; use App\Models\Transaction;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
beforeEach(function () { beforeEach(function () {
config([ config([
@ -20,8 +19,6 @@ beforeEach(function () {
test('users can view their connections page', function () { test('users can view their connections page', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->create(['user_id' => $user->id]); BankingConnection::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get('/settings/connections'); $response = $this->actingAs($user)->get('/settings/connections');
@ -36,8 +33,6 @@ test('users can view their connections page', function () {
test('connections page only shows own connections', function () { test('connections page only shows own connections', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
$otherUser = 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' => $user->id]);
BankingConnection::factory()->create(['user_id' => $otherUser->id]); BankingConnection::factory()->create(['user_id' => $otherUser->id]);
@ -51,8 +46,6 @@ test('connections page only shows own connections', function () {
test('users can disconnect a banking connection and keep accounts as manual', function () { test('users can disconnect a banking connection and keep accounts as manual', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $user->id]); $connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create([ $account = Account::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
@ -93,8 +86,6 @@ test('users can disconnect a banking connection and keep accounts as manual', fu
test('users can disconnect a banking connection and delete accounts', function () { test('users can disconnect a banking connection and delete accounts', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $user->id]); $connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create([ $account = Account::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
@ -131,8 +122,6 @@ test('users can disconnect a banking connection and delete accounts', function (
test('deleting accounts requires confirmation text', function () { test('deleting accounts requires confirmation text', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $user->id]); $connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [ $response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
@ -147,8 +136,6 @@ test('deleting accounts requires confirmation text', function () {
test('users cannot disconnect another users connection', function () { test('users cannot disconnect another users connection', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create(); $otherUser = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $otherUser->id]); $connection = BankingConnection::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [ $response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
@ -162,8 +149,6 @@ test('users can trigger manual sync on active connection', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create([ $connection = BankingConnection::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'status' => BankingConnectionStatus::Active, 'status' => BankingConnectionStatus::Active,
@ -175,10 +160,26 @@ test('users can trigger manual sync on active connection', function () {
$response->assertSessionHas('success'); $response->assertSessionHas('success');
}); });
test('free tier users are redirected to subscribe when syncing a connection after onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'status' => BankingConnectionStatus::Active,
]);
$response = $this->actingAs($user)->post("/settings/connections/{$connection->id}/sync");
$response->assertRedirect(route('subscribe'));
Queue::assertNothingPushed();
});
test('disconnecting indexa capital connection does not revoke session', function () { test('disconnecting indexa capital connection does not revoke session', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->indexaCapital()->create(['user_id' => $user->id]); $connection = BankingConnection::factory()->indexaCapital()->create(['user_id' => $user->id]);
$account = Account::factory()->create([ $account = Account::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
@ -210,8 +211,6 @@ test('users cannot sync expired connection', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->expired()->create([ $connection = BankingConnection::factory()->expired()->create([
'user_id' => $user->id, 'user_id' => $user->id,
]); ]);
@ -226,8 +225,6 @@ test('users can update indexa capital credentials with valid token', function ()
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->indexaCapital()->error()->create([ $connection = BankingConnection::factory()->indexaCapital()->error()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.', 'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
@ -252,12 +249,30 @@ test('users can update indexa capital credentials with valid token', function ()
expect($connection->api_token)->toBe('new-valid-indexa-token-12345'); expect($connection->api_token)->toBe('new-valid-indexa-token-12345');
}); });
test('free tier users are redirected to subscribe when updating credentials after onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->error()->create([
'user_id' => $user->id,
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_token' => 'new-valid-indexa-token-12345',
]);
$response->assertRedirect(route('subscribe'));
Queue::assertNothingPushed();
});
test('users can update binance credentials with valid api key and secret', function () { test('users can update binance credentials with valid api key and secret', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->binance()->error()->create([ $connection = BankingConnection::factory()->binance()->error()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.', 'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
@ -288,8 +303,6 @@ test('users can update bitpanda credentials with valid api key', function () {
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->bitpanda()->error()->create([ $connection = BankingConnection::factory()->bitpanda()->error()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.', 'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
@ -316,8 +329,6 @@ test('users can update bitpanda credentials with valid api key', function () {
test('updating credentials with invalid token returns validation error', function () { test('updating credentials with invalid token returns validation error', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->indexaCapital()->error()->create([ $connection = BankingConnection::factory()->indexaCapital()->error()->create([
'user_id' => $user->id, 'user_id' => $user->id,
]); ]);
@ -338,8 +349,6 @@ test('updating credentials with invalid token returns validation error', functio
test('cannot update credentials for enablebanking connection', function () { test('cannot update credentials for enablebanking connection', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create([ $connection = BankingConnection::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'provider' => 'enablebanking', 'provider' => 'enablebanking',
@ -355,8 +364,6 @@ test('cannot update credentials for enablebanking connection', function () {
test('cannot update credentials for another users connection', function () { test('cannot update credentials for another users connection', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create(); $otherUser = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->indexaCapital()->create([ $connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $otherUser->id, 'user_id' => $otherUser->id,
]); ]);
@ -368,24 +375,28 @@ test('cannot update credentials for another users connection', function () {
$response->assertForbidden(); $response->assertForbidden();
}); });
test('credential update requires feature flag', function () { test('users can update credentials for their own connection', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create([ $connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $user->id, 'user_id' => $user->id,
]); ]);
Http::fake([
'api.indexacapital.com/users/me' => Http::response([
'accounts' => [],
]),
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [ $response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_token' => 'some-token-value-12345', 'api_token' => 'some-token-value-12345',
]); ]);
$response->assertNotFound(); $response->assertRedirect();
}); });
test('credential update validates required fields for binance', function () { test('credential update validates required fields for binance', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->binance()->error()->create([ $connection = BankingConnection::factory()->binance()->error()->create([
'user_id' => $user->id, 'user_id' => $user->id,
]); ]);
@ -400,8 +411,6 @@ test('credential update validates required fields for binance', function () {
test('connections page includes provider and aspsp_name fields needed for frontend duplicate filtering', function () { test('connections page includes provider and aspsp_name fields needed for frontend duplicate filtering', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->create([ BankingConnection::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'provider' => 'enablebanking', 'provider' => 'enablebanking',
@ -420,8 +429,6 @@ test('connections page includes provider and aspsp_name fields needed for fronte
test('connections page includes connections from all provider types for frontend filtering', function () { test('connections page includes connections from all provider types for frontend filtering', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->create(['user_id' => $user->id, 'provider' => 'enablebanking', 'aspsp_name' => 'CaixaBank']); BankingConnection::factory()->create(['user_id' => $user->id, 'provider' => 'enablebanking', 'aspsp_name' => 'CaixaBank']);
BankingConnection::factory()->binance()->create(['user_id' => $user->id]); BankingConnection::factory()->binance()->create(['user_id' => $user->id]);
BankingConnection::factory()->bitpanda()->create(['user_id' => $user->id]); BankingConnection::factory()->bitpanda()->create(['user_id' => $user->id]);

View File

@ -7,7 +7,6 @@ use App\Models\BankingConnection;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
beforeEach(function () { beforeEach(function () {
Bank::factory()->create([ Bank::factory()->create([
@ -21,8 +20,6 @@ test('users can connect an indexa capital account with valid token', function ()
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.indexacapital.com/users/me' => Http::response([ 'api.indexacapital.com/users/me' => Http::response([
'accounts' => [ 'accounts' => [
@ -54,8 +51,6 @@ test('users can connect an indexa capital account with valid token', function ()
test('invalid token returns 422', function () { test('invalid token returns 422', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.indexacapital.com/users/me' => Http::response(['message' => 'Unauthorized'], 401), 'api.indexacapital.com/users/me' => Http::response(['message' => 'Unauthorized'], 401),
]); ]);
@ -73,20 +68,34 @@ test('invalid token returns 422', function () {
]); ]);
}); });
test('requires open-banking feature flag', function () { test('free tier users cannot connect an indexa capital account after onboarding when subscriptions are enabled', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
$response = $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [ $response = $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [
'api_token' => 'valid-test-token-12345', 'api_token' => 'valid-test-token-12345',
]); ]);
$response->assertNotFound(); $response->assertStatus(402);
$response->assertJson(['redirect' => route('subscribe')]);
$this->assertDatabaseMissing('banking_connections', [
'user_id' => $user->id,
'provider' => 'indexacapital',
]);
});
test('indexa capital requires authentication', function () {
$response = $this->postJson('/open-banking/indexa-capital/connect', [
'api_token' => 'valid-test-token-12345',
]);
$response->assertUnauthorized();
}); });
test('api_token is required and must be at least 10 characters', function () { test('api_token is required and must be at least 10 characters', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', []) $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [])
->assertUnprocessable() ->assertUnprocessable()
->assertJsonValidationErrors(['api_token']); ->assertJsonValidationErrors(['api_token']);
@ -102,8 +111,6 @@ test('stores multiple pending accounts for multiple indexa portfolios', function
Queue::fake(); Queue::fake();
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.indexacapital.com/users/me' => Http::response([ 'api.indexacapital.com/users/me' => Http::response([
'accounts' => [ 'accounts' => [
@ -130,11 +137,11 @@ test('stores multiple pending accounts for multiple indexa portfolios', function
}); });
test('indexa capital auto-creates accounts during onboarding', function () { test('indexa capital auto-creates accounts during onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake(); Queue::fake();
$user = User::factory()->notOnboarded()->create(); $user = User::factory()->notOnboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([ Http::fake([
'api.indexacapital.com/users/me' => Http::response([ 'api.indexacapital.com/users/me' => Http::response([
'accounts' => [ 'accounts' => [

View File

@ -2,11 +2,9 @@
use App\Contracts\BankingProviderInterface; use App\Contracts\BankingProviderInterface;
use App\Models\User; use App\Models\User;
use Laravel\Pennant\Feature;
test('authenticated users with feature flag can list institutions', function () { test('authenticated users can list institutions', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$mockProvider = Mockery::mock(BankingProviderInterface::class); $mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('getInstitutions') $mockProvider->shouldReceive('getInstitutions')
@ -28,7 +26,6 @@ test('authenticated users with feature flag can list institutions', function ()
test('institutions endpoint requires country parameter', function () { test('institutions endpoint requires country parameter', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->getJson('/open-banking/institutions'); $response = $this->actingAs($user)->getJson('/open-banking/institutions');
@ -37,7 +34,6 @@ test('institutions endpoint requires country parameter', function () {
test('institutions endpoint requires valid country code length', function () { test('institutions endpoint requires valid country code length', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->getJson('/open-banking/institutions?country=SPAIN'); $response = $this->actingAs($user)->getJson('/open-banking/institutions?country=SPAIN');

View File

@ -1,114 +1,23 @@
<?php <?php
use App\Models\User; test('guests cannot access institutions route', function () {
use Laravel\Pennant\Feature; $this->getJson('/open-banking/institutions?country=ES')
->assertUnauthorized();
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 () { test('guests cannot access authorize route', function () {
$user = User::factory()->onboarded()->create(); $this->postJson('/open-banking/authorize', [
Feature::for($user)->deactivate('open-banking');
$response = $this->actingAs($user)->post('/open-banking/authorize', [
'aspsp_name' => 'Test Bank', 'aspsp_name' => 'Test Bank',
'country' => 'ES', 'country' => 'ES',
]); ])->assertUnauthorized();
$response->assertNotFound();
}); });
test('users without open-banking feature get 404 on callback', function () { test('guests are redirected away from callback route', function () {
$user = User::factory()->onboarded()->create(); $this->get('/open-banking/callback?code=test')
->assertRedirect(route('login'));
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 () { test('guests are redirected away from connections index', function () {
$user = User::factory()->onboarded()->create(); $this->get('/settings/connections')
->assertRedirect(route('login'));
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)
);
});
test('open-banking feature flag is shared with frontend on onboarding page when enabled', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->get('/onboarding');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('features.open-banking', true)
);
});
test('open-banking feature flag is shared with frontend on onboarding page when disabled', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
Feature::for($user)->deactivate('open-banking');
$response = $this->actingAs($user)->get('/onboarding');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('features.open-banking', false)
);
}); });

View File

@ -515,7 +515,6 @@ test('scheduled sync excludes error connections with expired valid_until', funct
test('manual sync resets consecutive sync failures', function () { test('manual sync resets consecutive sync failures', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->error()->create([ $connection = BankingConnection::factory()->error()->create([
'user_id' => $user->id, 'user_id' => $user->id,

View File

@ -8,7 +8,6 @@ use App\Models\Category;
use App\Models\Transaction; use App\Models\Transaction;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Laravel\Pennant\Feature;
beforeEach(function () { beforeEach(function () {
config(['subscriptions.enabled' => true]); config(['subscriptions.enabled' => true]);
@ -213,30 +212,24 @@ test('pricing config includes all plan details', function () {
); );
}); });
test('open banking users without bank connections are redirected to paywall on first visit', function () { test('users without bank connections are redirected to paywall on first visit', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$this->actingAs($user); $this->actingAs($user);
$this->get(route('dashboard'))->assertRedirect(route('subscribe')); $this->get(route('dashboard'))->assertRedirect(route('subscribe'));
}); });
test('open banking users without bank connections can access protected routes after seeing paywall', function () { test('users without bank connections can access protected routes after seeing paywall', function () {
$user = User::factory()->onboarded()->create(['paywall_seen_at' => now()]); $user = User::factory()->onboarded()->create(['paywall_seen_at' => now()]);
Feature::for($user)->activate('open-banking');
$this->actingAs($user); $this->actingAs($user);
$this->get(route('dashboard'))->assertOk(); $this->get(route('dashboard'))->assertOk();
}); });
test('open banking users with a bank connection are redirected to paywall', function () { test('users with a bank connection are redirected to paywall', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->for($user)->create(); BankingConnection::factory()->for($user)->create();
$this->actingAs($user); $this->actingAs($user);
@ -244,11 +237,9 @@ test('open banking users with a bank connection are redirected to paywall', func
$this->get(route('dashboard'))->assertRedirect(route('subscribe')); $this->get(route('dashboard'))->assertRedirect(route('subscribe'));
}); });
test('paywall shows canUseFreePlan true when open banking is active and no bank connected', function () { test('paywall shows canUseFreePlan true when no bank is connected', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$this->actingAs($user); $this->actingAs($user);
$this->get(route('subscribe')) $this->get(route('subscribe'))
@ -259,10 +250,8 @@ test('paywall shows canUseFreePlan true when open banking is active and no bank
); );
}); });
test('paywall shows canUseFreePlan false when open banking is active but user has a bank connection', function () { test('paywall shows canUseFreePlan false when user has a bank connection', function () {
$user = User::factory()->onboarded()->create(); $user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->for($user)->create(); BankingConnection::factory()->for($user)->create();
$this->actingAs($user); $this->actingAs($user);
@ -275,19 +264,6 @@ test('paywall shows canUseFreePlan false when open banking is active but user ha
); );
}); });
test('paywall shows canUseFreePlan false when open banking is not active', function () {
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
$this->get(route('subscribe'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('subscription/paywall')
->where('canUseFreePlan', false)
);
});
test('billing portal creates stripe customer when user has no stripe id', function () { test('billing portal creates stripe customer when user has no stripe id', function () {
$user = Mockery::mock(User::class)->shouldIgnoreMissing(); $user = Mockery::mock(User::class)->shouldIgnoreMissing();
$user->shouldReceive('isDemoAccount')->andReturn(false); $user->shouldReceive('isDemoAccount')->andReturn(false);

View File

@ -5,7 +5,6 @@ use App\Models\Bank;
use App\Models\BankingConnection; use App\Models\BankingConnection;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Laravel\Pennant\Feature;
beforeEach(function () { beforeEach(function () {
Cache::forget('popular-banks'); Cache::forget('popular-banks');
@ -13,7 +12,6 @@ beforeEach(function () {
test('home popular banks are ordered by popularity and then Spain first', function () { test('home popular banks are ordered by popularity and then Spain first', function () {
$user = User::factory()->create(); $user = User::factory()->create();
Feature::for($user)->activate('open-banking');
$mostPopularNonSpanish = Bank::factory()->create([ $mostPopularNonSpanish = Bank::factory()->create([
'name' => 'Apex Banque', 'name' => 'Apex Banque',
@ -68,23 +66,22 @@ test('home popular banks are ordered by popularity and then Spain first', functi
); );
}); });
test('home returns empty popular banks when open-banking feature is inactive', function () { test('home returns popular banks for unauthenticated visitors', function () {
$user = User::factory()->create(); $bank = Bank::factory()->create([
// open-banking is inactive by default 'name' => 'Public Bank',
'logo' => 'https://example.com/public.png',
'user_id' => null,
]);
$connection = BankingConnection::factory()->create([
'aspsp_name' => $bank->name,
'aspsp_country' => 'ES',
]);
Account::factory()->for($bank)->for($connection, 'bankingConnection')->create();
$this->actingAs($user)->get(route('home'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('welcome')
->where('popularBanks', [])
);
});
test('home returns empty popular banks for unauthenticated visitors', function () {
$this->get(route('home')) $this->get(route('home'))
->assertOk() ->assertOk()
->assertInertia(fn ($page) => $page ->assertInertia(fn ($page) => $page
->component('welcome') ->component('welcome')
->where('popularBanks', []) ->where('popularBanks.0.name', $bank->name)
); );
}); });

View File

@ -158,6 +158,8 @@ function createAccountViaUI($page, string $displayName, string $bankName, string
{ {
$page->assertSee('Bank accounts'); $page->assertSee('Bank accounts');
$page->click('Create Account') $page->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(0.5) ->wait(0.5)
->fill('#display_name', $displayName) ->fill('#display_name', $displayName)
->click('[data-testid="bank-select"]') ->click('[data-testid="bank-select"]')