From 0770faaa9fcf373f70e8fef62462f23a6120d47e Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 30 Jun 2026 01:07:24 +0000 Subject: [PATCH] Add Queue Pro billing, renewal, and scheduler. Organizations can upgrade from wallet with plan expiry enforcement and nightly qms:pro-renew charges. Co-authored-by: Cursor --- .../Commands/RenewProSubscriptionsCommand.php | 22 +++++ app/Http/Controllers/Qms/ProController.php | 92 +++++++++++++++++++ app/Providers/AppServiceProvider.php | 18 ++++ app/Services/Qms/PlanService.php | 28 +++++- app/Services/Qms/ProRenewalService.php | 89 ++++++++++++++++++ config/qms.php | 5 + resources/views/partials/sidebar.blade.php | 15 ++- resources/views/qms/pro/index.blade.php | 54 +++++++++++ routes/console.php | 1 + routes/web.php | 4 + tests/Feature/QmsProTest.php | 88 ++++++++++++++++++ 11 files changed, 411 insertions(+), 5 deletions(-) create mode 100644 app/Console/Commands/RenewProSubscriptionsCommand.php create mode 100644 app/Http/Controllers/Qms/ProController.php create mode 100644 app/Services/Qms/ProRenewalService.php create mode 100644 resources/views/qms/pro/index.blade.php create mode 100644 tests/Feature/QmsProTest.php 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