Subscriptions (#15)

## Subscribe paywall
<img width="1119" height="764" alt="image"
src="https://github.com/user-attachments/assets/55367c91-f2b9-43f5-b450-75faf867bde0"
/>

## Subscribe success page
<img width="1112" height="681" alt="image"
src="https://github.com/user-attachments/assets/a923752a-d506-410e-ac66-e02b96acca20"
/>

## Manage subscription page
<img width="1221" height="705" alt="image"
src="https://github.com/user-attachments/assets/1eba1fec-7fb7-4500-8549-d8def809ddae"
/>
This commit is contained in:
Víctor Falcón 2025-12-06 19:09:56 +01:00 committed by GitHub
parent 517d758a9d
commit 374ce0546b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1443 additions and 131 deletions

View File

@ -66,3 +66,12 @@ LEAD_REDIRECT_URL=https://google.es
HIDE_AUTH_BUTTONS=false
VITE_APP_NAME="${APP_NAME}"
# Stripe Configuration
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
# Subscriptions
SUBSCRIPTIONS_ENABLED=false
STRIPE_PRO_PRICE_ID=

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Cashier\Checkout;
class SubscriptionController extends Controller
{
public function index(): Response|RedirectResponse
{
$user = auth()->user();
if ($user->hasProPlan()) {
return redirect()->route('dashboard');
}
return Inertia::render('subscription/paywall');
}
public function checkout(Request $request): Checkout
{
$priceId = config('subscriptions.prices.pro_monthly');
return $request->user()
->newSubscription('default', $priceId)
->allowPromotionCodes()
->checkout([
'success_url' => route('subscribe.success'),
'cancel_url' => route('subscribe.cancel'),
]);
}
public function success(): Response
{
return Inertia::render('subscription/success');
}
public function cancel(): RedirectResponse
{
return redirect()->route('subscribe');
}
public function billing(Request $request): Response|RedirectResponse
{
if (! config('subscriptions.enabled')) {
return redirect()->route('dashboard');
}
return Inertia::render('settings/billing');
}
public function billingPortal(Request $request): RedirectResponse
{
return $request->user()->redirectToBillingPortal(route('settings.billing'));
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsSubscribed
{
/**
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! config('subscriptions.enabled')) {
return $next($request);
}
if ($request->user()?->hasProPlan()) {
return $next($request);
}
return redirect()->route('subscribe');
}
}

View File

@ -38,14 +38,18 @@ class HandleInertiaRequests extends Middleware
{
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
$user = $request->user();
return [
...parent::share($request),
'name' => config('app.name'),
'appUrl' => config('app.url'),
'quote' => ['message' => trim($message), 'author' => trim($author)],
'auth' => [
'user' => $request->user(),
'user' => $user,
'hasProPlan' => $user?->hasProPlan() ?? false,
],
'subscriptionsEnabled' => config('subscriptions.enabled', false),
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}

View File

@ -9,12 +9,13 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Cashier\Billable;
use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, HasUuids, Notifiable, TwoFactorAuthenticatable;
use Billable, HasFactory, HasUuids, Notifiable, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
@ -78,4 +79,13 @@ class User extends Authenticatable
{
return $this->hasMany(AutomationRule::class);
}
public function hasProPlan(): bool
{
if (! config('subscriptions.enabled')) {
return true;
}
return $this->subscribed('default');
}
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\EnsureUserIsSubscribed;
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\RedirectToEncryptionSetup;
@ -34,6 +35,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'redirect.encryption' => RedirectToEncryptionSetup::class,
'subscribed' => EnsureUserIsSubscribed::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -11,6 +11,7 @@
"require": {
"php": "^8.2",
"inertiajs/inertia-laravel": "^2.0",
"laravel/cashier": "^16.1",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
@ -51,7 +52,7 @@
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"bun run dev\" --names=server,queue,logs,vite --kill-others"
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"bun run dev\" \"stripe listen --forward-to https://whispermoney.test/stripe/webhook\" --names=server,queue,logs,vite --kill-others"
],
"dev:ssr": [
"bun run build:ssr",

327
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c486936c59a7be23c521c87384d243bf",
"content-hash": "6b2387225b885940346beca54c271107",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -1226,6 +1226,94 @@
},
"time": "2025-09-28T21:21:36+00:00"
},
{
"name": "laravel/cashier",
"version": "v16.1.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/cashier-stripe.git",
"reference": "6d526c79ea0b0e664299e5a122811034fe75ece9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/6d526c79ea0b0e664299e5a122811034fe75ece9",
"reference": "6d526c79ea0b0e664299e5a122811034fe75ece9",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/contracts": "^10.0|^11.0|^12.0",
"illuminate/database": "^10.0|^11.0|^12.0",
"illuminate/http": "^10.0|^11.0|^12.0",
"illuminate/log": "^10.0|^11.0|^12.0",
"illuminate/notifications": "^10.0|^11.0|^12.0",
"illuminate/pagination": "^10.0|^11.0|^12.0",
"illuminate/routing": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/view": "^10.0|^11.0|^12.0",
"moneyphp/money": "^4.0",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.1",
"stripe/stripe-php": "^17.3.0",
"symfony/console": "^6.0|^7.0",
"symfony/http-kernel": "^6.0|^7.0",
"symfony/polyfill-intl-icu": "^1.22.1",
"symfony/polyfill-php84": "^1.32"
},
"require-dev": {
"dompdf/dompdf": "^2.0|^3.0",
"orchestra/testbench": "^8.36|^9.15|^10.8",
"phpstan/phpstan": "^1.10",
"spatie/laravel-ray": "^1.40"
},
"suggest": {
"dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).",
"ext-intl": "Allows for more locales besides the default \"en\" when formatting money values."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Cashier\\CashierServiceProvider"
]
},
"branch-alias": {
"dev-master": "16.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Cashier\\": "src/",
"Laravel\\Cashier\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Dries Vints",
"email": "dries@laravel.com"
}
],
"description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.",
"keywords": [
"billing",
"laravel",
"stripe"
],
"support": {
"issues": "https://github.com/laravel/cashier/issues",
"source": "https://github.com/laravel/cashier"
},
"time": "2025-12-03T00:26:48+00:00"
},
{
"name": "laravel/fortify",
"version": "v1.31.2",
@ -2310,6 +2398,96 @@
],
"time": "2024-12-08T08:18:47+00:00"
},
{
"name": "moneyphp/money",
"version": "v4.8.0",
"source": {
"type": "git",
"url": "https://github.com/moneyphp/money.git",
"reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/moneyphp/money/zipball/b358727ea5a5cd2d7475e59c31dfc352440ae7ec",
"reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec",
"shasum": ""
},
"require": {
"ext-bcmath": "*",
"ext-filter": "*",
"ext-json": "*",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cache/taggable-cache": "^1.1.0",
"doctrine/coding-standard": "^12.0",
"doctrine/instantiator": "^1.5.0 || ^2.0",
"ext-gmp": "*",
"ext-intl": "*",
"florianv/exchanger": "^2.8.1",
"florianv/swap": "^4.3.0",
"moneyphp/crypto-currencies": "^1.1.0",
"moneyphp/iso-currencies": "^3.4",
"php-http/message": "^1.16.0",
"php-http/mock-client": "^1.6.0",
"phpbench/phpbench": "^1.2.5",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1.9",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5.9",
"psr/cache": "^1.0.1 || ^2.0 || ^3.0",
"ticketswap/phpstan-error-formatter": "^1.1"
},
"suggest": {
"ext-gmp": "Calculate without integer limits",
"ext-intl": "Format Money objects with intl",
"florianv/exchanger": "Exchange rates library for PHP",
"florianv/swap": "Exchange rates library for PHP",
"psr/cache-implementation": "Used for Currency caching"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Money\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mathias Verraes",
"email": "mathias@verraes.net",
"homepage": "http://verraes.net"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
},
{
"name": "Frederik Bosch",
"email": "f.bosch@genkgo.nl"
}
],
"description": "PHP implementation of Fowler's Money pattern",
"homepage": "http://moneyphp.org",
"keywords": [
"Value Object",
"money",
"vo"
],
"support": {
"issues": "https://github.com/moneyphp/money/issues",
"source": "https://github.com/moneyphp/money/tree/v4.8.0"
},
"time": "2025-10-23T07:55:09+00:00"
},
{
"name": "monolog/monolog",
"version": "3.9.0",
@ -3749,6 +3927,65 @@
},
"time": "2025-09-04T20:59:21+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v17.6.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.72.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v17.6.0"
},
"time": "2025-08-27T19:32:42+00:00"
},
{
"name": "symfony/clock",
"version": "v7.3.0",
@ -4904,6 +5141,94 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-icu",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git",
"reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c",
"reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance and support of other locales than \"en\""
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Icu\\": ""
},
"classmap": [
"Resources/stubs"
],
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's ICU-related data and classes",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"icu",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-06-20T22:24:30+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",

127
config/cashier.php Normal file
View File

@ -0,0 +1,127 @@
<?php
use Laravel\Cashier\Console\WebhookCommand;
use Laravel\Cashier\Invoices\DompdfInvoiceRenderer;
return [
/*
|--------------------------------------------------------------------------
| Stripe Keys
|--------------------------------------------------------------------------
|
| The Stripe publishable key and secret key give you access to Stripe's
| API. The "publishable" key is typically used when interacting with
| Stripe.js while the "secret" key accesses private API endpoints.
|
*/
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
/*
|--------------------------------------------------------------------------
| Cashier Path
|--------------------------------------------------------------------------
|
| This is the base URI path where Cashier's views, such as the payment
| verification screen, will be available from. You're free to tweak
| this path according to your preferences and application design.
|
*/
'path' => env('CASHIER_PATH', 'stripe'),
/*
|--------------------------------------------------------------------------
| Stripe Webhooks
|--------------------------------------------------------------------------
|
| Your Stripe webhook secret is used to prevent unauthorized requests to
| your Stripe webhook handling controllers. The tolerance setting will
| check the drift between the current time and the signed request's.
|
*/
'webhook' => [
'secret' => env('STRIPE_WEBHOOK_SECRET'),
'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300),
'events' => WebhookCommand::DEFAULT_EVENTS,
],
/*
|--------------------------------------------------------------------------
| Currency
|--------------------------------------------------------------------------
|
| This is the default currency that will be used when generating charges
| from your application. Of course, you are welcome to use any of the
| various world currencies that are currently supported via Stripe.
|
*/
'currency' => env('CASHIER_CURRENCY', 'usd'),
/*
|--------------------------------------------------------------------------
| Currency Locale
|--------------------------------------------------------------------------
|
| This is the default locale in which your money values are formatted in
| for display. To utilize other locales besides the default en locale
| verify you have the "intl" PHP extension installed on the system.
|
*/
'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
| Payment Confirmation Notification
|--------------------------------------------------------------------------
|
| If this setting is enabled, Cashier will automatically notify customers
| whose payments require additional verification. You should listen to
| Stripe's webhooks in order for this feature to function correctly.
|
*/
'payment_notification' => env('CASHIER_PAYMENT_NOTIFICATION'),
/*
|--------------------------------------------------------------------------
| Invoice Settings
|--------------------------------------------------------------------------
|
| The following options determine how Cashier invoices are converted from
| HTML into PDFs. You're free to change the options based on the needs
| of your application or your preferences regarding invoice styling.
|
*/
'invoices' => [
'renderer' => env('CASHIER_INVOICE_RENDERER', DompdfInvoiceRenderer::class),
'options' => [
// Supported: 'letter', 'legal', 'A4'
'paper' => env('CASHIER_PAPER', 'letter'),
'remote_enabled' => env('CASHIER_REMOTE_ENABLED', false),
],
],
/*
|--------------------------------------------------------------------------
| Stripe Logger
|--------------------------------------------------------------------------
|
| This setting defines which logging channel will be used by the Stripe
| library to write log messages. You are free to specify any of your
| logging channels listed inside the "logging" configuration file.
|
*/
'logger' => env('CASHIER_LOGGER'),
];

45
config/subscriptions.php Normal file
View File

@ -0,0 +1,45 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Subscriptions Enabled
|--------------------------------------------------------------------------
|
| This option controls whether the subscription system is enabled. When
| disabled, all users will have access to all features without needing
| to subscribe. This is useful for development or self-hosted instances.
|
*/
'enabled' => env('SUBSCRIPTIONS_ENABLED', false),
/*
|--------------------------------------------------------------------------
| Stripe Price IDs
|--------------------------------------------------------------------------
|
| These are the Stripe Price IDs for the different subscription plans.
| You can find these in your Stripe Dashboard under Products.
|
*/
'prices' => [
'pro_monthly' => env('STRIPE_PRO_PRICE_ID', 'price_1SbJYkLNIsVExnyvAJhUoSeB'),
],
/*
|--------------------------------------------------------------------------
| Stripe Product IDs
|--------------------------------------------------------------------------
|
| These are the Stripe Product IDs for reference.
|
*/
'products' => [
'pro' => env('STRIPE_PRO_PRODUCT_ID', 'prod_TYQPg0s9rpxNsU'),
],
];

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropIndex([
'stripe_id',
]);
$table->dropColumn([
'stripe_id',
'pm_type',
'pm_last_four',
'trial_ends_at',
]);
});
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignUuid('user_id');
$table->string('type');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'stripe_status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscriptions');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscription_items', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id');
$table->string('stripe_id')->unique();
$table->string('stripe_product');
$table->string('stripe_price');
$table->integer('quantity')->nullable();
$table->timestamps();
$table->index(['subscription_id', 'stripe_price']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscription_items');
}
};

View File

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

View File

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

View File

@ -15,27 +15,25 @@ import {
import { dashboard } from '@/routes';
import { type NavItem } from '@/types';
import { Link } from '@inertiajs/react';
import {
BookOpen,
CreditCard,
Folder,
LayoutGrid,
Receipt,
} from 'lucide-react';
import { CreditCard, Github, LayoutGrid, Receipt } from 'lucide-react';
import AppLogo from './app-logo';
import DiscordIcon from './icons/DiscordIcon';
const mainNavItems: NavItem[] = [
{
type: 'nav-item',
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
{
type: 'nav-item',
title: 'Accounts',
href: accountsIndex(),
icon: CreditCard,
},
{
type: 'nav-item',
title: 'Transactions',
href: transactionsIndex(),
icon: Receipt,
@ -44,14 +42,16 @@ const mainNavItems: NavItem[] = [
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/react-starter-kit',
icon: Folder,
type: 'nav-item',
title: 'Github',
href: 'https://github.com/whisper-money/whisper-money',
icon: Github,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#react',
icon: BookOpen,
type: 'nav-item',
title: 'Community',
href: 'https://discord.gg/zqfrynthvb',
icon: <DiscordIcon className="size-5" />,
},
];

View File

@ -49,7 +49,7 @@ export function AccountBalanceCard({
<CardTitle className="text-sm font-medium">
<Link
href={show.url(account.id)}
className="-ml-1.5 -my-1 flex items-center rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
className="-my-1 -ml-1.5 flex items-center rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
>
{account.bank.logo && (
<img

View File

@ -0,0 +1,13 @@
import { cn } from '@/lib/utils';
export default function DiscordIcon({ className }: { className?: string }) {
return (
<svg
className={cn(['size-5', className])}
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
);
}

View File

@ -8,7 +8,8 @@ import {
} from '@/components/ui/sidebar';
import { resolveUrl } from '@/lib/utils';
import { type NavItem } from '@/types';
import { type ComponentPropsWithoutRef } from 'react';
import { LucideIcon } from 'lucide-react';
import { isValidElement, type ComponentPropsWithoutRef } from 'react';
export function NavFooter({
items,
@ -35,12 +36,7 @@ export function NavFooter({
target="_blank"
rel="noopener noreferrer"
>
{item.icon && (
<Icon
iconNode={item.icon}
className="h-5 w-5"
/>
)}
<IconOrComponent icon={item.icon} />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
@ -51,3 +47,11 @@ export function NavFooter({
</SidebarGroup>
);
}
function IconOrComponent({ icon }: { icon: React.ReactNode | LucideIcon }) {
if (isValidElement(icon)) {
return icon;
}
return <Icon iconNode={icon as LucideIcon} />;
}

View File

@ -0,0 +1,100 @@
import { dashboard } from '@/routes';
import { type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { BirdIcon, Github } from 'lucide-react';
import DiscordIcon from '../icons/DiscordIcon';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
type Props = {
canRegister?: boolean;
hideAuthButtons?: boolean;
hideExternalButtons?: boolean;
};
export default function Header({
canRegister = false,
hideAuthButtons = false,
hideExternalButtons = false,
}: Props) {
const { auth } = usePage<SharedData>().props;
return (
<header className="fade-bottom fixed top-0 z-50 w-full bg-background/5 backdrop-blur-lg">
<div className="mx-auto flex max-w-5xl items-center justify-between px-2 py-4 lg:py-6">
<div className="flex items-center gap-4 font-mono">
<BirdIcon className="size-5 text-[#1b1b18] dark:text-[#EDEDEC]" />
<span className="font-medium">Whisper Money</span>
</div>
<nav className="flex items-center gap-4">
{!hideExternalButtons && (
<>
<a
href="https://github.com/whisper-money/whisper-money"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={'ghost'}
className="cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100"
>
<Github className="size-5" />
Github
</Button>
</a>
<a
href="https://discord.gg/9UQWZECDDv"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={'ghost'}
className="cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100"
>
<DiscordIcon className="size-5" />
Discord
</Button>
</a>
</>
)}
{!hideAuthButtons && (
<>
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-6 data-[orientation=vertical]:w-[1px] data-[orientation=vertical]:bg-border"
/>
{auth.user ? (
<Link href={dashboard()}>
<Button className="cursor-pointer">
Dashboard
</Button>
</Link>
) : (
<>
<Link href="/login">
<Button
variant={'ghost'}
className="cursor-pointer"
>
Log in
</Button>
</Link>
{canRegister && (
<Link href="/register">
<Button
variant="default"
className="cursor-pointer"
>
Register
</Button>
</Link>
)}
</>
)}
</>
)}
</nav>
</div>
</header>
);
}

View File

@ -8,11 +8,19 @@ import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
import { edit as editAccount } from '@/routes/account';
import { edit as editAppearance } from '@/routes/appearance';
import { edit as editDeleteAccount } from '@/routes/delete-account';
import { NavDivider, NavSectionHeader, type NavItem } from '@/types';
import { Link } from '@inertiajs/react';
import { billing } from '@/routes/settings';
import {
NavDivider,
NavSectionHeader,
SharedData,
type NavItem,
} from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { type PropsWithChildren } from 'react';
const sidebarNavItems: (NavItem | NavSectionHeader | NavDivider)[] = [
const getNavItems = (
subscriptionsEnabled: boolean,
): (NavItem | NavSectionHeader | NavDivider)[] => [
{
type: 'nav-item',
title: 'Bank accounts',
@ -42,6 +50,16 @@ const sidebarNavItems: (NavItem | NavSectionHeader | NavDivider)[] = [
href: editAccount(),
icon: null,
},
...(subscriptionsEnabled
? [
{
type: 'nav-item' as const,
title: 'Manage Plan',
href: billing(),
icon: null,
},
]
: []),
{
type: 'nav-item',
title: 'Appearance',
@ -58,12 +76,15 @@ const sidebarNavItems: (NavItem | NavSectionHeader | NavDivider)[] = [
];
export default function SettingsLayout({ children }: PropsWithChildren) {
const { subscriptionsEnabled } = usePage<SharedData>().props;
// When server-side rendering, we only render the layout on the client...
if (typeof window === 'undefined') {
return null;
}
const currentPath = window.location.pathname;
const sidebarNavItems = getNavItems(subscriptionsEnabled);
return (
<div className="px-4 py-6">

View File

@ -0,0 +1,104 @@
import HeadingSmall from '@/components/heading-small';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { billing } from '@/routes/settings';
import { type BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/react';
import {
CheckIcon,
CreditCardIcon,
InfinityIcon,
ShieldCheckIcon,
SparklesIcon,
} from 'lucide-react';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Manage Plan',
href: billing().url,
},
];
const benefits = [
{
icon: InfinityIcon,
title: 'Unlimited Everything',
description: 'No limits on bank accounts, transactions, or categories.',
},
{
icon: ShieldCheckIcon,
title: 'End-to-End Encryption',
description:
'Your financial data is encrypted with military-grade security.',
},
{
icon: SparklesIcon,
title: 'Smart Automation',
description:
'Automation rules to categorize transactions automatically.',
},
{
icon: CreditCardIcon,
title: 'Priority Support',
description: 'Get help when you need it with priority email support.',
},
];
export default function Billing() {
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Manage Plan" />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall
title="Your Pro Plan"
description="You're enjoying all the benefits of Whisper Money Pro"
/>
<div className="grid gap-4 sm:grid-cols-2">
{benefits.map((benefit) => (
<div
key={benefit.title}
className="flex items-start gap-3 rounded-lg border bg-card p-4"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-emerald-100 dark:bg-emerald-900/30">
<benefit.icon className="size-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<h3 className="font-medium">
{benefit.title}
</h3>
<p className="text-sm text-muted-foreground">
{benefit.description}
</p>
</div>
</div>
))}
</div>
<div className="rounded-lg border bg-card p-5">
<div className="flex items-center gap-2">
<CheckIcon className="size-5 text-emerald-500" />
<span className="font-medium">Pro Plan Active</span>
<span className="text-muted-foreground">
$9/month
</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Manage your subscription, update payment methods, or
view invoices through the Stripe billing portal.
</p>
<a href={billing.portal.url()}>
<Button className="mt-4">
<CreditCardIcon className="size-4" />
Manage Subscription
</Button>
</a>
</div>
</div>
</SettingsLayout>
</AppLayout>
);
}

View File

@ -0,0 +1,66 @@
import { Button } from '@/components/ui/button';
import AuthLayout from '@/layouts/auth-layout';
import { checkout } from '@/routes/subscribe';
import { Head } from '@inertiajs/react';
import { CheckIcon } from 'lucide-react';
export default function Paywall() {
return (
<AuthLayout
title="Upgrade to Pro"
description="Unlock all features and take control of your finances"
>
<Head title="Upgrade to Pro" />
<div className="flex flex-col gap-6">
<div className="overflow-hidden rounded-xl border bg-card">
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 px-5 py-4 dark:from-emerald-900/20 dark:to-teal-900/20">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">$9</span>
<span className="text-muted-foreground">
/month
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Billed monthly. Cancel anytime.
</p>
</div>
<div className="p-5">
<ul className="space-y-2.5">
{[
'Unlimited bank accounts',
'Unlimited transactions',
'End-to-end encryption',
'Smart categorization',
'Automation rules',
'Visual insights & reports',
'Priority support',
].map((feature) => (
<li
key={feature}
className="flex items-center gap-2.5"
>
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
</div>
</div>
<p className="text-center text-xs text-muted-foreground">
Use code{' '}
<span className="font-mono font-semibold text-emerald-600 dark:text-emerald-400">
FOUNDER
</span>{' '}
for $8 off your first month
</p>
<a href={checkout.url()}>
<Button className="w-full">Subscribe Now</Button>
</a>
</div>
</AuthLayout>
);
}

View File

@ -0,0 +1,49 @@
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/auth-layout';
import { dashboard } from '@/routes';
import { Head, Link } from '@inertiajs/react';
import { useState } from 'react';
export default function Success() {
const [loading, setLoading] = useState(true);
// Simulate loading for 5 seconds
setTimeout(() => {
setLoading(false);
}, 3000);
return (
<AuthLayout
title={loading ? 'Creating subscripiton...' : 'Welcome to Pro!'}
description={
loading
? 'We are proccessing your payment...'
: 'Your subscription is now active'
}
>
<Head title="Welcome to Pro!" />
<div className="flex flex-col items-center gap-6">
{!loading && (
<p className="text-center text-muted-foreground">
You now have full access to all Whisper Money features.
Thank you for supporting us!
</p>
)}
<Link href={dashboard()} className="w-full">
<Button size="lg" disabled={loading} className="w-full">
{loading ? (
<>
<Spinner /> Setting things up...
</>
) : (
'Go to Dashboard'
)}
</Button>
</Link>
</div>
</AuthLayout>
);
}

View File

@ -1,19 +1,17 @@
import InputError from '@/components/input-error';
import Header from '@/components/partials/header';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { LEAD_FUNNEL_EVENT_UUID } from '@/lib/constants';
import { trackEvent } from '@/lib/track-event';
import { dashboard } from '@/routes';
import { store } from '@/routes/user-leads';
import { type SharedData } from '@/types';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { Separator } from '@radix-ui/react-separator';
import {
BellIcon,
BirdIcon,
CheckIcon,
CodeIcon,
EyeOffIcon,
Github,
LockIcon,
PieChartIcon,
ShieldCheckIcon,
@ -26,11 +24,13 @@ import { useEffect, useRef } from 'react';
export default function Welcome({
canRegister = true,
hideAuthButtons = false,
subscriptionsEnabled = false,
}: {
canRegister?: boolean;
hideAuthButtons?: boolean;
subscriptionsEnabled?: boolean;
}) {
const { auth, appUrl } = usePage<SharedData>().props;
const { appUrl } = usePage<SharedData>().props;
const emailInputRef = useRef<HTMLInputElement>(null);
const visitTrackedRef = useRef(false);
@ -57,17 +57,6 @@ export default function Welcome({
/>
<link rel="canonical" href={appUrl} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Stack+Sans+Text:wght@200..700&display=swap"
rel="stylesheet"
/>
<meta property="og:site_name" content="Whisper Money" />
<meta
property="og:title"
@ -135,84 +124,10 @@ export default function Welcome({
</script>
</Head>
<div className="flex min-h-screen flex-col bg-[#FDFDFC] text-[#1b1b18] dark:bg-[#0a0a0a] dark:text-[#EDEDEC]">
<header className="fade-bottom fixed top-0 z-50 w-full bg-background/5 backdrop-blur-lg">
<div className="mx-auto flex max-w-5xl items-center justify-between px-2 py-4 lg:py-6">
<div className="flex items-center gap-4 font-mono">
<BirdIcon className="size-5 text-[#1b1b18] dark:text-[#EDEDEC]" />
<span className="font-medium">Whisper Money</span>
</div>
<nav className="flex items-center gap-4">
<a
href="https://github.com/whisper-money/whisper-money"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={'ghost'}
className="cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100"
>
<Github className="size-5" />
Github
</Button>
</a>
<a
href="https://discord.gg/9UQWZECDDv"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={'ghost'}
className="cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100"
>
<svg
className="size-5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
Discord
</Button>
</a>
{!hideAuthButtons && (
<>
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-6 data-[orientation=vertical]:w-[1px] data-[orientation=vertical]:bg-border"
/>
{auth.user ? (
<Link href={dashboard()}>
<Button className="cursor-pointer">
Dashboard
</Button>
</Link>
) : (
<>
<Link href="/login">
<Button
variant={'ghost'}
className="cursor-pointer"
>
Log in
</Button>
</Link>
{canRegister && (
<Link href="/register">
<Button
variant="default"
className="cursor-pointer"
>
Register
</Button>
</Link>
)}
</>
)}
</>
)}
</nav>
</div>
</header>
<Header
canRegister={canRegister}
hideAuthButtons={hideAuthButtons}
/>
<main className="flex flex-1 flex-col">
<section className="relative w-full overflow-hidden px-6 py-24 sm:py-32 md:py-40">
@ -452,6 +367,95 @@ export default function Welcome({
</div>
</section>
{subscriptionsEnabled && (
<section
id="pricing"
className="px-4 py-12 sm:py-24 md:py-32"
>
<div className="mx-auto flex max-w-4xl flex-col items-center gap-8 sm:gap-12">
<div className="flex flex-col items-center gap-4 text-center">
<p className="max-w-[600px] text-sm tracking-wider text-[#706f6c] uppercase dark:text-[#A1A09A]">
One plan, all features. No hidden fees.
</p>
<h2 className="text-2xl leading-tight font-semibold sm:text-4xl sm:leading-tight">
Simple, transparent pricing
</h2>
</div>
<div className="w-full max-w-md">
<div className="relative overflow-hidden rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 shadow-lg dark:border-[#3E3E3A] dark:bg-[#161615]">
<div className="absolute -top-12 -right-12 h-32 w-32 rounded-full bg-gradient-to-br from-emerald-500/20 to-teal-500/20 blur-3xl" />
<div className="relative">
<div className='mb-6 flex gap-4 items-center'>
<h3 className="text-xl font-semibold">
Pro Plan
</h3>
<div className="uppercase inline-block rounded-lg bg-emerald-100 px-2 py-1.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
Founder Promotion
</div>
</div>
<div className="mb-6 flex items-baseline gap-3">
<span className="text-2xl font-medium text-[#706f6c] line-through decoration-2 dark:text-[#A1A09A]">
$9
</span>
<span className="text-5xl font-bold tracking-tight text-emerald-600 dark:text-emerald-400">
$1
</span>
<span className="text-lg text-[#706f6c] dark:text-[#A1A09A]">
/first month
</span>
</div>
<p className="mb-6 text-[#706f6c] dark:text-[#A1A09A]">
Everything you need to manage
your finances securely.
</p>
<ul className="mb-8 space-y-3">
{[
'Unlimited accounts',
'Unlimited transactions',
'End-to-end encryption',
'Smart categorization',
'Automation rules',
'Visual insights & reports',
'Priority support',
].map((feature) => (
<li
key={feature}
className="flex items-center gap-3"
>
<CheckIcon className="size-5 shrink-0 text-emerald-500" />
<span className="text-sm">
{feature}
</span>
</li>
))}
</ul>
<p className="mb-4 text-center text-xs text-[#706f6c] dark:text-[#A1A09A]">
Use code{' '}
<span className="font-mono font-semibold text-emerald-600 dark:text-emerald-400">
FOUNDER
</span>{' '}
at checkout Then $9/month
</p>
<Link href="/register">
<Button className="w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 py-6 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] hover:dark:from-zinc-50">
Get Started
</Button>
</Link>
</div>
</div>
</div>
</div>
</section>
)}
<section className="w-full overflow-hidden px-0 py-12 sm:py-24 md:py-32 dark:border-[#3E3E3A]">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-4 px-1 text-center sm:gap-16">
<div className="flex flex-col items-center gap-4 px-4 sm:gap-4">

View File

@ -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<InertiaLinkProps['href']>;
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;
}

View File

@ -44,6 +44,10 @@
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Stack+Sans+Text:wght@200..700&display=swap" rel="stylesheet">
@viteReactRefresh
@vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
@inertiaHead

View File

@ -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');

View File

@ -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');

View File

@ -0,0 +1,156 @@
<?php
use App\Models\User;
beforeEach(function () {
config(['subscriptions.enabled' => 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)
);
});

View File

@ -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]);
}
}