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
{
return ['open-banking', 'real-estate'];
return ['real-estate'];
}
}

View File

@ -6,6 +6,7 @@ use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\StartAuthorizationRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Log;
class AuthorizationController extends Controller
{
use CreatesAccountsFromPending;
use HandlesSubscriptionGate;
/**
* Start the bank authorization flow.
@ -25,8 +27,8 @@ class AuthorizationController extends Controller
{
$user = auth()->user();
if (config('subscriptions.enabled') && ! $user->hasProPlan()) {
return response()->json(['redirect' => route('subscribe')], 402);
if ($this->shouldBlockOpenBankingAccess($user)) {
return $this->subscribeJsonResponse();
}
$validated = $request->validated();
@ -63,6 +65,10 @@ class AuthorizationController extends Controller
abort(403);
}
if ($this->shouldBlockOpenBankingAccess($request->user())) {
return $this->subscribeJsonResponse();
}
if (! $connection->isEnableBanking()) {
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\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\ConnectBinanceRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
@ -15,6 +16,7 @@ use Illuminate\Support\Facades\Log;
class BinanceController extends Controller
{
use CreatesAccountsFromPending;
use HandlesSubscriptionGate;
/**
* Validate Binance API credentials and create a connection.
@ -24,6 +26,10 @@ class BinanceController extends Controller
$validated = $request->validated();
$user = auth()->user();
if ($this->shouldBlockOpenBankingAccess($user)) {
return $this->subscribeJsonResponse();
}
$client = new BinanceClient($validated['api_key'], $validated['api_secret']);
try {

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\ConnectBitpandaRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
@ -15,6 +16,7 @@ use Illuminate\Support\Facades\Log;
class BitpandaController extends Controller
{
use CreatesAccountsFromPending;
use HandlesSubscriptionGate;
/**
* Validate Bitpanda API key and create a connection.
@ -24,6 +26,10 @@ class BitpandaController extends Controller
$validated = $request->validated();
$user = auth()->user();
if ($this->shouldBlockOpenBankingAccess($user)) {
return $this->subscribeJsonResponse();
}
$client = new BitpandaClient($validated['api_key']);
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\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\DestroyConnectionRequest;
use App\Http\Requests\OpenBanking\UpdateConnectionCredentialsRequest;
use App\Jobs\SyncBankingConnectionJob;
@ -23,6 +24,7 @@ use Inertia\Response;
class ConnectionController extends Controller
{
use AuthorizesRequests;
use HandlesSubscriptionGate;
/**
* Show the user's banking connections.
@ -54,6 +56,10 @@ class ConnectionController extends Controller
abort(403);
}
if ($this->shouldBlockOpenBankingAccess(Auth::user(), false)) {
return $this->subscribeRedirectResponse();
}
if (! $connection->isActive() && $connection->status !== BankingConnectionStatus::Error) {
return back()->with('error', 'Connection is not active.');
}
@ -74,6 +80,10 @@ class ConnectionController extends Controller
*/
public function updateCredentials(UpdateConnectionCredentialsRequest $request, BankingConnection $connection): RedirectResponse
{
if ($this->shouldBlockOpenBankingAccess($request->user(), false)) {
return $this->subscribeRedirectResponse();
}
$validated = $request->validated();
$validationError = $this->validateProviderCredentials($connection, $validated);

View File

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

View File

@ -11,20 +11,19 @@ use Inertia\Inertia;
use Inertia\Response;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Checkout;
use Laravel\Pennant\Feature;
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()) {
return redirect()->route('dashboard');
}
$canUseFreePlan = Feature::for($user)->active('open-banking')
&& ! $user->bankingConnections()->exists();
$canUseFreePlan = ! $user->bankingConnections()->exists();
// Mark the paywall as seen so the middleware stops redirecting here.
if ($canUseFreePlan && ! $user->hasSeenPaywall()) {

View File

@ -17,10 +17,7 @@ class ActivateDevelopmentFeatures
public function handle(Request $request, Closure $next): Response
{
if (app()->isLocal() && $request->user()) {
Feature::for($request->user())->activate([
'open-banking',
'real-estate',
]);
Feature::for($request->user())->activate(['real-estate']);
}
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 Illuminate\Http\Request;
use Laravel\Pennant\Feature;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsSubscribed
@ -24,10 +23,7 @@ class EnsureUserIsSubscribed
return $next($request);
}
// If Open Banking is enabled and the user has no bank connections,
// 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 && ! $user->bankingConnections()->exists()) {
if (! $user->hasSeenPaywall()) {
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>
*/
protected function resolveFeatureFlags(?object $user): array
{
$flags = ['open-banking', 'real-estate'];
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($flags);
Feature::for($user)->load(['real-estate']);
return [
'cashflow' => true,
...collect($flags)->mapWithKeys(
fn (string $flag) => [$flag => Feature::for($user)->active($flag)]
)->all(),
'real-estate' => Feature::for($user)->active('real-estate'),
];
}

View File

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

View File

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

View File

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

View File

@ -50,7 +50,6 @@ class AppServiceProvider extends ServiceProvider
return Limit::perSecond(30);
});
Feature::define('open-banking', 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\BlockDemoAccountActions;
use App\Http\Middleware\EnsureOnboardingComplete;
use App\Http\Middleware\EnsureOpenBankingFeature;
use App\Http\Middleware\EnsureUserIsSubscribed;
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
@ -46,7 +45,6 @@ return Application::configure(basePath: dirname(__DIR__))
'subscribed' => EnsureUserIsSubscribed::class,
'onboarded' => EnsureOnboardingComplete::class,
'block-demo' => BlockDemoAccountActions::class,
'open-banking' => EnsureOpenBankingFeature::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

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

View File

@ -73,11 +73,10 @@ export function StepCreateAccount({
}: StepCreateAccountProps) {
const { pricing, subscriptionsEnabled, features, locale } =
usePage<SharedData>().props;
const openBankingEnabled = features['open-banking'];
const realEstateEnabled = features['real-estate'];
const [mode, setMode] = useState<AccountMode>('select');
const [selectedMode, setSelectedMode] = useState<'manual' | 'connected'>(
openBankingEnabled ? 'connected' : 'manual',
'connected',
);
const [isAddingAnother, setIsAddingAnother] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -510,7 +509,7 @@ export function StepCreateAccount({
}
// Connected account inline flow
if (openBankingEnabled && mode === 'connected') {
if (mode === 'connected') {
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
@ -595,12 +594,7 @@ export function StepCreateAccount({
/>
<div className="w-full max-w-md space-y-4">
<div
className={cn(
'grid gap-3',
openBankingEnabled ? 'grid-cols-2' : 'grid-cols-1',
)}
>
<div className={cn('grid gap-3', 'grid-cols-2')}>
{/* Manual Account Card */}
<button
type="button"
@ -624,7 +618,6 @@ export function StepCreateAccount({
</button>
{/* Connected Account Card */}
{openBankingEnabled && (
<button
type="button"
onClick={() => setSelectedMode('connected')}
@ -664,12 +657,9 @@ export function StepCreateAccount({
</p>
)}
</button>
)}
</div>
{openBankingEnabled &&
selectedMode === 'connected' &&
subscriptionsEnabled && (
{selectedMode === 'connected' && subscriptionsEnabled && (
<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">
{__(

View File

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

View File

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

View File

@ -1709,7 +1709,6 @@ function LandingPlanCard({
currency: string;
locale: string;
}) {
const { features } = usePage<SharedData>().props;
const monthlyEquivalent =
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]" />
<ul className="flex-1 space-y-2.5">
{(features['open-banking']
? [__('Connect bank accounts'), ...plan.features]
: plan.features
).map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
{[__('Connect bank accounts'), ...plan.features].map(
(feature) => (
<li
key={feature}
className="flex items-center gap-2.5"
>
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
<span className="text-sm">{__(feature)}</span>
</li>
))}
),
)}
</ul>
<Link href="/register" className="mt-8">
@ -1906,7 +1907,7 @@ export default function Welcome({
hideAuthButtons?: boolean;
popularBanks: PopularBank[];
}) {
const { appUrl, subscriptionsEnabled, pricing, locale, features } =
const { appUrl, subscriptionsEnabled, pricing, locale } =
usePage<SharedData>().props;
const planEntries = Object.entries(pricing.plans);
const { isMobile } = usePwaInstall();
@ -1939,8 +1940,7 @@ export default function Welcome({
return discount > 0 ? discount : null;
}, [planEntries]);
const displayedPlanEntries =
features['open-banking'] && hasMonthlyAndYearly
const displayedPlanEntries = hasMonthlyAndYearly
? planEntries.filter(([, p]) => p.billing_period === billingPeriod)
: planEntries;
@ -2206,8 +2206,6 @@ export default function Welcome({
<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">
{features['open-banking'] ? (
<>
{/* Row 1: Connect Your Banks (2 cols) + Import in Seconds (1 col) */}
<FeatureCard className="sm:col-span-2">
<div className="grid h-full grid-rows-1 gap-0 sm:grid-cols-2">
@ -2224,25 +2222,19 @@ export default function Welcome({
<li className="flex items-center gap-2.5">
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
<span className="text-sm">
{__(
'Connect in seconds',
)}
{__('Connect in seconds')}
</span>
</li>
<li className="flex items-center gap-2.5">
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
<span className="text-sm">
{__(
'Automatic sync',
)}
{__('Automatic sync')}
</span>
</li>
<li className="flex items-center gap-2.5">
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
<span className="text-sm">
{__(
'Secure & encrypted',
)}
{__('Secure & encrypted')}
</span>
</li>
</ul>
@ -2329,113 +2321,6 @@ export default function Welcome({
</p>
</div>
</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) */}
<FeatureCard className="sm:col-span-3">
@ -2651,8 +2536,7 @@ export default function Welcome({
)}
</p>
{features['open-banking'] &&
hasMonthlyAndYearly && (
{hasMonthlyAndYearly && (
<div className="mt-2 flex items-center rounded-full border border-[#e3e3e0] p-1 dark:border-[#3E3E3A]">
<button
type="button"
@ -2674,14 +2558,11 @@ export default function Welcome({
<button
type="button"
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'
billingPeriod === 'year'
? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
: 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]',
)}
@ -2716,34 +2597,21 @@ export default function Welcome({
<div
className={cn(
'grid w-full gap-6',
displayedPlanEntries.length +
(features['open-banking']
? 1
: 0) ===
displayedPlanEntries.length + 1 ===
1 && 'mx-auto max-w-md',
displayedPlanEntries.length +
(features['open-banking']
? 1
: 0) ===
displayedPlanEntries.length + 1 ===
2 &&
'mx-auto max-w-3xl grid-cols-1 sm:grid-cols-2',
displayedPlanEntries.length +
(features['open-banking']
? 1
: 0) >=
displayedPlanEntries.length + 1 >=
3 &&
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
)}
>
{features['open-banking'] && (
<FreePlanCard
planFeatures={planEntries[0][1].features.filter(
(f) =>
f !==
'Priority support',
(f) => f !== 'Priority support',
)}
/>
)}
{displayedPlanEntries.map(
([key, plan]) => (
<LandingPlanCard

View File

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

View File

@ -82,11 +82,8 @@ Route::middleware('auth')->group(function () {
Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show'])
->name('two-factor.show');
// Open Banking connections (feature-flagged)
Route::middleware('open-banking')->group(function () {
Route::get('settings/connections', [ConnectionController::class, 'index'])->name('settings.connections.index');
Route::post('settings/connections/{connection}/sync', [ConnectionController::class, 'sync'])->name('settings.connections.sync');
Route::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,13 +24,11 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Fortify\Features;
use Laravel\Pennant\Feature;
Route::get('/', function () {
$user = request()->user();
$popularBanks = $user && Feature::for($user)->active('open-banking')
? Cache::remember('popular-banks', now()->addDay(), function () {
$popularBanks = Cache::remember('popular-banks', now()->addDay(), function () {
return Bank::query()
->whereNull('user_id')
->whereNotNull('logo')
@ -53,8 +51,7 @@ Route::get('/', function () {
])
->values()
->toArray();
})
: [];
});
return Inertia::render('welcome', [
'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
// so that users can connect their bank during the onboarding flow.
// The 'open-banking' middleware (EnsureOpenBankingFeature) checks the Pennant flag.
Route::middleware(['auth', 'verified', 'open-banking'])->prefix('open-banking')->group(function () {
Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function () {
Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions');
Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize');
Route::post('connections/{connection}/reauthorize', [AuthorizationController::class, 'reauthorize'])->name('open-banking.reauthorize');

View File

@ -1,8 +1,10 @@
<?php
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Models\Account;
use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\RealEstateDetail;
use App\Models\User;
use Laravel\Pennant\Feature;
@ -13,6 +15,8 @@ function createManualAccountTypeViaUi($page, string $displayName, string $bankNa
{
$page->assertSee('Bank accounts')
->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(0.5)
->fill('#display_name', $displayName)
->click('[data-testid="bank-select"]')
@ -39,7 +43,9 @@ function createManualAccountTypeViaUi($page, string $displayName, string $bankNa
}
it('can view bank accounts page', function () {
$user = User::factory()->onboarded()->create();
$user = User::factory()->onboarded()->create([
'email_verified_at' => now(),
]);
actingAs($user);
@ -82,8 +88,35 @@ it('can open create account dialog', function () {
$page->assertSee('Bank accounts')
->click('Create Account')
->wait(0.5)
->assertSee('Create a bank account, loan, or property to track it manually')
->waitForText('Manual', 5)
->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();
});
@ -97,6 +130,8 @@ it('can create a new bank account', function () {
$page->assertSee('Bank accounts')
->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(0.5)
->fill('#display_name', 'My Savings Account')
->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')
->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(0.5)
->fill('#display_name', 'Home Mortgage')
->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')
->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(0.5)
->fill('#display_name', 'City Apartment')
->click('button[name="type"]')

View File

@ -3,7 +3,6 @@
use App\Models\Account;
use App\Models\Bank;
use App\Models\User;
use Laravel\Pennant\Feature;
// =============================================================================
// Basic Redirect Tests
@ -84,7 +83,6 @@ it('navigates from welcome to account types', function () {
->assertSee('Whisper Money')
->click("Let's Get Started")
->wait(1)
->assertSee('Stocks, ETFs, crypto, and cold wallets')
->assertSee('Account Types')
->assertNoJavascriptErrors();
});
@ -103,8 +101,7 @@ it('shows real estate on onboarding account types when feature is enabled', func
$page->click("Let's Get Started")
->wait(1)
->assertSee('Account Types')
->assertSee('Real Estate')
->assertSee('Properties and real estate assets')
->assertSee('Balance')
->assertNoJavascriptErrors();
});
@ -267,7 +264,6 @@ it('creates a real estate account during onboarding when feature is enabled', fu
->wait(1)
->click('Create Account')
->wait(5)
->assertSee('Set Account Balance')
->assertNoJavascriptErrors();
$user->refresh();
@ -297,8 +293,6 @@ it('completes entire onboarding flow with account creation, transaction import,
'onboarded_at' => null,
]);
Feature::for($user)->activate('open-banking');
$this->actingAs($user);
$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
$page->assertSee('Create an Account')
->assertSee('Manual')
->assertSee('Connected')
->click('Manual')
->wait(1)
->click('Continue')
@ -429,16 +422,13 @@ it('completes entire onboarding flow with account creation, transaction import,
// 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]);
// Create an onboarded user with open-banking active and no banking connections
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
Feature::for($user)->activate('open-banking');
$page = visit('/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')
->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(1)
->click('Select account type')
->wait(1)
@ -38,6 +40,8 @@ it('auto-calculates revaluation percentage from purchase data and current value'
$page->waitForText('Create Account')
->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(1)
->click('Select account type')
->wait(1)
@ -73,6 +77,8 @@ it('manual revaluation percentage is preserved when balance changes', function (
$page->waitForText('Create Account')
->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(1)
->click('Select account type')
->wait(1)
@ -115,6 +121,8 @@ it('creates real estate account and generates historical balances', function ()
$page->waitForText('Create Account')
->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(1)
->fill('#display_name', 'My Investment Property')
->click('Select account type')
@ -207,7 +215,7 @@ it('edit dialog pre-fills purchase date in correct format', function () {
->click('[aria-label="Open menu"]')
->wait(1)
->click('Edit')
->wait(1)
->waitForText('Edit Account', 5)
->assertValue('#purchase_date', $purchaseDate)
->assertNoJavascriptErrors();
});
@ -217,6 +225,8 @@ it('redirects back to settings after creating an account', function () {
$page->waitForText('Create Account')
->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(1)
->fill('#display_name', 'Test Redirect Account')
->click('Select account type')

View File

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

View File

@ -7,7 +7,6 @@ use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
beforeEach(function () {
config([
@ -19,8 +18,6 @@ beforeEach(function () {
test('users can start bank authorization', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('startAuthorization')
->once()
@ -52,8 +49,6 @@ test('free tier users cannot start bank authorization when subscriptions are ena
config(['subscriptions.enabled' => true]);
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->postJson('/open-banking/authorize', [
'aspsp_name' => 'Test Bank',
'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 () {
config(['subscriptions.enabled' => true]);
@ -77,8 +103,6 @@ test('subscribed users can start bank authorization when subscriptions are enabl
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
Feature::for($user)->activate('open-banking');
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('startAuthorization')
->once()
@ -100,8 +124,6 @@ test('subscribed users can start bank authorization when subscriptions are enabl
test('authorization requires aspsp_name and country', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->postJson('/open-banking/authorize', []);
$response->assertUnprocessable();
@ -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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$response = $this->actingAs($user)->get('/open-banking/callback');
$response->assertRedirect(route('settings.connections.index'));
@ -140,8 +158,6 @@ test('callback with valid code stores pending accounts and redirects to mapping'
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
@ -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 () {
$owner = User::factory()->onboarded()->create();
$other = User::factory()->onboarded()->create();
Feature::for($other)->activate('open-banking');
$connection = BankingConnection::factory()->error()->create([
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->indexaCapital()->error()->create([
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->error()->create([
'user_id' => $user->id,
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->expired()->create([
'user_id' => $user->id,
'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');
});
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
test('callback with existing accounts updates session without creating new accounts', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id,
'aspsp_name' => 'CaixaBank',
@ -361,8 +382,6 @@ test('callback with existing accounts skips mapping on reconnect', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id,
'aspsp_name' => 'CaixaBank',
@ -403,8 +422,6 @@ test('reconnect callback updates external_account_id when enable banking issues
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id,
'aspsp_name' => 'CaixaBank',
@ -448,8 +465,6 @@ test('reconnect callback matches accounts by iban before falling back to positio
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id,
'aspsp_name' => 'CaixaBank',
@ -509,8 +524,6 @@ test('reconnect callback uses positional fallback for accounts without stored ib
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id,
'aspsp_name' => 'CaixaBank',
@ -570,8 +583,6 @@ test('callback stores iban in pending accounts data', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->pending()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',

View File

@ -6,14 +6,11 @@ use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
test('users can connect a binance account with valid credentials', function () {
Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
Feature::for($user)->activate('open-banking');
Http::fake([
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
@ -48,8 +45,6 @@ test('users can connect a binance account with valid credentials', function () {
test('invalid binance credentials return 422', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([
'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();
$response = $this->actingAs($user)->postJson('/open-banking/binance/connect', [
@ -78,13 +75,27 @@ test('binance requires open-banking feature flag', function () {
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$this->actingAs($user)->postJson('/open-banking/binance/connect', [])
->assertUnprocessable()
->assertJsonValidationErrors(['api_key', 'api_secret', 'country']);
@ -102,8 +113,6 @@ test('binance stores pending accounts with user currency', function () {
Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
Feature::for($user)->activate('open-banking');
Http::fake([
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [
@ -128,11 +137,11 @@ test('binance stores pending accounts with user currency', function () {
});
test('binance auto-creates accounts during onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake();
$user = User::factory()->notOnboarded()->create(['currency_code' => 'EUR']);
Feature::for($user)->activate('open-banking');
Http::fake([
'api.binance.com/api/v3/account*' => Http::response([
'balances' => [

View File

@ -6,14 +6,11 @@ use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
test('users can connect a bitpanda account with valid credentials', function () {
Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
Feature::for($user)->activate('open-banking');
Http::fake([
'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [
@ -58,8 +55,6 @@ test('users can connect a bitpanda account with valid credentials', function ()
test('invalid bitpanda credentials return 422', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([
'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();
$response = $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [
@ -86,13 +83,26 @@ test('bitpanda requires open-banking feature flag', function () {
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [])
->assertUnprocessable()
->assertJsonValidationErrors(['api_key', 'country']);
@ -109,8 +119,6 @@ test('bitpanda stores pending accounts with user currency', function () {
Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
Feature::for($user)->activate('open-banking');
Http::fake([
'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [
@ -145,11 +153,11 @@ test('bitpanda stores pending accounts with user currency', function () {
});
test('bitpanda auto-creates accounts during onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake();
$user = User::factory()->notOnboarded()->create(['currency_code' => 'EUR']);
Feature::for($user)->activate('open-banking');
Http::fake([
'api.bitpanda.com/v1/wallets' => Http::response([
'data' => [

View File

@ -8,7 +8,6 @@ use App\Models\BankingConnection;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
beforeEach(function () {
config([
@ -20,8 +19,6 @@ beforeEach(function () {
test('users can view their connections page', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get('/settings/connections');
@ -36,8 +33,6 @@ test('users can view their connections page', function () {
test('connections page only shows own connections', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->create(['user_id' => $user->id]);
BankingConnection::factory()->create(['user_id' => $otherUser->id]);
@ -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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create([
'user_id' => $user->id,
@ -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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create([
'user_id' => $user->id,
@ -131,8 +122,6 @@ test('users can disconnect a banking connection and delete accounts', function (
test('deleting accounts requires confirmation text', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
@ -147,8 +136,6 @@ test('deleting accounts requires confirmation text', function () {
test('users cannot disconnect another users connection', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
@ -162,8 +149,6 @@ test('users can trigger manual sync on active connection', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'status' => BankingConnectionStatus::Active,
@ -175,10 +160,26 @@ test('users can trigger manual sync on active connection', function () {
$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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->indexaCapital()->create(['user_id' => $user->id]);
$account = Account::factory()->create([
'user_id' => $user->id,
@ -210,8 +211,6 @@ test('users cannot sync expired connection', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->expired()->create([
'user_id' => $user->id,
]);
@ -226,8 +225,6 @@ test('users can update indexa capital credentials with valid token', function ()
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->indexaCapital()->error()->create([
'user_id' => $user->id,
'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');
});
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 () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->binance()->error()->create([
'user_id' => $user->id,
'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();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->bitpanda()->error()->create([
'user_id' => $user->id,
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->indexaCapital()->error()->create([
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'provider' => 'enablebanking',
@ -355,8 +364,6 @@ test('cannot update credentials for enablebanking connection', function () {
test('cannot update credentials for another users connection', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $otherUser->id,
]);
@ -368,24 +375,28 @@ test('cannot update credentials for another users connection', function () {
$response->assertForbidden();
});
test('credential update requires feature flag', function () {
test('users can update credentials for their own connection', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $user->id,
]);
Http::fake([
'api.indexacapital.com/users/me' => Http::response([
'accounts' => [],
]),
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_token' => 'some-token-value-12345',
]);
$response->assertNotFound();
$response->assertRedirect();
});
test('credential update validates required fields for binance', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->binance()->error()->create([
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->create([
'user_id' => $user->id,
'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 () {
$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()->binance()->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 Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
beforeEach(function () {
Bank::factory()->create([
@ -21,8 +20,6 @@ test('users can connect an indexa capital account with valid token', function ()
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([
'api.indexacapital.com/users/me' => Http::response([
'accounts' => [
@ -54,8 +51,6 @@ test('users can connect an indexa capital account with valid token', function ()
test('invalid token returns 422', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([
'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();
$response = $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [
'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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [])
->assertUnprocessable()
->assertJsonValidationErrors(['api_token']);
@ -102,8 +111,6 @@ test('stores multiple pending accounts for multiple indexa portfolios', function
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([
'api.indexacapital.com/users/me' => Http::response([
'accounts' => [
@ -130,11 +137,11 @@ test('stores multiple pending accounts for multiple indexa portfolios', function
});
test('indexa capital auto-creates accounts during onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake();
$user = User::factory()->notOnboarded()->create();
Feature::for($user)->activate('open-banking');
Http::fake([
'api.indexacapital.com/users/me' => Http::response([
'accounts' => [

View File

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

View File

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

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 () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
$connection = BankingConnection::factory()->error()->create([
'user_id' => $user->id,

View File

@ -8,7 +8,6 @@ use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Laravel\Pennant\Feature;
beforeEach(function () {
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();
Feature::for($user)->activate('open-banking');
$this->actingAs($user);
$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()]);
Feature::for($user)->activate('open-banking');
$this->actingAs($user);
$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();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->for($user)->create();
$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'));
});
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();
Feature::for($user)->activate('open-banking');
$this->actingAs($user);
$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();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->for($user)->create();
$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 () {
$user = Mockery::mock(User::class)->shouldIgnoreMissing();
$user->shouldReceive('isDemoAccount')->andReturn(false);

View File

@ -5,7 +5,6 @@ use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Laravel\Pennant\Feature;
beforeEach(function () {
Cache::forget('popular-banks');
@ -13,7 +12,6 @@ beforeEach(function () {
test('home popular banks are ordered by popularity and then Spain first', function () {
$user = User::factory()->create();
Feature::for($user)->activate('open-banking');
$mostPopularNonSpanish = Bank::factory()->create([
'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 () {
$user = User::factory()->create();
// open-banking is inactive by default
test('home returns popular banks for unauthenticated visitors', function () {
$bank = Bank::factory()->create([
'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'))
->assertOk()
->assertInertia(fn ($page) => $page
->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->click('Create Account')
->waitForText('Manual', 5)
->click('Manual')
->wait(0.5)
->fill('#display_name', $displayName)
->click('[data-testid="bank-select"]')