From 71668ea5bdc58767246f14c10ec8b9d7216be94f Mon Sep 17 00:00:00 2001 From: myrmidex Date: Mon, 5 Jan 2026 23:51:50 +0100 Subject: [PATCH] feature - 18 - Add subscriptions --- app/Enums/SubscriptionStatusEnum.php | 23 ++++++++ .../Controllers/SubscriptionController.php | 47 +++++++++++++++ app/Http/Middleware/RequireSubscription.php | 25 ++++++++ app/Models/Planner.php | 12 ++++ app/Models/Subscription.php | 57 +++++++++++++++++++ bootstrap/app.php | 18 +++--- ...1_05_000000_create_subscriptions_table.php | 28 +++++++++ resources/views/subscription/index.blade.php | 33 +++++++++++ routes/web.php | 36 ++++++------ routes/web/subscription.php | 13 +++++ 10 files changed, 267 insertions(+), 25 deletions(-) create mode 100644 app/Enums/SubscriptionStatusEnum.php create mode 100644 app/Http/Controllers/SubscriptionController.php create mode 100644 app/Http/Middleware/RequireSubscription.php create mode 100644 app/Models/Subscription.php create mode 100644 database/migrations/2025_01_05_000000_create_subscriptions_table.php create mode 100644 resources/views/subscription/index.blade.php create mode 100644 routes/web/subscription.php diff --git a/app/Enums/SubscriptionStatusEnum.php b/app/Enums/SubscriptionStatusEnum.php new file mode 100644 index 0000000..29bbf3c --- /dev/null +++ b/app/Enums/SubscriptionStatusEnum.php @@ -0,0 +1,23 @@ +user(); + + if ($planner->hasActiveSubscription()) { + return redirect()->route('dashboard'); + } + + Subscription::create([ + 'planner_id' => $planner->id, + 'stripe_subscription_id' => 'mock_' . Str::random(14), + 'stripe_customer_id' => 'mock_' . Str::random(14), + 'status' => SubscriptionStatusEnum::ACTIVE, + 'plan' => 'default', + ]); + + return redirect()->route('dashboard')->with('success', 'Subscription activated!'); + } + + public function cancel(Request $request): RedirectResponse + { + $subscription = $request->user()->subscription; + + if (! $subscription) { + return back()->with('error', 'No active subscription found.'); + } + + $subscription->update([ + 'status' => SubscriptionStatusEnum::CANCELED, + 'ends_at' => now()->addDays(1), // Placeholder until Stripe webhook sets actual end date + ]); + + return back()->with('success', 'Subscription canceled. Access will continue until the end of your billing period.'); + } +} diff --git a/app/Http/Middleware/RequireSubscription.php b/app/Http/Middleware/RequireSubscription.php new file mode 100644 index 0000000..9387919 --- /dev/null +++ b/app/Http/Middleware/RequireSubscription.php @@ -0,0 +1,25 @@ +user(); + + if (! $planner?->hasActiveSubscription()) { + return redirect()->route('subscription.index'); + } + + return $next($request); + } +} diff --git a/app/Models/Planner.php b/app/Models/Planner.php index 16614bb..0d7cc8a 100644 --- a/app/Models/Planner.php +++ b/app/Models/Planner.php @@ -4,12 +4,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; 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\Sanctum\HasApiTokens; /** * @property int $id + * @property Subscription $subscription * @property static PlannerFactory factory($count = null, $state = []) * @method static first() */ @@ -33,4 +35,14 @@ public function schedules(): HasMany { return $this->hasMany(Schedule::class); } + + public function subscription(): HasOne + { + return $this->hasOne(Subscription::class); + } + + public function hasActiveSubscription(): bool + { + return $this->subscription?->isValid() ?? false; + } } diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 0000000..e94d18f --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,57 @@ + SubscriptionStatusEnum::class, + 'trial_ends_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + + public function planner(): BelongsTo + { + return $this->belongsTo(Planner::class); + } + + public function isValid(): bool + { + if ($this->status->allowsAccess()) { + return true; + } + + // Canceled but still in grace period + if ($this->status === SubscriptionStatusEnum::CANCELED && $this->ends_at?->isFuture()) { + return true; + } + + return false; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c6a8eab..0cc39ff 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,17 +1,14 @@ group(base_path('routes/web/subscription.php')); + }, ) ->withMiddleware(function (Middleware $middleware) { // Apply ForceJsonResponse only to API routes $middleware->api(ForceJsonResponse::class); + + $middleware->alias([ + 'subscription' => RequireSubscription::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { @@ -65,7 +70,4 @@ } }); }) - ->withCommands([ - GenerateScheduleCommand::class, - ]) ->create(); diff --git a/database/migrations/2025_01_05_000000_create_subscriptions_table.php b/database/migrations/2025_01_05_000000_create_subscriptions_table.php new file mode 100644 index 0000000..c9f815f --- /dev/null +++ b/database/migrations/2025_01_05_000000_create_subscriptions_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('planner_id')->constrained()->cascadeOnDelete(); + $table->string('stripe_subscription_id')->unique(); + $table->string('stripe_customer_id'); + $table->string('status'); + $table->string('plan'); + $table->timestamp('trial_ends_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/resources/views/subscription/index.blade.php b/resources/views/subscription/index.blade.php new file mode 100644 index 0000000..ac19ebe --- /dev/null +++ b/resources/views/subscription/index.blade.php @@ -0,0 +1,33 @@ + +
+
+

SUBSCRIPTION

+ + @if(auth()->user()->hasActiveSubscription()) +
+

Active Subscription

+

You have an active subscription.

+ +
+ @csrf + +
+
+ @else +
+

Subscribe to Dish Planner

+

Get access to all features.

+ +
+ @csrf + +
+
+ @endif +
+
+
diff --git a/routes/web.php b/routes/web.php index eb414a9..6bcee6c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,22 +23,24 @@ // Authenticated routes Route::middleware('auth')->group(function () { - Route::get('/dashboard', function () { - return view('dashboard'); - })->name('dashboard'); - Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); - - // Placeholder routes for future Livewire components - Route::get('/dishes', function () { - return view('dishes.index'); - })->name('dishes.index'); - - Route::get('/schedule', function () { - return view('schedule.index'); - })->name('schedule.index'); - - Route::get('/users', function () { - return view('users.index'); - })->name('users.index'); + + // Routes requiring active subscription in SaaS mode + Route::middleware('subscription')->group(function () { + Route::get('/dashboard', function () { + return view('dashboard'); + })->name('dashboard'); + + Route::get('/dishes', function () { + return view('dishes.index'); + })->name('dishes.index'); + + Route::get('/schedule', function () { + return view('schedule.index'); + })->name('schedule.index'); + + Route::get('/users', function () { + return view('users.index'); + })->name('users.index'); + }); }); diff --git a/routes/web/subscription.php b/routes/web/subscription.php new file mode 100644 index 0000000..63bbaf0 --- /dev/null +++ b/routes/web/subscription.php @@ -0,0 +1,13 @@ +group(function () { + Route::get('/subscription', function () { + return view('subscription.index'); + })->name('subscription.index'); + + Route::post('/subscription/subscribe', [SubscriptionController::class, 'subscribe'])->name('subscription.subscribe'); + Route::post('/subscription/cancel', [SubscriptionController::class, 'cancel'])->name('subscription.cancel'); +});