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:
parent
517d758a9d
commit
374ce0546b
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
];
|
||||
|
|
@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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" />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue