diff --git a/app/Console/Commands/RenewProSubscriptionsCommand.php b/app/Console/Commands/RenewProSubscriptionsCommand.php new file mode 100644 index 0000000..b2248c5 --- /dev/null +++ b/app/Console/Commands/RenewProSubscriptionsCommand.php @@ -0,0 +1,22 @@ +dueOrganizations(); + $this->info("Renewing {$due->count()} due organization(s)."); + $due->each(fn ($organization) => $renewals->renewIfDue($organization)); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Qms/ProController.php b/app/Http/Controllers/Qms/ProController.php new file mode 100644 index 0000000..ec3d4ac --- /dev/null +++ b/app/Http/Controllers/Qms/ProController.php @@ -0,0 +1,92 @@ +organization($request); + $canManage = app(QmsPermissions::class)->can($this->member($request), 'settings.manage'); + $settings = $organization->settings ?? []; + $expiresAt = ! empty($settings['plan_expires_at']) + ? Carbon::parse($settings['plan_expires_at']) + : null; + + return view('qms.pro.index', [ + 'organization' => $organization, + 'isPro' => $plans->isPro($organization), + 'canManage' => $canManage, + 'priceMinor' => $plans->proPriceMinor(), + 'currency' => (string) config('billing.currency', 'GHS'), + 'planExpiresAt' => $expiresAt, + 'proFeatures' => [ + 'Unlimited branches & queues', + 'Advanced routing rules', + 'Digital displays & kiosk devices', + 'Appointment reminders', + 'Analytics & report exports', + ], + ]); + } + + public function subscribe(Request $request, PlanService $plans, BillingClient $billing): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + + if ($plans->isPro($organization)) { + return redirect()->route('qms.pro.index') + ->with('success', 'Your organization is already on Queue Pro.'); + } + + $priceMinor = $plans->proPriceMinor(); + + try { + if (! $billing->canAfford($organization->owner_ref, $priceMinor)) { + return redirect()->route('qms.pro.index') + ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to upgrade to Pro.'); + } + + $charged = $billing->debit( + $organization->owner_ref, + $priceMinor, + 'queue_pro', + 'queue-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'), + 'Ladill Queue Pro — monthly subscription', + ); + } catch (\Throwable) { + return redirect()->route('qms.pro.index') + ->with('error', 'Could not process upgrade. Please try again.'); + } + + if (! $charged) { + return redirect()->route('qms.pro.index') + ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to upgrade to Pro.'); + } + + $settings = $organization->settings ?? []; + $settings['plan'] = 'pro'; + $settings['auto_renew'] = true; + $settings['plan_expires_at'] = now() + ->addDays((int) config('qms.pro.period_days', 30)) + ->toIso8601String(); + unset($settings['plan_renewal_error']); + $organization->update(['settings' => $settings]); + + return redirect()->route('qms.pro.index') + ->with('success', 'Welcome to Queue Pro — active for one month.'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1185430..ce224d4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,9 @@ namespace App\Providers; use App\Events\ServiceEventOccurred; use App\Listeners\PlatformServiceEventListener; +use App\Models\User; +use App\Services\Qms\OrganizationResolver; +use App\Services\Qms\PlanService; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; @@ -28,6 +31,21 @@ class AppServiceProvider extends ServiceProvider return Limit::perMinute(30)->by((string) $key); }); + View::composer('partials.sidebar', function ($view) { + /** @var User|null $user */ + $user = auth()->user(); + $isPro = false; + + if ($user) { + $organization = app(OrganizationResolver::class)->resolveForUser($user); + if ($organization) { + $isPro = app(PlanService::class)->isPro($organization); + } + } + + $view->with('isPro', $isPro); + }); + View::composer(['partials.topbar'], function ($view) { $view->with(\App\Support\MobileTopbar::resolve()); }); diff --git a/app/Services/Qms/PlanService.php b/app/Services/Qms/PlanService.php index fceb085..e80949a 100644 --- a/app/Services/Qms/PlanService.php +++ b/app/Services/Qms/PlanService.php @@ -3,27 +3,42 @@ namespace App\Services\Qms; use App\Models\Organization; +use Carbon\Carbon; class PlanService { + public function planKey(Organization $organization): string + { + $settings = $organization->settings ?? []; + $plan = (string) ($settings['plan'] ?? 'free'); + + if ($plan === 'pro' && ! empty($settings['plan_expires_at'])) { + if (Carbon::parse($settings['plan_expires_at'])->isPast()) { + return 'free'; + } + } + + return array_key_exists($plan, config('qms.plans', [])) ? $plan : 'free'; + } + public function plan(Organization $organization): string { - return (string) data_get($organization->settings, 'plan', 'free'); + return $this->planKey($organization); } public function isPro(Organization $organization): bool { - return $this->plan($organization) === 'pro'; + return $this->planKey($organization) === 'pro'; } public function maxBranches(Organization $organization): ?int { - return config('qms.plans.'.$this->plan($organization).'.max_branches'); + return config('qms.plans.'.$this->planKey($organization).'.max_branches'); } public function maxQueues(Organization $organization): ?int { - return config('qms.plans.'.$this->plan($organization).'.max_queues'); + return config('qms.plans.'.$this->planKey($organization).'.max_queues'); } public function canAddBranch(Organization $organization, int $currentCount): bool @@ -39,4 +54,9 @@ class PlanService return $max === null || $currentCount < $max; } + + public function proPriceMinor(): int + { + return (int) config('qms.plans.pro.price_minor', 9900); + } } diff --git a/app/Services/Qms/ProRenewalService.php b/app/Services/Qms/ProRenewalService.php new file mode 100644 index 0000000..34e75df --- /dev/null +++ b/app/Services/Qms/ProRenewalService.php @@ -0,0 +1,89 @@ + */ + public function dueOrganizations(): Collection + { + return Organization::query() + ->where('settings->plan', 'pro') + ->whereNotNull('settings->plan_expires_at') + ->get() + ->filter(fn (Organization $organization) => $this->isDue($organization)); + } + + public function isDue(Organization $organization): bool + { + $settings = $organization->settings ?? []; + if (($settings['plan'] ?? '') !== 'pro') { + return false; + } + if (array_key_exists('auto_renew', $settings) && $settings['auto_renew'] === false) { + return false; + } + if (empty($settings['plan_expires_at'])) { + return false; + } + + return Carbon::parse($settings['plan_expires_at'])->lte(now()); + } + + public function renewIfDue(Organization $organization): void + { + if (! $this->isDue($organization)) { + return; + } + + $settings = $organization->settings ?? []; + $price = $this->plans->proPriceMinor(); + $reference = 'queue-pro-'.$organization->id.'-'.now()->format('YmdHis'); + + try { + $charged = $this->billing->debit( + $organization->owner_ref, + $price, + 'queue_pro_renewal', + $reference, + 'Ladill Queue Pro — monthly renewal', + ); + } catch (\Throwable) { + $charged = false; + } + + if ($charged) { + $settings['plan'] = 'pro'; + $settings['auto_renew'] = true; + $settings['plan_expires_at'] = now() + ->addDays((int) config('qms.pro.period_days', 30)) + ->toIso8601String(); + unset($settings['plan_renewal_error']); + $organization->update(['settings' => $settings]); + + return; + } + + $expires = Carbon::parse($settings['plan_expires_at']); + $graceEnd = $expires->copy()->addDays((int) config('qms.pro.grace_days', 3)); + if ($graceEnd->isPast()) { + $settings['plan'] = 'free'; + unset($settings['plan_expires_at']); + $settings['plan_renewal_error'] = 'Suspended after failed renewal.'; + } else { + $settings['plan_renewal_error'] = 'Renewal failed — top up your wallet.'; + } + + $organization->update(['settings' => $settings]); + } +} diff --git a/config/qms.php b/config/qms.php index f09a173..1ce4913 100644 --- a/config/qms.php +++ b/config/qms.php @@ -194,6 +194,11 @@ return [ ], ], + 'pro' => [ + 'grace_days' => (int) env('QUEUE_PRO_GRACE_DAYS', 3), + 'period_days' => (int) env('QUEUE_PRO_PERIOD_DAYS', 30), + ], + 'rule_types' => [ 'overflow' => 'Overflow routing', 'priority_boost' => 'Priority boost', diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 4ffa547..aa38f27 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -68,7 +68,7 @@ 'icon' => '']; } - $settingsActive = request()->routeIs('qms.settings.*'); + $settingsActive = request()->routeIs('qms.settings.*') && ! request()->routeIs('qms.pro.*'); @endphp @foreach ($nav as $item) @@ -98,6 +98,19 @@ Settings + + @php $proActive = request()->routeIs('qms.pro.*'); @endphp + @if (! empty($isPro)) + + + Queue Pro + + @else + + + Upgrade to Pro + + @endif @endif diff --git a/resources/views/qms/pro/index.blade.php b/resources/views/qms/pro/index.blade.php new file mode 100644 index 0000000..0637893 --- /dev/null +++ b/resources/views/qms/pro/index.blade.php @@ -0,0 +1,54 @@ + + @php + $price = number_format($priceMinor / 100, 2); + @endphp + + + + + + Pro + @if ($isPro) + Active + @endif + + Ladill Queue Pro + Unlimited branches, queues, and advanced queue management. + {{ $currency }} {{ $price }} / month + + + + + @foreach ($proFeatures as $feature) + + + {{ $feature }} + + @endforeach + + + + @if ($isPro) + + @if ($planExpiresAt) + Pro is active until {{ $planExpiresAt->format('d M Y') }}. + @else + Your organization is on Queue Pro. + @endif + + @elseif ($canManage) + + @csrf + + Upgrade to Pro — {{ $currency }} {{ $price }}/mo from wallet + + + Billed monthly from your Ladill wallet. + @else + Ask an organization administrator to upgrade this workspace to Pro. + @endif + + + + + diff --git a/routes/console.php b/routes/console.php index d8ed87a..4029570 100644 --- a/routes/console.php +++ b/routes/console.php @@ -10,3 +10,4 @@ Artisan::command('inspire', function () { Schedule::command('qms:mark-devices-offline')->everyFiveMinutes(); Schedule::command('qms:send-appointment-reminders')->everyFifteenMinutes(); +Schedule::command('qms:pro-renew')->dailyAt('02:40')->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index cacbede..593c096 100644 --- a/routes/web.php +++ b/routes/web.php @@ -22,6 +22,7 @@ use App\Http\Controllers\Qms\MobileQueueController; use App\Http\Controllers\Qms\OnboardingController; use App\Http\Controllers\Qms\QueueBoardController; use App\Http\Controllers\Qms\QueueRuleController; +use App\Http\Controllers\Qms\ProController; use App\Http\Controllers\Qms\ReportController; use App\Http\Controllers\Qms\ServiceQueueController; use App\Http\Controllers\Qms\SettingsController; @@ -168,5 +169,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/settings', [SettingsController::class, 'edit'])->name('qms.settings.edit'); Route::put('/settings', [SettingsController::class, 'update'])->name('qms.settings.update'); + + Route::get('/pro', [ProController::class, 'index'])->name('qms.pro.index'); + Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('qms.pro.subscribe'); }); }); diff --git a/tests/Feature/QmsProTest.php b/tests/Feature/QmsProTest.php new file mode 100644 index 0000000..2ecb67b --- /dev/null +++ b/tests/Feature/QmsProTest.php @@ -0,0 +1,88 @@ +withoutMiddleware(EnsurePlatformSession::class); + config(['billing.api_url' => 'https://billing.test']); + + $this->owner = User::create([ + 'public_id' => 'queue-owner-001', + 'name' => 'Owner', + 'email' => 'owner@example.com', + 'password' => bcrypt('password'), + ]); + + $this->organization = app(OrganizationResolver::class)->completeOnboarding($this->owner, [ + 'organization_name' => 'Queue Org', + 'industry' => 'retail', + 'appointment_mode' => 'hybrid', + 'branch_name' => 'Main', + 'timezone' => 'UTC', + ]); + } + + public function test_pro_page_renders_for_free_organization(): void + { + $this->actingAs($this->owner) + ->get(route('qms.pro.index')) + ->assertOk() + ->assertSee('Ladill Queue Pro') + ->assertSee('GHS 99') + ->assertSee('Upgrade to Pro'); + } + + public function test_subscribe_upgrades_organization(): void + { + Http::fake([ + 'billing.test/can-afford*' => Http::response(['affordable' => true]), + 'billing.test/debit' => Http::response(['ok' => true]), + ]); + + $this->actingAs($this->owner) + ->post(route('qms.pro.subscribe')) + ->assertRedirect(route('qms.pro.index')) + ->assertSessionHas('success'); + + $this->assertSame('pro', $this->organization->fresh()->settings['plan']); + } + + public function test_pro_renew_extends_subscription_when_wallet_charges(): void + { + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'plan' => 'pro', + 'auto_renew' => true, + 'plan_expires_at' => now()->subDay()->toIso8601String(), + ]), + ]); + + Http::fake([ + 'billing.test/debit' => Http::response(['ok' => true]), + ]); + + $this->artisan('qms:pro-renew')->assertSuccessful(); + + $settings = $this->organization->fresh()->settings; + $this->assertSame('pro', $settings['plan']); + $this->assertTrue(now()->parse($settings['plan_expires_at'])->isFuture()); + } +}
Unlimited branches, queues, and advanced queue management.
{{ $currency }} {{ $price }} / month
Billed monthly from your Ladill wallet.
Ask an organization administrator to upgrade this workspace to Pro.