diff --git a/app/Filament/Pages/ManageSubscriptions.php b/app/Filament/Pages/ManageSubscriptions.php new file mode 100644 index 0000000..9d62840 --- /dev/null +++ b/app/Filament/Pages/ManageSubscriptions.php @@ -0,0 +1,225 @@ +tenant = $this->getCurrentTenant(); + } + + public function getTenantSubscriptionsTable(): Table + { + return Tables\Table::make($this) + ->query( + $this->tenant + ? Subscription::query()->where('tenant_id', $this->tenant->id) + : Subscription::query() + ) + ->columns([ + Tables\Columns\TextColumn::make('plan.name') + ->label('Plan') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('name') + ->searchable(), + Tables\Columns\TextColumn::make('stripe_status') + ->label('Status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'active' => 'success', + 'canceled' => 'danger', + 'past_due' => 'warning', + 'unpaid' => 'danger', + 'incomplete' => 'warning', + 'incomplete_expired' => 'danger', + default => 'gray', + }), + Tables\Columns\TextColumn::make('trial_ends_at') + ->dateTime() + ->sortable() + ->badge() + ->color(fn ($record) => $record->trial_ends_at && $record->trial_ends_at->isFuture() ? 'info' : 'gray') + ->label('Trial Ends'), + Tables\Columns\TextColumn::make('ends_at') + ->dateTime() + ->sortable() + ->badge() + ->color(fn ($record) => $record->ends_at && $record->ends_at->isFuture() ? 'warning' : 'danger') + ->label('Subscription Ends'), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('stripe_status') + ->label('Status') + ->options([ + 'active' => 'Active', + 'canceled' => 'Canceled', + 'past_due' => 'Past Due', + 'unpaid' => 'Unpaid', + 'incomplete' => 'Incomplete', + 'incomplete_expired' => 'Incomplete Expired', + ]), + Tables\Filters\Filter::make('trial') + ->label('On Trial') + ->query(fn ($query) => $query->whereNotNull('trial_ends_at')->where('trial_ends_at', '>', Carbon::now())), + Tables\Filters\Filter::make('active') + ->label('Active Subscriptions') + ->query(fn ($query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', Carbon::now())), + ]) + ->actions([ + Tables\Actions\Action::make('cancel') + ->label('Cancel Subscription') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->action(function ($record) { + $subscriptionService = app(SubscriptionService::class); + $subscriptionService->cancelSubscription($record); + + Notification::make() + ->title('Subscription cancelled successfully') + ->success() + ->send(); + }) + ->visible(fn ($record) => $record->ends_at === null || $record->ends_at->isFuture()), + ]); + } + + public function getSubscribeForm(): Form + { + return Forms\Form::make($this) + ->schema([ + Forms\Components\Select::make('plan_id') + ->label('Select a Plan') + ->options(Plan::active()->get()->pluck('name', 'id')) + ->required() + ->searchable() + ->preload() + ->columnSpanFull(), + Forms\Components\Select::make('payment_method') + ->label('Payment Method') + ->options([ + 'stripe' => 'Stripe', + 'paystack' => 'Paystack', + ]) + ->required() + ->default('stripe'), + Forms\Components\Select::make('interval') + ->label('Billing Interval') + ->options([ + 'monthly' => 'Monthly', + 'yearly' => 'Yearly', + ]) + ->required() + ->default('monthly'), + ]) + ->statePath('subscribeData'); + } + + public $subscribeData = []; + + public function subscribe() + { + $data = $this->getSubscribeForm()->getState(); + + if (!$this->tenant) { + Notification::make() + ->title('No tenant selected') + ->danger() + ->send(); + + return; + } + + $plan = Plan::find($data['plan_id']); + + if (!$plan) { + Notification::make() + ->title('Invalid plan selected') + ->danger() + ->send(); + + return; + } + + try { + $subscriptionService = app(SubscriptionService::class); + $subscription = $subscriptionService->createSubscription( + $this->tenant, + $plan, + $data['payment_method'], + $data['interval'] + ); + + Notification::make() + ->title('Successfully subscribed to ' . $plan->name . ' plan') + ->success() + ->send(); + + $this->reset('subscribeData'); + } catch (\Exception $e) { + Notification::make() + ->title('Failed to process subscription') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + /** + * Get the current tenant for the authenticated user. + * + * @return \App\Models\Tenant|null + */ + protected function getCurrentTenant() + { + if (SpatieTenant::checkCurrent()) { + return Tenant::find(SpatieTenant::current()->id); + } + + // For super admins or when not in a tenant context + $user = Auth::user(); + if ($user && $user instanceof User && $user->hasRole(User::$SUPER_ADMIN_ROLE)) { + // Super admin might be managing subscriptions for a specific tenant + // You could add logic here to determine which tenant they're managing + return null; + } + + return $user ? $user->tenant : null; + } +} diff --git a/app/Filament/Resources/PlanResource.php b/app/Filament/Resources/PlanResource.php new file mode 100644 index 0000000..93cc209 --- /dev/null +++ b/app/Filament/Resources/PlanResource.php @@ -0,0 +1,197 @@ +schema([ + Forms\Components\Section::make('Basic Information') + ->schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(fn (Forms\Set $set, ?string $state) => $set('slug', Str::slug($state))), + Forms\Components\TextInput::make('slug') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + Forms\Components\Textarea::make('description') + ->rows(3), + Forms\Components\Toggle::make('is_active') + ->required() + ->default(true), + ]) + ->columns(2), + + Forms\Components\Section::make('Pricing') + ->schema([ + Forms\Components\TextInput::make('price_monthly') + ->required() + ->numeric() + ->prefix('$') + ->step(0.01), + Forms\Components\TextInput::make('price_yearly') + ->required() + ->numeric() + ->prefix('$') + ->step(0.01), + Forms\Components\TextInput::make('trial_days') + ->required() + ->numeric() + ->default(0) + ->suffix('days'), + ]) + ->columns(3), + + Forms\Components\Section::make('Limits') + ->schema([ + Forms\Components\TextInput::make('max_schools') + ->required() + ->numeric() + ->default(1), + Forms\Components\TextInput::make('max_students') + ->required() + ->numeric() + ->default(100), + Forms\Components\TextInput::make('max_teachers') + ->required() + ->numeric() + ->default(10), + Forms\Components\TextInput::make('max_parents') + ->required() + ->numeric() + ->default(200), + ]) + ->columns(4), + + Forms\Components\Section::make('Payment Gateway Integration') + ->schema([ + Forms\Components\TextInput::make('stripe_monthly_plan_id') + ->maxLength(255) + ->placeholder('Stripe Monthly Plan ID'), + Forms\Components\TextInput::make('stripe_yearly_plan_id') + ->maxLength(255) + ->placeholder('Stripe Yearly Plan ID'), + Forms\Components\TextInput::make('paystack_monthly_plan_id') + ->maxLength(255) + ->placeholder('Paystack Monthly Plan ID'), + Forms\Components\TextInput::make('paystack_yearly_plan_id') + ->maxLength(255) + ->placeholder('Paystack Yearly Plan ID'), + ]) + ->columns(2), + + Forms\Components\Section::make('Features') + ->schema([ + Forms\Components\Repeater::make('features') + ->schema([ + Forms\Components\TextInput::make('feature') + ->required() + ->maxLength(255), + ]) + ->defaultItems(3) + ->reorderable() + ->collapsible() + ->itemLabel(fn (array $state): ?string => $state['feature'] ?? null), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + Tables\Columns\IconColumn::make('is_active') + ->boolean() + ->sortable(), + Tables\Columns\TextColumn::make('price_monthly') + ->money('USD') + ->sortable(), + Tables\Columns\TextColumn::make('price_yearly') + ->money('USD') + ->sortable(), + Tables\Columns\TextColumn::make('trial_days') + ->numeric() + ->sortable() + ->suffix(' days'), + Tables\Columns\TextColumn::make('max_schools') + ->numeric() + ->sortable(), + Tables\Columns\TextColumn::make('max_students') + ->numeric() + ->sortable(), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_active') + ->label('Active Status') + ->placeholder('All Plans') + ->trueLabel('Active Plans') + ->falseLabel('Inactive Plans'), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + RelationManagers\SubscriptionsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPlans::route('/'), + 'create' => Pages\CreatePlan::route('/create'), + 'edit' => Pages\EditPlan::route('/{record}/edit'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return static::getModel()::count(); + } +} diff --git a/app/Filament/Resources/PlanResource/Pages/CreatePlan.php b/app/Filament/Resources/PlanResource/Pages/CreatePlan.php new file mode 100644 index 0000000..20e28da --- /dev/null +++ b/app/Filament/Resources/PlanResource/Pages/CreatePlan.php @@ -0,0 +1,12 @@ +schema([ + Forms\Components\Select::make('tenant_id') + ->label('Tenant') + ->options(Tenant::all()->pluck('name', 'id')) + ->required() + ->searchable(), + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + Forms\Components\Select::make('stripe_status') + ->label('Status') + ->options([ + 'active' => 'Active', + 'canceled' => 'Canceled', + 'past_due' => 'Past Due', + 'unpaid' => 'Unpaid', + 'incomplete' => 'Incomplete', + 'incomplete_expired' => 'Incomplete Expired', + ]) + ->required(), + Forms\Components\DateTimePicker::make('trial_ends_at') + ->label('Trial Ends At'), + Forms\Components\DateTimePicker::make('ends_at') + ->label('Ends At'), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->columns([ + Tables\Columns\TextColumn::make('tenant.name') + ->label('Tenant') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('name') + ->searchable(), + Tables\Columns\TextColumn::make('stripe_status') + ->label('Status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'active' => 'success', + 'canceled' => 'danger', + 'past_due' => 'warning', + 'unpaid' => 'danger', + 'incomplete' => 'warning', + 'incomplete_expired' => 'danger', + default => 'gray', + }), + Tables\Columns\TextColumn::make('trial_ends_at') + ->dateTime() + ->sortable() + ->badge() + ->color(fn ($record) => $record->trial_ends_at && $record->trial_ends_at->isFuture() ? 'info' : 'gray') + ->label('Trial Ends'), + Tables\Columns\TextColumn::make('ends_at') + ->dateTime() + ->sortable() + ->badge() + ->color(fn ($record) => $record->ends_at && $record->ends_at->isFuture() ? 'warning' : 'danger') + ->label('Subscription Ends'), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('stripe_status') + ->label('Status') + ->options([ + 'active' => 'Active', + 'canceled' => 'Canceled', + 'past_due' => 'Past Due', + 'unpaid' => 'Unpaid', + 'incomplete' => 'Incomplete', + 'incomplete_expired' => 'Incomplete Expired', + ]), + Tables\Filters\Filter::make('trial') + ->label('On Trial') + ->query(fn (Builder $query) => $query->whereNotNull('trial_ends_at')->where('trial_ends_at', '>', Carbon::now())), + Tables\Filters\Filter::make('active') + ->label('Active Subscriptions') + ->query(fn (Builder $query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', Carbon::now())), + ]) + ->headerActions([ + Tables\Actions\CreateAction::make(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + Tables\Actions\Action::make('cancel') + ->label('Cancel Subscription') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->action(fn ($record) => $record->update(['ends_at' => Carbon::now()])) + ->visible(fn ($record) => $record->ends_at === null || $record->ends_at->isFuture()), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/SubscriptionResource.php b/app/Filament/Resources/SubscriptionResource.php new file mode 100644 index 0000000..f857239 --- /dev/null +++ b/app/Filament/Resources/SubscriptionResource.php @@ -0,0 +1,230 @@ +schema([ + Forms\Components\Section::make('Subscription Details') + ->schema([ + Forms\Components\Select::make('tenant_id') + ->label('Tenant') + ->options(Tenant::all()->pluck('name', 'id')) + ->required() + ->searchable() + ->preload(), + Forms\Components\Select::make('plan_id') + ->label('Plan') + ->options(Plan::all()->pluck('name', 'id')) + ->required() + ->searchable() + ->preload(), + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + ]) + ->columns(3), + + Forms\Components\Section::make('Payment Gateway Details') + ->schema([ + Forms\Components\Tabs::make('Payment Gateways') + ->tabs([ + Forms\Components\Tabs\Tab::make('Stripe') + ->schema([ + Forms\Components\TextInput::make('stripe_id') + ->label('Stripe ID') + ->maxLength(255), + Forms\Components\Select::make('stripe_status') + ->label('Status') + ->options([ + 'active' => 'Active', + 'canceled' => 'Canceled', + 'past_due' => 'Past Due', + 'unpaid' => 'Unpaid', + 'incomplete' => 'Incomplete', + 'incomplete_expired' => 'Incomplete Expired', + ]), + Forms\Components\TextInput::make('stripe_price') + ->label('Stripe Price ID') + ->maxLength(255), + ]) + ->columns(3), + + Forms\Components\Tabs\Tab::make('Paystack') + ->schema([ + Forms\Components\TextInput::make('paystack_id') + ->label('Paystack ID') + ->maxLength(255), + Forms\Components\Select::make('paystack_status') + ->label('Status') + ->options([ + 'active' => 'Active', + 'canceled' => 'Canceled', + 'past_due' => 'Past Due', + 'unpaid' => 'Unpaid', + ]), + Forms\Components\TextInput::make('paystack_plan') + ->label('Paystack Plan ID') + ->maxLength(255), + ]) + ->columns(3), + ]), + ]), + + Forms\Components\Section::make('Subscription Period') + ->schema([ + Forms\Components\TextInput::make('quantity') + ->required() + ->numeric() + ->default(1), + Forms\Components\DateTimePicker::make('trial_ends_at') + ->label('Trial Ends At'), + Forms\Components\DateTimePicker::make('ends_at') + ->label('Subscription Ends At'), + ]) + ->columns(3), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('tenant.name') + ->label('Tenant') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('plan.name') + ->label('Plan') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('name') + ->searchable(), + Tables\Columns\TextColumn::make('stripe_status') + ->label('Status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'active' => 'success', + 'canceled' => 'danger', + 'past_due' => 'warning', + 'unpaid' => 'danger', + 'incomplete' => 'warning', + 'incomplete_expired' => 'danger', + default => 'gray', + }), + Tables\Columns\TextColumn::make('trial_ends_at') + ->dateTime() + ->sortable() + ->badge() + ->color(fn ($record) => $record->trial_ends_at && $record->trial_ends_at->isFuture() ? 'info' : 'gray') + ->label('Trial Ends'), + Tables\Columns\TextColumn::make('ends_at') + ->dateTime() + ->sortable() + ->badge() + ->color(fn ($record) => $record->ends_at && $record->ends_at->isFuture() ? 'warning' : 'danger') + ->label('Subscription Ends'), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('tenant_id') + ->label('Tenant') + ->options(Tenant::all()->pluck('name', 'id')) + ->searchable(), + Tables\Filters\SelectFilter::make('plan_id') + ->label('Plan') + ->options(Plan::all()->pluck('name', 'id')) + ->searchable(), + Tables\Filters\SelectFilter::make('stripe_status') + ->label('Status') + ->options([ + 'active' => 'Active', + 'canceled' => 'Canceled', + 'past_due' => 'Past Due', + 'unpaid' => 'Unpaid', + 'incomplete' => 'Incomplete', + 'incomplete_expired' => 'Incomplete Expired', + ]), + Tables\Filters\Filter::make('trial') + ->label('On Trial') + ->query(fn (Builder $query) => $query->whereNotNull('trial_ends_at')->where('trial_ends_at', '>', Carbon::now())), + Tables\Filters\Filter::make('active') + ->label('Active Subscriptions') + ->query(fn (Builder $query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', Carbon::now())), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + Tables\Actions\Action::make('cancel') + ->label('Cancel Subscription') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->action(function ($record) { + $subscriptionService = app(SubscriptionService::class); + $subscriptionService->cancelSubscription($record); + }) + ->visible(fn ($record) => $record->ends_at === null || $record->ends_at->isFuture()), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListSubscriptions::route('/'), + 'create' => Pages\CreateSubscription::route('/create'), + 'edit' => Pages\EditSubscription::route('/{record}/edit'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return static::getModel()::whereNull('ends_at')->orWhere('ends_at', '>', Carbon::now())->count(); + } + + public static function getNavigationBadgeColor(): ?string + { + return 'success'; + } +} diff --git a/app/Filament/Resources/SubscriptionResource/Pages/CreateSubscription.php b/app/Filament/Resources/SubscriptionResource/Pages/CreateSubscription.php new file mode 100644 index 0000000..aa4c9c2 --- /dev/null +++ b/app/Filament/Resources/SubscriptionResource/Pages/CreateSubscription.php @@ -0,0 +1,12 @@ +subscriptionService = $subscriptionService; + $this->middleware('auth'); + } + + /** + * Display a listing of available plans. + * + * @return \Illuminate\Http\Response + */ + public function index() + { + $plans = Plan::active()->get(); + $tenant = $this->getCurrentTenant(); + $currentSubscription = $tenant ? $tenant->subscriptions()->whereNull('ends_at')->first() : null; + + return view('subscriptions.index', compact('plans', 'tenant', 'currentSubscription')); + } + + /** + * Show the form for subscribing to a plan. + * + * @param \App\Models\Plan $plan + * @return \Illuminate\Http\Response + */ + public function showSubscriptionForm(Plan $plan) + { + $tenant = $this->getCurrentTenant(); + + return view('subscriptions.subscribe', compact('plan', 'tenant')); + } + + /** + * Process a subscription to a plan. + * + * @param \Illuminate\Http\Request $request + * @param \App\Models\Plan $plan + * @return \Illuminate\Http\Response + */ + public function subscribe(Request $request, Plan $plan) + { + $request->validate([ + 'payment_method' => 'required|in:stripe,paystack', + 'interval' => 'required|in:monthly,yearly', + ]); + + $tenant = $this->getCurrentTenant(); + + if (!$tenant) { + return redirect()->route('subscriptions.index') + ->with('error', 'You need to create a tenant first.'); + } + + try { + $subscription = $this->subscriptionService->createSubscription( + $tenant, + $plan, + $request->payment_method, + $request->interval + ); + + return redirect()->route('subscriptions.show', $subscription->id) + ->with('success', 'Successfully subscribed to ' . $plan->name . ' plan.'); + } catch (\Exception $e) { + return redirect()->back() + ->with('error', 'Failed to process subscription: ' . $e->getMessage()); + } + } + + /** + * Display the specified subscription. + * + * @param \App\Models\Subscription $subscription + * @return \Illuminate\Http\Response + */ + public function show(Subscription $subscription) + { + $tenant = $this->getCurrentTenant(); + + if ($subscription->tenant_id !== $tenant->id) { + abort(403, 'Unauthorized action.'); + } + + return view('subscriptions.show', compact('subscription')); + } + + /** + * Cancel the specified subscription. + * + * @param \App\Models\Subscription $subscription + * @return \Illuminate\Http\Response + */ + public function cancel(Subscription $subscription) + { + $tenant = $this->getCurrentTenant(); + + if ($subscription->tenant_id !== $tenant->id) { + abort(403, 'Unauthorized action.'); + } + + $result = $this->subscriptionService->cancelSubscription($subscription); + + if ($result) { + return redirect()->route('subscriptions.index') + ->with('success', 'Subscription cancelled successfully.'); + } + + return redirect()->back() + ->with('error', 'Failed to cancel subscription.'); + } + + /** + * Show the form for changing to a different plan. + * + * @param \App\Models\Plan $plan + * @return \Illuminate\Http\Response + */ + public function showChangePlanForm(Plan $plan) + { + $tenant = $this->getCurrentTenant(); + $currentSubscription = $tenant->subscriptions()->whereNull('ends_at')->first(); + + if (!$currentSubscription) { + return redirect()->route('subscriptions.show-subscription-form', $plan->id); + } + + return view('subscriptions.change-plan', compact('plan', 'tenant', 'currentSubscription')); + } + + /** + * Change to a different plan. + * + * @param \Illuminate\Http\Request $request + * @param \App\Models\Plan $plan + * @return \Illuminate\Http\Response + */ + public function changePlan(Request $request, Plan $plan) + { + $request->validate([ + 'interval' => 'required|in:monthly,yearly', + ]); + + $tenant = $this->getCurrentTenant(); + + try { + $subscription = $this->subscriptionService->changePlan( + $tenant, + $plan, + $request->interval + ); + + return redirect()->route('subscriptions.show', $subscription->id) + ->with('success', 'Successfully changed to ' . $plan->name . ' plan.'); + } catch (\Exception $e) { + return redirect()->back() + ->with('error', 'Failed to change plan: ' . $e->getMessage()); + } + } + + /** + * Get the current tenant for the authenticated user. + * + * @return \App\Models\Tenant|null + */ + protected function getCurrentTenant() + { + if (SpatieTenant::checkCurrent()) { + return Tenant::find(SpatieTenant::current()->id); + } + + // For super admins or when not in a tenant context + $user = Auth::user(); + if ($user && $user->hasRole('super-admin')) { + // Super admin might be managing subscriptions for a specific tenant + // You could add logic here to determine which tenant they're managing + return null; + } + + return $user ? $user->tenant : null; + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 0aa6668..b5392ab 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -71,5 +71,6 @@ class Kernel extends HttpKernel 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, 'view-logs' => \App\Http\Middleware\LogMiddleware::class, + 'subscription' => \App\Http\Middleware\CheckSubscription::class, ]; } diff --git a/app/Http/Middleware/CheckSubscription.php b/app/Http/Middleware/CheckSubscription.php new file mode 100644 index 0000000..66b2552 --- /dev/null +++ b/app/Http/Middleware/CheckSubscription.php @@ -0,0 +1,37 @@ +school_id); + + // Skip check for super admin + if (Auth::user()->isSuperAdmin()) { + return $next($request); + } + + // If no tenant or no plan, redirect to subscription page + if (!$tenant || !$tenant->plan) { + return redirect()->route('filament.admin.pages.subscription') + ->with('warning', 'Please subscribe to a plan to continue using the platform.'); + } + + // Check if subscription is active + if (!$tenant->subscription || !$tenant->subscription->isActive()) { + return redirect()->route('filament.admin.pages.subscription') + ->with('warning', 'Your subscription has expired. Please renew to continue using the platform.'); + } + } + + return $next($request); + } +} diff --git a/app/Livewire/ClassStudentsTable.php b/app/Livewire/ClassStudentsTable.php index 5e4b2e8..68d13c2 100644 --- a/app/Livewire/ClassStudentsTable.php +++ b/app/Livewire/ClassStudentsTable.php @@ -24,6 +24,7 @@ use App\Livewire\IRelationalEntityTable; use Filament\Actions\DeleteAction; use Filament\Notifications\Notification; +use App\Models\Tenant; class ClassStudentsTable extends IRelationalEntityTable { @@ -144,6 +145,25 @@ public function removeStudent($index) public function saveStudents() { $class = Classes::find($this->classId); + $tenant = Tenant::find(auth()->user()->school_id); + + if (!$tenant || !$tenant->subscription || !$tenant->subscription->isActive()) { + Notification::make() + ->title('Subscription Required') + ->body('Please subscribe to a plan to add students to classes.') + ->danger() + ->send(); + return; + } + + if ($tenant->exceedsStudentLimit()) { + Notification::make() + ->title('Student Limit Reached') + ->body('You have reached the maximum number of students allowed in your subscription plan.') + ->danger() + ->send(); + return; + } foreach($this->students as $student) { @@ -156,6 +176,12 @@ public function saveStudents() // close the modal $this->dispatch('close-modal', id: 'add-students'); + + Notification::make() + ->title('Students Added') + ->body('Students have been successfully added to the class.') + ->success() + ->send(); } public function mount($classId = null, $students = []) diff --git a/app/Models/Classes.php b/app/Models/Classes.php index 33d7afc..d4b61f9 100644 --- a/app/Models/Classes.php +++ b/app/Models/Classes.php @@ -8,11 +8,23 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Attributes\ScopedBy; use App\Models\Scopes\SessionTermSchoolScope; +use App\Traits\HasSubscriptionLimits; #[ScopedBy([SchoolScope::class])] class Classes extends BaseModel { - use HasFactory, SoftDeletes; + use HasFactory, SoftDeletes, HasSubscriptionLimits; + + protected static function booted(): void + { + parent::booted(); + + static::creating(function ($class) { + if ($class->exceedsClassLimit()) { + throw new \Exception('You have reached the maximum number of classes allowed in your subscription plan.'); + } + }); + } public function users() { @@ -53,4 +65,9 @@ public static function elementarySchool() { return self::whereIn('name', User::$ELEMENTARY_SCHOOL_CLASSES); } + + public function tenant() + { + return $this->belongsTo(Tenant::class, 'school_id'); + } } diff --git a/app/Models/Plan.php b/app/Models/Plan.php new file mode 100644 index 0000000..dee1de6 --- /dev/null +++ b/app/Models/Plan.php @@ -0,0 +1,70 @@ + 'boolean', + 'price_monthly' => 'decimal:2', + 'price_yearly' => 'decimal:2', + 'features' => 'array', + ]; + + /** + * Get the subscriptions for the plan. + */ + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class); + } + + /** + * Get the monthly price formatted. + */ + public function getMonthlyPriceFormattedAttribute(): string + { + return '$' . number_format($this->price_monthly, 2); + } + + /** + * Get the yearly price formatted. + */ + public function getYearlyPriceFormattedAttribute(): string + { + return '$' . number_format($this->price_yearly, 2); + } + + /** + * Get active plans. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/app/Models/School.php b/app/Models/School.php index dd909ad..3bd8486 100644 --- a/app/Models/School.php +++ b/app/Models/School.php @@ -2,14 +2,29 @@ namespace App\Models; +use App\Models\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class School extends BaseModel { - use HasFactory; + use HasFactory, BelongsToTenant; + protected $fillable = [ + 'tenant_id', + 'name', + 'address', + 'phone', + 'email', + 'website', + 'logo', + ]; + + /** + * Get the users for the school. + */ public function users(): HasMany { return $this->hasMany(User::class); diff --git a/app/Models/Scopes/TenantScope.php b/app/Models/Scopes/TenantScope.php new file mode 100644 index 0000000..1b38735 --- /dev/null +++ b/app/Models/Scopes/TenantScope.php @@ -0,0 +1,33 @@ +where($model->getTable() . '.tenant_id', Tenant::current()->id); + } + + /** + * Extend the query builder with the needed functions. + */ + public function extend(Builder $builder): void + { + $builder->macro('withoutTenantScope', function (Builder $builder) { + return $builder->withoutGlobalScope($this); + }); + } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 0000000..d50fc8b --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,75 @@ + 'datetime', + 'ends_at' => 'datetime', + ]; + + /** + * Get the tenant that owns the subscription. + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * Get the plan that the subscription belongs to. + */ + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } + + /** + * Determine if the subscription is active. + */ + public function active(): bool + { + return $this->stripe_status === 'active' || + $this->paystack_status === 'active' || + $this->onTrial(); + } + + /** + * Determine if the subscription is on trial. + */ + public function onTrial(): bool + { + return $this->trial_ends_at && $this->trial_ends_at->isFuture(); + } + + /** + * Determine if the subscription has ended. + */ + public function ended(): bool + { + return $this->ends_at && $this->ends_at->isPast(); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php new file mode 100644 index 0000000..ec371b2 --- /dev/null +++ b/app/Models/Tenant.php @@ -0,0 +1,88 @@ + 'boolean', + 'trial_ends_at' => 'datetime', + ]; + + /** + * Get the plan that the tenant belongs to. + */ + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } + + /** + * Get the schools for the tenant. + */ + public function schools(): HasMany + { + return $this->hasMany(School::class); + } + + /** + * Get the users for the tenant. + */ + public function users(): HasMany + { + return $this->hasMany(User::class); + } + + /** + * Get the subscriptions for the tenant. + */ + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class); + } + + /** + * Check if the tenant is on trial. + */ + public function onTrial(): bool + { + return $this->trial_ends_at && $this->trial_ends_at->isFuture(); + } + + /** + * Check if the tenant's trial has ended. + */ + public function trialEnded(): bool + { + return $this->trial_ends_at && $this->trial_ends_at->isPast(); + } +} diff --git a/app/Models/Traits/BelongsToTenant.php b/app/Models/Traits/BelongsToTenant.php new file mode 100644 index 0000000..c5a3fba --- /dev/null +++ b/app/Models/Traits/BelongsToTenant.php @@ -0,0 +1,32 @@ +tenant_id && \Spatie\Multitenancy\Models\Tenant::checkCurrent()) { + $model->tenant_id = \Spatie\Multitenancy\Models\Tenant::current()->id; + } + }); + } + + /** + * Get the tenant that owns the model. + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 2b0a51a..b42df87 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -22,11 +22,13 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Attributes\ScopedBy; use App\Events\StudentPromoted; +use App\Models\Traits\BelongsToTenant; +use App\Traits\HasSubscriptionLimits; #[ScopedBy([SchoolScope::class])] class User extends Authenticatable implements FilamentUser, HasName, CanResetPassword { - use HasApiTokens, HasFactory, Notifiable, HasSuperAdmin, SoftDeletes; + use HasApiTokens, HasFactory, Notifiable, HasSuperAdmin, SoftDeletes, BelongsToTenant, HasSubscriptionLimits; public static string $TEACHER_ROLE = 'Teacher'; public static string $STUDENT_ROLE = 'Student'; @@ -527,6 +529,16 @@ protected static function booted(): void Log::debug('Model updated with: session: ' . $model->session_id . ' term ' . $model->term_id . ' school_id: ' . $model->school_id); }); + + static::creating(function ($user) { + if ($user->role === 'student' && $user->exceedsStudentLimit()) { + throw new \Exception('You have reached the maximum number of students allowed in your subscription plan.'); + } + + if ($user->role === 'teacher' && $user->exceedsTeacherLimit()) { + throw new \Exception('You have reached the maximum number of teachers allowed in your subscription plan.'); + } + }); } public function promotions() @@ -609,4 +621,12 @@ public function canBeImpersonated() User::$ADMIN_ROLE ]); } + + /** + * Get the tenant that owns the user. + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'school_id'); + } } diff --git a/app/Multitenancy/DomainTenantFinder.php b/app/Multitenancy/DomainTenantFinder.php new file mode 100644 index 0000000..83d3421 --- /dev/null +++ b/app/Multitenancy/DomainTenantFinder.php @@ -0,0 +1,29 @@ +getHost(); + + // Check for a subdomain + $parts = explode('.', $host); + if (count($parts) > 2) { + $subdomain = $parts[0]; + return Tenant::query() + ->where('subdomain', $subdomain) + ->first(); + } + + // Check for a custom domain + return Tenant::query() + ->where('domain', $host) + ->first(); + } +} diff --git a/app/Providers/SubscriptionServiceProvider.php b/app/Providers/SubscriptionServiceProvider.php new file mode 100644 index 0000000..74d7a31 --- /dev/null +++ b/app/Providers/SubscriptionServiceProvider.php @@ -0,0 +1,52 @@ +user; + $tenant = Tenant::find($user->school_id); + + if (!$tenant || !$tenant->subscription || !$tenant->subscription->isActive()) { + throw new \Exception('Please subscribe to a plan to add new users.'); + } + + if ($user->role === 'student' && $tenant->exceedsStudentLimit()) { + throw new \Exception('You have reached the maximum number of students allowed in your subscription plan.'); + } + + if ($user->role === 'teacher' && $tenant->exceedsTeacherLimit()) { + throw new \Exception('You have reached the maximum number of teachers allowed in your subscription plan.'); + } + }); + + // Add subscription-related macros to models + Classes::macro('canAddMore', function () { + return !$this->exceedsClassLimit(); + }); + + User::macro('canAddMoreStudents', function () { + return !$this->exceedsStudentLimit(); + }); + + User::macro('canAddMoreTeachers', function () { + return !$this->exceedsTeacherLimit(); + }); + } +} diff --git a/app/Services/SubscriptionService.php b/app/Services/SubscriptionService.php new file mode 100644 index 0000000..b96a60e --- /dev/null +++ b/app/Services/SubscriptionService.php @@ -0,0 +1,156 @@ +subscriptions() + ->whereNull('ends_at') + ->first(); + + if ($activeSubscription) { + // End the current subscription + $this->cancelSubscription($activeSubscription); + } + + // Create a new subscription + $subscription = new Subscription(); + $subscription->tenant_id = $tenant->id; + $subscription->plan_id = $plan->id; + $subscription->name = $plan->name; + + // Set trial period if applicable + if ($plan->trial_days > 0) { + $subscription->trial_ends_at = Carbon::now()->addDays($plan->trial_days); + } + + // Handle different payment methods + if ($paymentMethod === 'stripe') { + $planId = $interval === 'yearly' ? $plan->stripe_yearly_plan_id : $plan->stripe_monthly_plan_id; + $subscription->stripe_price = $planId; + $subscription->stripe_status = 'active'; + // In a real implementation, you would integrate with Stripe API here + } elseif ($paymentMethod === 'paystack') { + $planId = $interval === 'yearly' ? $plan->paystack_yearly_plan_id : $plan->paystack_monthly_plan_id; + $subscription->paystack_plan = $planId; + $subscription->paystack_status = 'active'; + // In a real implementation, you would integrate with Paystack API here + } + + $subscription->save(); + + // Update tenant's plan + $tenant->plan_id = $plan->id; + $tenant->save(); + + return $subscription; + } + + /** + * Cancel a subscription. + * + * @param Subscription $subscription + * @return bool + */ + public function cancelSubscription(Subscription $subscription): bool + { + try { + // In a real implementation, you would cancel the subscription with the payment provider + if ($subscription->stripe_id) { + // Cancel with Stripe + } elseif ($subscription->paystack_id) { + // Cancel with Paystack + } + + // Mark subscription as ended + $subscription->ends_at = Carbon::now(); + $subscription->save(); + + return true; + } catch (\Exception $e) { + Log::error('Failed to cancel subscription: ' . $e->getMessage()); + return false; + } + } + + /** + * Upgrade or downgrade a subscription. + * + * @param Tenant $tenant + * @param Plan $newPlan + * @param string $interval + * @return Subscription + */ + public function changePlan(Tenant $tenant, Plan $newPlan, string $interval = 'monthly'): Subscription + { + // Get current subscription + $currentSubscription = $tenant->subscriptions() + ->whereNull('ends_at') + ->first(); + + if (!$currentSubscription) { + // If no active subscription, create a new one + return $this->createSubscription($tenant, $newPlan, 'stripe', $interval); + } + + // Cancel current subscription + $this->cancelSubscription($currentSubscription); + + // Create new subscription with new plan + return $this->createSubscription($tenant, $newPlan, 'stripe', $interval); + } + + /** + * Check if a tenant has reached their plan limits. + * + * @param Tenant $tenant + * @param string $resourceType + * @param int $count + * @return bool + */ + public function hasReachedLimit(Tenant $tenant, string $resourceType, int $count = 1): bool + { + if (!$tenant->plan) { + return true; // No plan means no access + } + + switch ($resourceType) { + case 'schools': + $currentCount = $tenant->schools()->count(); + return ($currentCount + $count) > $tenant->plan->max_schools; + + case 'students': + $currentCount = $tenant->users()->role('Student')->count(); + return ($currentCount + $count) > $tenant->plan->max_students; + + case 'teachers': + $currentCount = $tenant->users()->role('Teacher')->count(); + return ($currentCount + $count) > $tenant->plan->max_teachers; + + case 'parents': + $currentCount = $tenant->users()->role('Parent')->count(); + return ($currentCount + $count) > $tenant->plan->max_parents; + + default: + return false; + } + } +} diff --git a/app/Traits/HasSubscriptionLimits.php b/app/Traits/HasSubscriptionLimits.php new file mode 100644 index 0000000..33ef92e --- /dev/null +++ b/app/Traits/HasSubscriptionLimits.php @@ -0,0 +1,81 @@ +school_id); + + if (!$tenant || !$tenant->plan) { + return true; // No valid subscription + } + + $currentClassCount = $tenant->classes()->count(); + return $currentClassCount >= $tenant->plan->max_classes; + } + + public function exceedsStudentLimit(): bool + { + $tenant = Tenant::find(Auth::user()->school_id); + + if (!$tenant || !$tenant->plan) { + return true; // No valid subscription + } + + $currentStudentCount = $tenant->users()->where('role', 'student')->count(); + return $currentStudentCount >= $tenant->plan->max_students; + } + + public function exceedsTeacherLimit(): bool + { + $tenant = Tenant::find(Auth::user()->school_id); + + if (!$tenant || !$tenant->plan) { + return true; // No valid subscription + } + + $currentTeacherCount = $tenant->users()->where('role', 'teacher')->count(); + return $currentTeacherCount >= $tenant->plan->max_teachers; + } + + public function getRemainingClasses(): int + { + $tenant = Tenant::find(Auth::user()->school_id); + + if (!$tenant || !$tenant->plan) { + return 0; + } + + $currentClassCount = $tenant->classes()->count(); + return max(0, $tenant->plan->max_classes - $currentClassCount); + } + + public function getRemainingStudents(): int + { + $tenant = Tenant::find(Auth::user()->school_id); + + if (!$tenant || !$tenant->plan) { + return 0; + } + + $currentStudentCount = $tenant->users()->where('role', 'student')->count(); + return max(0, $tenant->plan->max_students - $currentStudentCount); + } + + public function getRemainingTeachers(): int + { + $tenant = Tenant::find(Auth::user()->school_id); + + if (!$tenant || !$tenant->plan) { + return 0; + } + + $currentTeacherCount = $tenant->users()->where('role', 'teacher')->count(); + return max(0, $tenant->plan->max_teachers - $currentTeacherCount); + } +} diff --git a/composer.json b/composer.json index f772fe4..46714ba 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "opcodesio/log-viewer": "^3.8", "saade/filament-fullcalendar": "^3.0", "spatie/laravel-db-snapshots": "^2.7", + "spatie/laravel-multitenancy": "^3.2", "stechstudio/filament-impersonate": "^3.14", "unicodeveloper/laravel-paystack": "^1.1", "z3d0x/filament-logger": "^0.7.2" diff --git a/composer.lock b/composer.lock index 4e7573e..7e30537 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": "83aad5753cdd775ed4b964dc77aec50d", + "content-hash": "6f88dec1f454bcae2c7ded806297b2cb", "packages": [ { "name": "althinect/filament-spatie-roles-permissions", @@ -6752,6 +6752,85 @@ ], "time": "2024-06-12T15:01:18+00:00" }, + { + "name": "spatie/laravel-multitenancy", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-multitenancy.git", + "reference": "8f80fc78a28d3ab04eba1834eb8d33a5fb7401a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-multitenancy/zipball/8f80fc78a28d3ab04eba1834eb8d33a5fb7401a4", + "reference": "8f80fc78a28d3ab04eba1834eb8d33a5fb7401a4", + "shasum": "" + }, + "require": { + "illuminate/support": "^10.0|^11.0", + "php": "^8.2", + "spatie/laravel-package-tools": "^1.9" + }, + "require-dev": { + "laravel/legacy-factories": "^1.0.4", + "laravel/octane": "^2.3", + "laravel/serializable-closure": "^1.1", + "mockery/mockery": "^1.4", + "orchestra/testbench": "^8.0|^9.0", + "pestphp/pest": "^2.34", + "spatie/valuestore": "^1.2" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Multitenancy": "Spatie\\Multitenancy\\MultitenancyFacade" + }, + "providers": [ + "Spatie\\Multitenancy\\MultitenancyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\Multitenancy\\": "src", + "Spatie\\Multitenancy\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "http://spatie.be", + "role": "Developer" + } + ], + "description": "Make your Laravel app usable by multiple tenants", + "homepage": "https://github.com/spatie/laravel-multitenancy", + "keywords": [ + "laravel-multitenancy", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-multitenancy/issues", + "source": "https://github.com/spatie/laravel-multitenancy/tree/3.2.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2024-03-12T15:53:43+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.16.5", @@ -12152,12 +12231,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.1" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/config/app.php b/config/app.php index 3caf75a..d0b52e9 100644 --- a/config/app.php +++ b/config/app.php @@ -173,6 +173,7 @@ App\Providers\RouteServiceProvider::class, // App\Providers\TelescopeServiceProvider::class, Unicodeveloper\Paystack\PaystackServiceProvider::class, + App\Providers\SubscriptionServiceProvider::class, ])->toArray(), /* diff --git a/config/multitenancy.php b/config/multitenancy.php new file mode 100644 index 0000000..5fa7f3d --- /dev/null +++ b/config/multitenancy.php @@ -0,0 +1,117 @@ + \App\Multitenancy\DomainTenantFinder::class, + + /* + * These fields are used by tenant:artisan command to match one or more tenant. + */ + 'tenant_artisan_search_fields' => [ + 'id', + ], + + /* + * These tasks will be performed when switching tenants. + * + * A valid task is any class that implements Spatie\Multitenancy\Tasks\SwitchTenantTask + */ + 'switch_tenant_tasks' => [ + \Spatie\Multitenancy\Tasks\PrefixCacheTask::class, + \Spatie\Multitenancy\Tasks\SwitchTenantDatabaseTask::class, + \Spatie\Multitenancy\Tasks\SwitchRouteCacheTask::class, + ], + + /* + * This class is the model used for storing configuration on tenants. + * + * It must be or extend `Spatie\Multitenancy\Models\Tenant::class` + */ + 'tenant_model' => Tenant::class, + + /* + * If there is a current tenant when dispatching a job, the id of the current tenant + * will be automatically set on the job. When the job is executed, the set + * tenant on the job will be made current. + */ + 'queues_are_tenant_aware_by_default' => true, + + /* + * The connection name to reach the tenant database. + * + * Set to `null` to use the default connection. + */ + 'tenant_database_connection_name' => null, + + /* + * The connection name to reach the landlord database. + */ + 'landlord_database_connection_name' => null, + + /* + * This key will be used to bind the current tenant in the container. + */ + 'current_tenant_container_key' => 'currentTenant', + + /** + * Set it to `true` if you like to cache the tenant(s) routes + * in a shared file using the `SwitchRouteCacheTask`. + */ + 'shared_routes_cache' => false, + + /* + * You can customize some of the behavior of this package by using your own custom action. + * Your custom action should always extend the default one. + */ + 'actions' => [ + 'make_tenant_current_action' => MakeTenantCurrentAction::class, + 'forget_current_tenant_action' => ForgetCurrentTenantAction::class, + 'make_queue_tenant_aware_action' => MakeQueueTenantAwareAction::class, + 'migrate_tenant' => MigrateTenantAction::class, + ], + + /* + * You can customize the way in which the package resolves the queueable to a job. + * + * For example, using the package laravel-actions (by Loris Leiva), you can + * resolve JobDecorator to getAction() like so: JobDecorator::class => 'getAction' + */ + 'queueable_to_job' => [ + SendQueuedMailable::class => 'mailable', + SendQueuedNotifications::class => 'notification', + CallQueuedClosure::class => 'closure', + CallQueuedListener::class => 'class', + BroadcastEvent::class => 'event', + ], + + /* + * Jobs tenant aware even if these don't implement the TenantAware interface. + */ + 'tenant_aware_jobs' => [ + // ... + ], + + /* + * Jobs not tenant aware even if these don't implement the NotTenantAware interface. + */ + 'not_tenant_aware_jobs' => [ + // ... + ], +]; diff --git a/database/migrations/2025_02_25_201002_create_tenants_table.php b/database/migrations/2025_02_25_201002_create_tenants_table.php new file mode 100644 index 0000000..f4de695 --- /dev/null +++ b/database/migrations/2025_02_25_201002_create_tenants_table.php @@ -0,0 +1,45 @@ +id(); + $table->string('name'); + $table->string('domain')->unique()->nullable(); + $table->string('database')->unique()->nullable(); + $table->string('subdomain')->unique(); + $table->string('logo')->nullable(); + $table->string('email')->unique(); + $table->string('phone')->nullable(); + $table->text('address')->nullable(); + $table->string('city')->nullable(); + $table->string('state')->nullable(); + $table->string('country')->nullable(); + $table->string('zip_code')->nullable(); + $table->string('timezone')->default('UTC'); + $table->string('locale')->default('en'); + $table->string('currency')->default('USD'); + $table->boolean('active')->default(true); + $table->timestamp('trial_ends_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenants'); + } +}; diff --git a/database/migrations/2025_02_25_201508_add_tenant_id_to_schools_table.php b/database/migrations/2025_02_25_201508_add_tenant_id_to_schools_table.php new file mode 100644 index 0000000..a62082b --- /dev/null +++ b/database/migrations/2025_02_25_201508_add_tenant_id_to_schools_table.php @@ -0,0 +1,31 @@ +foreignId('tenant_id')->nullable()->after('id')->constrained('tenants')->onDelete('cascade'); + $table->index('tenant_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('schools', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropIndex(['tenant_id']); + $table->dropColumn('tenant_id'); + }); + } +}; diff --git a/database/migrations/2025_02_25_201551_add_tenant_id_to_users_table.php b/database/migrations/2025_02_25_201551_add_tenant_id_to_users_table.php new file mode 100644 index 0000000..f0b04f2 --- /dev/null +++ b/database/migrations/2025_02_25_201551_add_tenant_id_to_users_table.php @@ -0,0 +1,31 @@ +foreignId('tenant_id')->nullable()->after('id')->constrained('tenants')->onDelete('cascade'); + $table->index('tenant_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropIndex(['tenant_id']); + $table->dropColumn('tenant_id'); + }); + } +}; diff --git a/database/migrations/2025_02_25_201903_add_tenant_id_to_all_tables.php b/database/migrations/2025_02_25_201903_add_tenant_id_to_all_tables.php new file mode 100644 index 0000000..d07423f --- /dev/null +++ b/database/migrations/2025_02_25_201903_add_tenant_id_to_all_tables.php @@ -0,0 +1,71 @@ +tables as $table) { + if (Schema::hasTable($table) && !Schema::hasColumn($table, 'tenant_id')) { + Schema::table($table, function (Blueprint $table) { + $table->foreignId('tenant_id')->nullable()->after('id')->constrained('tenants')->onDelete('cascade'); + $table->index('tenant_id'); + }); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach ($this->tables as $table) { + if (Schema::hasTable($table) && Schema::hasColumn($table, 'tenant_id')) { + Schema::table($table, function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropIndex(['tenant_id']); + $table->dropColumn('tenant_id'); + }); + } + } + } +}; diff --git a/database/migrations/2025_02_25_202037_create_plans_table.php b/database/migrations/2025_02_25_202037_create_plans_table.php new file mode 100644 index 0000000..cac9d2f --- /dev/null +++ b/database/migrations/2025_02_25_202037_create_plans_table.php @@ -0,0 +1,43 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->decimal('price_monthly', 10, 2); + $table->decimal('price_yearly', 10, 2); + $table->string('stripe_monthly_plan_id')->nullable(); + $table->string('stripe_yearly_plan_id')->nullable(); + $table->string('paystack_monthly_plan_id')->nullable(); + $table->string('paystack_yearly_plan_id')->nullable(); + $table->integer('trial_days')->default(0); + $table->integer('max_schools')->default(1); + $table->integer('max_students')->default(100); + $table->integer('max_teachers')->default(10); + $table->integer('max_parents')->default(200); + $table->json('features')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plans'); + } +}; diff --git a/database/migrations/2025_02_25_202104_create_subscriptions_table.php b/database/migrations/2025_02_25_202104_create_subscriptions_table.php new file mode 100644 index 0000000..b5aeddd --- /dev/null +++ b/database/migrations/2025_02_25_202104_create_subscriptions_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->onDelete('cascade'); + $table->foreignId('plan_id')->constrained('plans')->onDelete('cascade'); + $table->string('name'); + $table->string('stripe_id')->nullable()->index(); + $table->string('stripe_status')->nullable(); + $table->string('stripe_price')->nullable(); + $table->string('paystack_id')->nullable()->index(); + $table->string('paystack_status')->nullable(); + $table->string('paystack_plan')->nullable(); + $table->integer('quantity')->default(1); + $table->timestamp('trial_ends_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'stripe_status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/database/migrations/2025_02_25_202418_add_plan_id_to_tenants_table.php b/database/migrations/2025_02_25_202418_add_plan_id_to_tenants_table.php new file mode 100644 index 0000000..38c69ce --- /dev/null +++ b/database/migrations/2025_02_25_202418_add_plan_id_to_tenants_table.php @@ -0,0 +1,31 @@ +foreignId('plan_id')->nullable()->after('id')->constrained('plans')->onDelete('set null'); + $table->index('plan_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropForeign(['plan_id']); + $table->dropIndex(['plan_id']); + $table->dropColumn('plan_id'); + }); + } +}; diff --git a/database/migrations/landlord/2025_02_25_201321_create_landlord_tenants_table.php b/database/migrations/landlord/2025_02_25_201321_create_landlord_tenants_table.php new file mode 100644 index 0000000..ac9299f --- /dev/null +++ b/database/migrations/landlord/2025_02_25_201321_create_landlord_tenants_table.php @@ -0,0 +1,45 @@ +id(); + $table->string('name'); + $table->string('domain')->unique(); + $table->string('database')->unique(); + $table->string('subdomain')->unique(); + $table->string('logo')->nullable(); + $table->string('email')->unique(); + $table->string('phone')->nullable(); + $table->text('address')->nullable(); + $table->string('city')->nullable(); + $table->string('state')->nullable(); + $table->string('country')->nullable(); + $table->string('zip_code')->nullable(); + $table->string('timezone')->default('UTC'); + $table->string('locale')->default('en'); + $table->string('currency')->default('USD'); + $table->boolean('active')->default(true); + $table->timestamp('trial_ends_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::dropIfExists('tenants'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 25a9225..2a65826 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ + PlanSeeder::class, ClassSeeder::class, RoleSeeder::class ]); diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php new file mode 100644 index 0000000..fce6399 --- /dev/null +++ b/database/seeders/PlanSeeder.php @@ -0,0 +1,95 @@ + 'Basic', + 'slug' => 'basic', + 'description' => 'Perfect for small schools with limited needs.', + 'is_active' => true, + 'price_monthly' => 49.99, + 'price_yearly' => 499.99, + 'trial_days' => 14, + 'max_schools' => 1, + 'max_students' => 100, + 'max_teachers' => 10, + 'max_parents' => 200, + 'features' => [ + 'Student Management', + 'Teacher Management', + 'Parent Portal', + 'Basic Reporting', + 'Email Support', + ], + ], + [ + 'name' => 'Standard', + 'slug' => 'standard', + 'description' => 'Ideal for medium-sized schools with growing needs.', + 'is_active' => true, + 'price_monthly' => 99.99, + 'price_yearly' => 999.99, + 'trial_days' => 14, + 'max_schools' => 2, + 'max_students' => 500, + 'max_teachers' => 50, + 'max_parents' => 1000, + 'features' => [ + 'Student Management', + 'Teacher Management', + 'Parent Portal', + 'Advanced Reporting', + 'Fee Management', + 'Library Management', + 'Email & Chat Support', + 'API Access', + ], + ], + [ + 'name' => 'Premium', + 'slug' => 'premium', + 'description' => 'Comprehensive solution for large schools and institutions.', + 'is_active' => true, + 'price_monthly' => 199.99, + 'price_yearly' => 1999.99, + 'trial_days' => 14, + 'max_schools' => 5, + 'max_students' => 2000, + 'max_teachers' => 200, + 'max_parents' => 4000, + 'features' => [ + 'Student Management', + 'Teacher Management', + 'Parent Portal', + 'Advanced Reporting', + 'Fee Management', + 'Library Management', + 'Timetable Management', + 'Attendance Tracking', + 'Exam Management', + 'Custom Branding', + 'Priority Support', + 'API Access', + 'Data Export', + 'Dedicated Account Manager', + ], + ], + ]; + + foreach ($plans as $plan) { + Plan::create($plan); + } + } +} diff --git a/resources/views/filament/pages/manage-subscriptions.blade.php b/resources/views/filament/pages/manage-subscriptions.blade.php new file mode 100644 index 0000000..95b0b84 --- /dev/null +++ b/resources/views/filament/pages/manage-subscriptions.blade.php @@ -0,0 +1,59 @@ + + + + Manage Subscriptions + + + + Manage your subscription plans and billing details. + + + @if($tenant) +
+

Current Tenant: {{ $tenant->name }}

+

+ @if($tenant->plan) + Current Plan: {{ $tenant->plan->name }} + @else + No active plan + @endif +

+
+ @else +
+

No Tenant Selected

+

+ You are viewing this page as a super admin. Please select a tenant to manage their subscriptions. +

+
+ @endif +
+ + + + Current Subscriptions + + + {{ $this->getTenantSubscriptionsTable() }} + + + + + Subscribe to a Plan + + + + Choose a plan and payment method to subscribe. + + +
+ {{ $this->getSubscribeForm() }} + +
+ + Subscribe + +
+
+
+
diff --git a/routes/web.php b/routes/web.php index 6419a92..5a69bda 100644 --- a/routes/web.php +++ b/routes/web.php @@ -43,3 +43,16 @@ Route::get('/admission', [PublicController::class, 'index'])->name('admission.index'); Route::get('/{user_id}/generate-scoresheet', [App\Http\Controllers\PDFController::class, 'generateScoresheet']); + +/** + * Subscription routes + */ +Route::prefix('subscriptions')->name('subscriptions.')->group(function () { + Route::get('/', [App\Http\Controllers\SubscriptionController::class, 'index'])->name('index'); + Route::get('/plans/{plan}', [App\Http\Controllers\SubscriptionController::class, 'showSubscriptionForm'])->name('show-subscription-form'); + Route::post('/plans/{plan}/subscribe', [App\Http\Controllers\SubscriptionController::class, 'subscribe'])->name('subscribe'); + Route::get('/{subscription}', [App\Http\Controllers\SubscriptionController::class, 'show'])->name('show'); + Route::post('/{subscription}/cancel', [App\Http\Controllers\SubscriptionController::class, 'cancel'])->name('cancel'); + Route::get('/plans/{plan}/change', [App\Http\Controllers\SubscriptionController::class, 'showChangePlanForm'])->name('show-change-plan-form'); + Route::post('/plans/{plan}/change', [App\Http\Controllers\SubscriptionController::class, 'changePlan'])->name('change-plan'); +});