diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts
index 6a58b9b0..a6fbcf21 100644
--- a/resources/js/types/index.d.ts
+++ b/resources/js/types/index.d.ts
@@ -1,9 +1,11 @@
import { InertiaLinkProps } from '@inertiajs/react';
import { LucideIcon } from 'lucide-react';
+import { ReactNode } from 'react';
import { UUID } from './uuid';
export interface Auth {
user: User;
+ hasProPlan: boolean;
}
export interface BreadcrumbItem {
@@ -20,7 +22,7 @@ export interface NavItem {
type: 'nav-item';
title: string;
href: NonNullable;
- icon?: LucideIcon | null;
+ icon?: LucideIcon | ReactNode | null;
isActive?: boolean;
}
@@ -38,6 +40,7 @@ export interface SharedData {
appUrl: string;
quote: { message: string; author: string };
auth: Auth;
+ subscriptionsEnabled: boolean;
sidebarOpen: boolean;
[key: string]: unknown;
}
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php
index 5bb0b47a..7b0eb986 100644
--- a/resources/views/app.blade.php
+++ b/resources/views/app.blade.php
@@ -44,6 +44,10 @@
+
+
+
+
@viteReactRefresh
@vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
@inertiaHead
diff --git a/routes/settings.php b/routes/settings.php
index 1adf3205..a5f65afa 100644
--- a/routes/settings.php
+++ b/routes/settings.php
@@ -6,6 +6,7 @@ use App\Http\Controllers\Settings\CategoryController;
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
+use App\Http\Controllers\SubscriptionController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@@ -44,6 +45,9 @@ Route::middleware('auth')->group(function () {
return Inertia::render('settings/appearance');
})->name('appearance.edit');
+ Route::get('settings/billing', [SubscriptionController::class, 'billing'])->name('settings.billing');
+ Route::get('settings/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('settings.billing.portal');
+
Route::get('settings/delete-account', function () {
return Inertia::render('settings/delete-account');
})->name('delete-account.edit');
diff --git a/routes/web.php b/routes/web.php
index 1d2294a1..ad48771f 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -6,6 +6,7 @@ use App\Http\Controllers\DashboardController;
use App\Http\Controllers\EncryptionController;
use App\Http\Controllers\RobotsController;
use App\Http\Controllers\SitemapController;
+use App\Http\Controllers\SubscriptionController;
use App\Http\Controllers\Sync\AccountBalanceSyncController;
use App\Http\Controllers\Sync\AccountSyncController;
use App\Http\Controllers\Sync\BankSyncController;
@@ -20,6 +21,7 @@ Route::get('/', function () {
return Inertia::render('welcome', [
'canRegister' => Features::enabled(Features::registration()),
'hideAuthButtons' => config('landing.hide_auth_buttons', false),
+ 'subscriptionsEnabled' => config('subscriptions.enabled', false),
]);
})->name('home');
@@ -37,13 +39,7 @@ Route::get('terms', function () {
})->name('terms');
if (config('landing.hide_auth_buttons')) {
- Route::match(['GET', 'POST'], 'login', fn () => abort(404));
Route::match(['GET', 'POST'], 'register', fn () => abort(404));
- Route::post('logout', fn () => abort(404));
- Route::get('forgot-password', fn () => abort(404));
- Route::post('forgot-password', fn () => abort(404));
- Route::get('reset-password/{token}', fn () => abort(404));
- Route::post('reset-password', fn () => abort(404));
}
Route::middleware(['auth'])->group(function () {
@@ -80,7 +76,14 @@ Route::middleware(['auth'])->group(function () {
});
});
-Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function () {
+Route::middleware(['auth', 'verified'])->group(function () {
+ Route::get('subscribe', [SubscriptionController::class, 'index'])->name('subscribe');
+ Route::get('subscribe/checkout', [SubscriptionController::class, 'checkout'])->name('subscribe.checkout');
+ Route::get('subscribe/success', [SubscriptionController::class, 'success'])->name('subscribe.success');
+ Route::get('subscribe/cancel', [SubscriptionController::class, 'cancel'])->name('subscribe.cancel');
+});
+
+Route::middleware(['auth', 'verified', 'redirect.encryption', 'subscribed'])->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');
diff --git a/tests/Feature/SubscriptionTest.php b/tests/Feature/SubscriptionTest.php
new file mode 100644
index 00000000..5736f62a
--- /dev/null
+++ b/tests/Feature/SubscriptionTest.php
@@ -0,0 +1,156 @@
+ true]);
+});
+
+test('guests cannot access subscription pages', function () {
+ $this->get(route('subscribe'))->assertRedirect(route('login'));
+ $this->get(route('subscribe.checkout'))->assertRedirect(route('login'));
+ $this->get(route('subscribe.success'))->assertRedirect(route('login'));
+});
+
+test('users without subscription are redirected to paywall when accessing protected routes', function () {
+ $user = User::factory()->create([
+ 'encryption_salt' => str_repeat('a', 24),
+ ]);
+
+ $this->actingAs($user);
+
+ $this->get(route('dashboard'))->assertRedirect(route('subscribe'));
+ $this->get(route('accounts.list'))->assertRedirect(route('subscribe'));
+ $this->get(route('transactions.index'))->assertRedirect(route('subscribe'));
+});
+
+test('users can view the paywall page', function () {
+ $user = User::factory()->create([
+ 'encryption_salt' => str_repeat('a', 24),
+ ]);
+
+ $this->actingAs($user);
+
+ $this->get(route('subscribe'))->assertOk();
+});
+
+test('subscribed users are redirected from paywall to dashboard', function () {
+ $user = User::factory()->create([
+ 'encryption_salt' => str_repeat('a', 24),
+ ]);
+
+ $user->subscriptions()->create([
+ 'type' => 'default',
+ 'stripe_id' => 'sub_test123',
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_test123',
+ ]);
+
+ $this->actingAs($user);
+
+ $this->get(route('subscribe'))->assertRedirect(route('dashboard'));
+});
+
+test('subscribed users can access protected routes', function () {
+ $user = User::factory()->create([
+ 'encryption_salt' => str_repeat('a', 24),
+ ]);
+
+ $user->subscriptions()->create([
+ 'type' => 'default',
+ 'stripe_id' => 'sub_test123',
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_test123',
+ ]);
+
+ $this->actingAs($user);
+
+ $this->get(route('dashboard'))->assertOk();
+});
+
+test('users can view the success page after subscribing', function () {
+ $user = User::factory()->create([
+ 'encryption_salt' => str_repeat('a', 24),
+ ]);
+
+ $this->actingAs($user);
+
+ $this->get(route('subscribe.success'))->assertOk();
+});
+
+test('cancel route redirects to paywall', function () {
+ $user = User::factory()->create([
+ 'encryption_salt' => str_repeat('a', 24),
+ ]);
+
+ $this->actingAs($user);
+
+ $this->get(route('subscribe.cancel'))->assertRedirect(route('subscribe'));
+});
+
+test('subscription middleware allows access when subscriptions are disabled', function () {
+ config(['subscriptions.enabled' => false]);
+
+ $user = User::factory()->create([
+ 'encryption_salt' => str_repeat('a', 24),
+ ]);
+
+ $this->actingAs($user);
+
+ $this->get(route('dashboard'))->assertOk();
+});
+
+test('hasProPlan returns true when subscriptions are disabled', function () {
+ config(['subscriptions.enabled' => false]);
+
+ $user = User::factory()->create();
+
+ expect($user->hasProPlan())->toBeTrue();
+});
+
+test('hasProPlan returns false for unsubscribed users when subscriptions are enabled', function () {
+ config(['subscriptions.enabled' => true]);
+
+ $user = User::factory()->create();
+
+ expect($user->hasProPlan())->toBeFalse();
+});
+
+test('hasProPlan returns true for subscribed users', function () {
+ config(['subscriptions.enabled' => true]);
+
+ $user = User::factory()->create();
+
+ $user->subscriptions()->create([
+ 'type' => 'default',
+ 'stripe_id' => 'sub_test123',
+ 'stripe_status' => 'active',
+ 'stripe_price' => 'price_test123',
+ ]);
+
+ expect($user->hasProPlan())->toBeTrue();
+});
+
+test('landing page passes subscriptions enabled prop when enabled', function () {
+ config(['subscriptions.enabled' => true]);
+
+ $this->get(route('home'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('welcome')
+ ->has('subscriptionsEnabled')
+ ->where('subscriptionsEnabled', true)
+ );
+});
+
+test('landing page passes subscriptions enabled prop when disabled', function () {
+ config(['subscriptions.enabled' => false]);
+
+ $this->get(route('home'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('welcome')
+ ->has('subscriptionsEnabled')
+ ->where('subscriptionsEnabled', false)
+ );
+});
diff --git a/tests/TestCase.php b/tests/TestCase.php
index fe1ffc2f..6b717d9d 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -6,5 +6,10 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
- //
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ config(['subscriptions.enabled' => false]);
+ }
}