From 374ce0546b9fcedd7d59b5b3c61da6de377fd835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 6 Dec 2025 19:09:56 +0100 Subject: [PATCH] Subscriptions (#15) ## Subscribe paywall image ## Subscribe success page image ## Manage subscription page image --- .env.example | 9 + .../Controllers/SubscriptionController.php | 60 ++++ .../Middleware/EnsureUserIsSubscribed.php | 26 ++ app/Http/Middleware/HandleInertiaRequests.php | 6 +- app/Models/User.php | 12 +- bootstrap/app.php | 2 + composer.json | 3 +- composer.lock | 327 +++++++++++++++++- config/cashier.php | 127 +++++++ config/subscriptions.php | 45 +++ ...5_12_06_112515_create_customer_columns.php | 40 +++ ...2_06_112516_create_subscriptions_table.php | 37 ++ ...112517_create_subscription_items_table.php | 34 ++ ...d_meter_id_to_subscription_items_table.php | 28 ++ ...event_name_to_subscription_items_table.php | 28 ++ resources/js/components/app-sidebar.tsx | 26 +- .../dashboard/account-balance-card.tsx | 2 +- resources/js/components/icons/DiscordIcon.tsx | 13 + resources/js/components/nav-footer.tsx | 18 +- resources/js/components/partials/header.tsx | 100 ++++++ resources/js/layouts/settings/layout.tsx | 27 +- resources/js/pages/settings/billing.tsx | 104 ++++++ resources/js/pages/subscription/paywall.tsx | 66 ++++ resources/js/pages/subscription/success.tsx | 49 +++ resources/js/pages/welcome.tsx | 192 +++++----- resources/js/types/index.d.ts | 5 +- resources/views/app.blade.php | 4 + routes/settings.php | 4 + routes/web.php | 17 +- tests/Feature/SubscriptionTest.php | 156 +++++++++ tests/TestCase.php | 7 +- 31 files changed, 1443 insertions(+), 131 deletions(-) create mode 100644 app/Http/Controllers/SubscriptionController.php create mode 100644 app/Http/Middleware/EnsureUserIsSubscribed.php create mode 100644 config/cashier.php create mode 100644 config/subscriptions.php create mode 100644 database/migrations/2025_12_06_112515_create_customer_columns.php create mode 100644 database/migrations/2025_12_06_112516_create_subscriptions_table.php create mode 100644 database/migrations/2025_12_06_112517_create_subscription_items_table.php create mode 100644 database/migrations/2025_12_06_112518_add_meter_id_to_subscription_items_table.php create mode 100644 database/migrations/2025_12_06_112519_add_meter_event_name_to_subscription_items_table.php create mode 100644 resources/js/components/icons/DiscordIcon.tsx create mode 100644 resources/js/components/partials/header.tsx create mode 100644 resources/js/pages/settings/billing.tsx create mode 100644 resources/js/pages/subscription/paywall.tsx create mode 100644 resources/js/pages/subscription/success.tsx create mode 100644 tests/Feature/SubscriptionTest.php diff --git a/.env.example b/.env.example index 6426f2c9..98dfb51d 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php new file mode 100644 index 00000000..dbc2c596 --- /dev/null +++ b/app/Http/Controllers/SubscriptionController.php @@ -0,0 +1,60 @@ +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')); + } +} diff --git a/app/Http/Middleware/EnsureUserIsSubscribed.php b/app/Http/Middleware/EnsureUserIsSubscribed.php new file mode 100644 index 00000000..3b50690d --- /dev/null +++ b/app/Http/Middleware/EnsureUserIsSubscribed.php @@ -0,0 +1,26 @@ +user()?->hasProPlan()) { + return $next($request); + } + + return redirect()->route('subscribe'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 56c6b7a4..76866aba 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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', ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 4a2a6c28..413407c6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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'); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 430df03b..fe8ddc71 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ alias([ 'redirect.encryption' => RedirectToEncryptionSetup::class, + 'subscribed' => EnsureUserIsSubscribed::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/composer.json b/composer.json index a444e117..333627e3 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 998daab6..b098dfb8 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/cashier.php b/config/cashier.php new file mode 100644 index 00000000..4a9b024b --- /dev/null +++ b/config/cashier.php @@ -0,0 +1,127 @@ + 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'), + +]; diff --git a/config/subscriptions.php b/config/subscriptions.php new file mode 100644 index 00000000..5b1425d3 --- /dev/null +++ b/config/subscriptions.php @@ -0,0 +1,45 @@ + 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'), + ], + +]; diff --git a/database/migrations/2025_12_06_112515_create_customer_columns.php b/database/migrations/2025_12_06_112515_create_customer_columns.php new file mode 100644 index 00000000..974b381e --- /dev/null +++ b/database/migrations/2025_12_06_112515_create_customer_columns.php @@ -0,0 +1,40 @@ +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', + ]); + }); + } +}; diff --git a/database/migrations/2025_12_06_112516_create_subscriptions_table.php b/database/migrations/2025_12_06_112516_create_subscriptions_table.php new file mode 100644 index 00000000..26b42191 --- /dev/null +++ b/database/migrations/2025_12_06_112516_create_subscriptions_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/2025_12_06_112517_create_subscription_items_table.php b/database/migrations/2025_12_06_112517_create_subscription_items_table.php new file mode 100644 index 00000000..420e23f0 --- /dev/null +++ b/database/migrations/2025_12_06_112517_create_subscription_items_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/database/migrations/2025_12_06_112518_add_meter_id_to_subscription_items_table.php b/database/migrations/2025_12_06_112518_add_meter_id_to_subscription_items_table.php new file mode 100644 index 00000000..033bb829 --- /dev/null +++ b/database/migrations/2025_12_06_112518_add_meter_id_to_subscription_items_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/database/migrations/2025_12_06_112519_add_meter_event_name_to_subscription_items_table.php b/database/migrations/2025_12_06_112519_add_meter_event_name_to_subscription_items_table.php new file mode 100644 index 00000000..b157b3a5 --- /dev/null +++ b/database/migrations/2025_12_06_112519_add_meter_event_name_to_subscription_items_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index aaa4ef79..48a5c7df 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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: , }, ]; diff --git a/resources/js/components/dashboard/account-balance-card.tsx b/resources/js/components/dashboard/account-balance-card.tsx index 0a93a053..e926ce2c 100644 --- a/resources/js/components/dashboard/account-balance-card.tsx +++ b/resources/js/components/dashboard/account-balance-card.tsx @@ -49,7 +49,7 @@ export function AccountBalanceCard({ {account.bank.logo && ( + + + ); +} diff --git a/resources/js/components/nav-footer.tsx b/resources/js/components/nav-footer.tsx index 00aa4d3c..affdd3e8 100644 --- a/resources/js/components/nav-footer.tsx +++ b/resources/js/components/nav-footer.tsx @@ -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 && ( - - )} + {item.title} @@ -51,3 +47,11 @@ export function NavFooter({ ); } + +function IconOrComponent({ icon }: { icon: React.ReactNode | LucideIcon }) { + if (isValidElement(icon)) { + return icon; + } + + return ; +} diff --git a/resources/js/components/partials/header.tsx b/resources/js/components/partials/header.tsx new file mode 100644 index 00000000..5475f48f --- /dev/null +++ b/resources/js/components/partials/header.tsx @@ -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().props; + + return ( +
+
+
+ + Whisper Money +
+ +
+
+ ); +} diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 40caa3e7..d35b3985 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -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().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 (
diff --git a/resources/js/pages/settings/billing.tsx b/resources/js/pages/settings/billing.tsx new file mode 100644 index 00000000..69ccc249 --- /dev/null +++ b/resources/js/pages/settings/billing.tsx @@ -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 ( + + + + +
+ + +
+ {benefits.map((benefit) => ( +
+
+ +
+
+

+ {benefit.title} +

+

+ {benefit.description} +

+
+
+ ))} +
+ +
+
+ + Pro Plan Active + + — $9/month + +
+

+ Manage your subscription, update payment methods, or + view invoices through the Stripe billing portal. +

+ + + +
+
+
+
+ ); +} diff --git a/resources/js/pages/subscription/paywall.tsx b/resources/js/pages/subscription/paywall.tsx new file mode 100644 index 00000000..9af00531 --- /dev/null +++ b/resources/js/pages/subscription/paywall.tsx @@ -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 ( + + + +
+
+
+
+ $9 + + /month + +
+

+ Billed monthly. Cancel anytime. +

+
+ +
+
    + {[ + 'Unlimited bank accounts', + 'Unlimited transactions', + 'End-to-end encryption', + 'Smart categorization', + 'Automation rules', + 'Visual insights & reports', + 'Priority support', + ].map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+
+ +

+ Use code{' '} + + FOUNDER + {' '} + for $8 off your first month +

+ + + + +
+
+ ); +} diff --git a/resources/js/pages/subscription/success.tsx b/resources/js/pages/subscription/success.tsx new file mode 100644 index 00000000..80e4b1e9 --- /dev/null +++ b/resources/js/pages/subscription/success.tsx @@ -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 ( + + + +
+ {!loading && ( +

+ You now have full access to all Whisper Money features. + Thank you for supporting us! +

+ )} + + + + +
+
+ ); +} diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index d0828fbf..33ef045d 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -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().props; + const { appUrl } = usePage().props; const emailInputRef = useRef(null); const visitTrackedRef = useRef(false); @@ -57,17 +57,6 @@ export default function Welcome({ /> - - - -
-
-
-
- - Whisper Money -
- -
-
+
@@ -452,6 +367,95 @@ export default function Welcome({
+ {subscriptionsEnabled && ( +
+
+
+

+ One plan, all features. No hidden fees. +

+

+ Simple, transparent pricing +

+
+ +
+
+
+ +
+
+

+ Pro Plan +

+ +
+ Founder Promotion +
+
+ +
+ + $9 + + + $1 + + + /first month + +
+ +

+ Everything you need to manage + your finances securely. +

+ +
    + {[ + 'Unlimited accounts', + 'Unlimited transactions', + 'End-to-end encryption', + 'Smart categorization', + 'Automation rules', + 'Visual insights & reports', + 'Priority support', + ].map((feature) => ( +
  • + + + {feature} + +
  • + ))} +
+ +

+ Use code{' '} + + FOUNDER + {' '} + at checkout • Then $9/month +

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