feature - 18 - Add subscriptions

This commit is contained in:
myrmidex 2026-01-05 23:51:50 +01:00
parent b1cf8b5f22
commit 71668ea5bd
10 changed files with 267 additions and 25 deletions

View file

@ -0,0 +1,23 @@
<?php
namespace App\Enums;
enum SubscriptionStatusEnum: string
{
case ACTIVE = 'active';
case CANCELED = 'canceled';
case PAST_DUE = 'past_due';
case TRIALING = 'trialing';
case INCOMPLETE = 'incomplete';
case UNPAID = 'unpaid';
public function isActive(): bool
{
return $this === self::ACTIVE;
}
public function allowsAccess(): bool
{
return in_array($this, [self::ACTIVE, self::TRIALING]);
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SubscriptionStatusEnum;
use App\Models\Subscription;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class SubscriptionController extends Controller
{
public function subscribe(Request $request): RedirectResponse
{
$planner = $request->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.');
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireSubscription
{
public function handle(Request $request, Closure $next): Response
{
if (is_mode_app()) {
return $next($request);
}
$planner = $request->user();
if (! $planner?->hasActiveSubscription()) {
return redirect()->route('subscription.index');
}
return $next($request);
}
}

View file

@ -4,12 +4,14 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
/** /**
* @property int $id * @property int $id
* @property Subscription $subscription
* @property static PlannerFactory factory($count = null, $state = []) * @property static PlannerFactory factory($count = null, $state = [])
* @method static first() * @method static first()
*/ */
@ -33,4 +35,14 @@ public function schedules(): HasMany
{ {
return $this->hasMany(Schedule::class); return $this->hasMany(Schedule::class);
} }
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class);
}
public function hasActiveSubscription(): bool
{
return $this->subscription?->isValid() ?? false;
}
} }

View file

@ -0,0 +1,57 @@
<?php
namespace App\Models;
use App\Enums\SubscriptionStatusEnum;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $planner_id
* @property Planner $planner
* @property string $stripe_subscription_id
* @property string $stripe_customer_id
* @property SubscriptionStatusEnum $status
* @property string $plan
* @property Carbon $trial_ends_at
* @property Carbon $ends_at
*/
class Subscription extends Model
{
protected $fillable = [
'planner_id',
'stripe_subscription_id',
'stripe_customer_id',
'status',
'plan',
'trial_ends_at',
'ends_at',
];
protected $casts = [
'status' => 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;
}
}

View file

@ -1,17 +1,14 @@
<?php <?php
use App\Http\Middleware\ForceJsonResponse; use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\HandleResourceNotFound; use App\Http\Middleware\RequireSubscription;
use App\Services\OutputService; use App\Services\OutputService;
use DishPlanner\Dish\Controllers\DishController;
use DishPlanner\Schedule\Console\Commands\GenerateScheduleCommand;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -21,10 +18,18 @@
api: __DIR__.'/../routes/api.php', api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
then: function () {
Route::middleware('web')
->group(base_path('routes/web/subscription.php'));
},
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
// Apply ForceJsonResponse only to API routes // Apply ForceJsonResponse only to API routes
$middleware->api(ForceJsonResponse::class); $middleware->api(ForceJsonResponse::class);
$middleware->alias([
'subscription' => RequireSubscription::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
@ -65,7 +70,4 @@
} }
}); });
}) })
->withCommands([
GenerateScheduleCommand::class,
])
->create(); ->create();

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,33 @@
<x-layouts.app>
<div class="px-4 sm:px-6 lg:px-8">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">SUBSCRIPTION</h1>
@if(auth()->user()->hasActiveSubscription())
<div class="border-2 border-success rounded-lg p-6 mb-6">
<h3 class="text-xl font-bold text-success mb-2">Active Subscription</h3>
<p class="text-gray-100 mb-4">You have an active subscription.</p>
<form action="{{ route('subscription.cancel') }}" method="POST">
@csrf
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded transition-colors duration-200">
Cancel Subscription
</button>
</form>
</div>
@else
<div class="border-2 border-secondary rounded-lg p-6">
<h3 class="text-xl font-bold text-primary mb-2">Subscribe to Dish Planner</h3>
<p class="text-gray-100 mb-4">Get access to all features.</p>
<form action="{{ route('subscription.subscribe') }}" method="POST">
@csrf
<button type="submit" class="bg-accent-blue hover:bg-blue-600 text-white px-6 py-3 rounded font-bold transition-colors duration-200">
Subscribe Now
</button>
</form>
</div>
@endif
</div>
</div>
</x-layouts.app>

View file

@ -23,13 +23,14 @@
// Authenticated routes // Authenticated routes
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
// Routes requiring active subscription in SaaS mode
Route::middleware('subscription')->group(function () {
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
return view('dashboard'); return view('dashboard');
})->name('dashboard'); })->name('dashboard');
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
// Placeholder routes for future Livewire components
Route::get('/dishes', function () { Route::get('/dishes', function () {
return view('dishes.index'); return view('dishes.index');
})->name('dishes.index'); })->name('dishes.index');
@ -41,4 +42,5 @@
Route::get('/users', function () { Route::get('/users', function () {
return view('users.index'); return view('users.index');
})->name('users.index'); })->name('users.index');
});
}); });

View file

@ -0,0 +1,13 @@
<?php
use App\Http\Controllers\SubscriptionController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->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');
});