feature - 18 - Add subscriptions
This commit is contained in:
parent
b1cf8b5f22
commit
71668ea5bd
10 changed files with 267 additions and 25 deletions
23
app/Enums/SubscriptionStatusEnum.php
Normal file
23
app/Enums/SubscriptionStatusEnum.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/SubscriptionController.php
Normal file
47
app/Http/Controllers/SubscriptionController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
25
app/Http/Middleware/RequireSubscription.php
Normal file
25
app/Http/Middleware/RequireSubscription.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
app/Models/Subscription.php
Normal file
57
app/Models/Subscription.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\ForceJsonResponse;
|
||||
use App\Http\Middleware\HandleResourceNotFound;
|
||||
use App\Http\Middleware\RequireSubscription;
|
||||
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\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
|
|
@ -21,10 +18,18 @@
|
|||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
then: function () {
|
||||
Route::middleware('web')
|
||||
->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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
33
resources/views/subscription/index.blade.php
Normal file
33
resources/views/subscription/index.blade.php
Normal 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>
|
||||
|
|
@ -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');
|
||||
// Routes requiring active subscription in SaaS mode
|
||||
Route::middleware('subscription')->group(function () {
|
||||
Route::get('/dashboard', function () {
|
||||
return view('dashboard');
|
||||
})->name('dashboard');
|
||||
|
||||
Route::get('/schedule', function () {
|
||||
return view('schedule.index');
|
||||
})->name('schedule.index');
|
||||
Route::get('/dishes', function () {
|
||||
return view('dishes.index');
|
||||
})->name('dishes.index');
|
||||
|
||||
Route::get('/users', function () {
|
||||
return view('users.index');
|
||||
})->name('users.index');
|
||||
Route::get('/schedule', function () {
|
||||
return view('schedule.index');
|
||||
})->name('schedule.index');
|
||||
|
||||
Route::get('/users', function () {
|
||||
return view('users.index');
|
||||
})->name('users.index');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
13
routes/web/subscription.php
Normal file
13
routes/web/subscription.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue