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:
parent
63e472e8a4
commit
244344e953
|
|
@ -49,6 +49,6 @@ trait ResolvesFeatures
|
|||
|
||||
private function getStringBasedFeatures(): array
|
||||
{
|
||||
return ['open-banking', 'real-estate'];
|
||||
return ['real-estate'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
setMode('choice');
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{openBankingEnabled
|
||||
? __('Back')
|
||||
: __('Cancel')}
|
||||
{__('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -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,60 +618,56 @@ export function StepCreateAccount({
|
|||
</button>
|
||||
|
||||
{/* Connected Account Card */}
|
||||
{openBankingEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedMode('connected')}
|
||||
className={cn(
|
||||
'relative flex flex-col rounded-xl border p-4 text-left transition-all duration-200',
|
||||
selectedMode === 'connected'
|
||||
? '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',
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedMode('connected')}
|
||||
className={cn(
|
||||
'relative flex flex-col rounded-xl border p-4 text-left transition-all duration-200',
|
||||
selectedMode === 'connected'
|
||||
? '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',
|
||||
)}
|
||||
>
|
||||
{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',
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
</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>
|
||||
)}
|
||||
<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 &&
|
||||
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>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{openBankingEnabled &&
|
||||
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">
|
||||
{__(
|
||||
"Connected accounts are a Standard Plan feature. You'll choose a plan at the end of the onboarding.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
{__(
|
||||
"Connected accounts are a Standard Plan feature. You'll choose a plan at the end of the onboarding.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<StepButton
|
||||
|
|
|
|||
|
|
@ -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: 'Connections',
|
||||
href: '/settings/connections',
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
|
||||
<span className="text-sm">{__(feature)}</span>
|
||||
</li>
|
||||
))}
|
||||
{[__('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,10 +1940,9 @@ export default function Welcome({
|
|||
return discount > 0 ? discount : null;
|
||||
}, [planEntries]);
|
||||
|
||||
const displayedPlanEntries =
|
||||
features['open-banking'] && hasMonthlyAndYearly
|
||||
? planEntries.filter(([, p]) => p.billing_period === billingPeriod)
|
||||
: planEntries;
|
||||
const displayedPlanEntries = hasMonthlyAndYearly
|
||||
? planEntries.filter(([, p]) => p.billing_period === billingPeriod)
|
||||
: planEntries;
|
||||
|
||||
// Handle localStorage for language preference
|
||||
useEffect(() => {
|
||||
|
|
@ -2206,236 +2206,121 @@ 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">
|
||||
<div className="flex flex-col justify-center p-8 sm:p-12">
|
||||
<h2 className="text-3xl leading-tight font-semibold text-balance sm:text-4xl sm:leading-tight">
|
||||
{__('Connect Your Banks')}
|
||||
</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.',
|
||||
)}
|
||||
</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">
|
||||
{__(
|
||||
'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',
|
||||
)}
|
||||
</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',
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative min-h-[320px]">
|
||||
<BankConnectionsPreview
|
||||
banks={popularBanks}
|
||||
className="absolute inset-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureCard>
|
||||
{/* 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">
|
||||
<div className="flex flex-col justify-center p-8 sm:p-12">
|
||||
<h2 className="text-3xl leading-tight font-semibold text-balance sm:text-4xl sm:leading-tight">
|
||||
{__('Connect Your Banks')}
|
||||
</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.',
|
||||
)}
|
||||
</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">
|
||||
{__('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')}
|
||||
</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')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative min-h-[320px]">
|
||||
<BankConnectionsPreview
|
||||
banks={popularBanks}
|
||||
className="absolute inset-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard>
|
||||
<div className="p-2">
|
||||
<ImportPreview
|
||||
currency={pricing.currency}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 pt-4">
|
||||
<h3 className="text-xl font-semibold">
|
||||
{__('Import in Seconds')}
|
||||
</h3>
|
||||
<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.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</FeatureCard>
|
||||
<FeatureCard>
|
||||
<div className="p-2">
|
||||
<ImportPreview
|
||||
currency={pricing.currency}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 pt-4">
|
||||
<h3 className="text-xl font-semibold">
|
||||
{__('Import in Seconds')}
|
||||
</h3>
|
||||
<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.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</FeatureCard>
|
||||
|
||||
{/* Row 2: 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>
|
||||
{/* Row 2: 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">
|
||||
<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 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>
|
||||
</>
|
||||
)}
|
||||
<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 3: Cashflow at a Glance (always full width) */}
|
||||
<FeatureCard className="sm:col-span-3">
|
||||
|
|
@ -2651,99 +2536,82 @@ export default function Welcome({
|
|||
)}
|
||||
</p>
|
||||
|
||||
{features['open-banking'] &&
|
||||
hasMonthlyAndYearly && (
|
||||
<div className="mt-2 flex items-center rounded-full border border-[#e3e3e0] p-1 dark:border-[#3E3E3A]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setBillingPeriod(
|
||||
'month',
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
billingPeriod ===
|
||||
'month'
|
||||
? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
|
||||
: 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]',
|
||||
)}
|
||||
>
|
||||
{__('Monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setBillingPeriod(
|
||||
'year',
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
billingPeriod ===
|
||||
'year'
|
||||
? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
|
||||
: 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]',
|
||||
)}
|
||||
>
|
||||
{__('Yearly')}
|
||||
{yearlyDiscount !==
|
||||
null && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs font-semibold',
|
||||
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(
|
||||
yearlyDiscount,
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hasMonthlyAndYearly && (
|
||||
<div className="mt-2 flex items-center rounded-full border border-[#e3e3e0] p-1 dark:border-[#3E3E3A]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setBillingPeriod(
|
||||
'month',
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
billingPeriod ===
|
||||
'month'
|
||||
? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
|
||||
: 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]',
|
||||
)}
|
||||
>
|
||||
{__('Monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setBillingPeriod('year')
|
||||
}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
billingPeriod === 'year'
|
||||
? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
|
||||
: 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]',
|
||||
)}
|
||||
>
|
||||
{__('Yearly')}
|
||||
{yearlyDiscount !==
|
||||
null && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs font-semibold',
|
||||
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(
|
||||
yearlyDiscount,
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FreePlanCard
|
||||
planFeatures={planEntries[0][1].features.filter(
|
||||
(f) => f !== 'Priority support',
|
||||
)}
|
||||
/>
|
||||
{displayedPlanEntries.map(
|
||||
([key, plan]) => (
|
||||
<LandingPlanCard
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export interface NavDivider {
|
|||
|
||||
export interface Features {
|
||||
cashflow: boolean;
|
||||
'open-banking': boolean;
|
||||
'real-estate': boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,37 +24,34 @@ 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 () {
|
||||
return Bank::query()
|
||||
->whereNull('user_id')
|
||||
->whereNotNull('logo')
|
||||
->where('logo', '!=', '')
|
||||
->withCount('accounts')
|
||||
->withExists([
|
||||
'accounts as has_spanish_accounts' => fn ($query) => $query->whereHas(
|
||||
'bankingConnection',
|
||||
fn ($bankingConnectionQuery) => $bankingConnectionQuery->where('aspsp_country', 'ES')
|
||||
),
|
||||
])
|
||||
->orderByDesc('accounts_count')
|
||||
->orderByDesc('has_spanish_accounts')
|
||||
->orderBy('name')
|
||||
->limit(300)
|
||||
->get(['name', 'logo'])
|
||||
->map(fn (Bank $bank): array => [
|
||||
'name' => $bank->name,
|
||||
'logo' => $bank->logo,
|
||||
])
|
||||
->values()
|
||||
->toArray();
|
||||
})
|
||||
: [];
|
||||
$popularBanks = Cache::remember('popular-banks', now()->addDay(), function () {
|
||||
return Bank::query()
|
||||
->whereNull('user_id')
|
||||
->whereNotNull('logo')
|
||||
->where('logo', '!=', '')
|
||||
->withCount('accounts')
|
||||
->withExists([
|
||||
'accounts as has_spanish_accounts' => fn ($query) => $query->whereHas(
|
||||
'bankingConnection',
|
||||
fn ($bankingConnectionQuery) => $bankingConnectionQuery->where('aspsp_country', 'ES')
|
||||
),
|
||||
])
|
||||
->orderByDesc('accounts_count')
|
||||
->orderByDesc('has_spanish_accounts')
|
||||
->orderBy('name')
|
||||
->limit(300)
|
||||
->get(['name', 'logo'])
|
||||
->map(fn (Bank $bank): array => [
|
||||
'name' => $bank->name,
|
||||
'logo' => $bank->logo,
|
||||
])
|
||||
->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');
|
||||
|
|
|
|||
|
|
@ -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"]')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]')
|
||||
|
|
|
|||
Loading…
Reference in New Issue