diff --git a/app/Http/Controllers/Qms/DashboardController.php b/app/Http/Controllers/Qms/DashboardController.php index a6781ec..27e9ce4 100644 --- a/app/Http/Controllers/Qms/DashboardController.php +++ b/app/Http/Controllers/Qms/DashboardController.php @@ -9,6 +9,7 @@ use App\Models\Member; use App\Models\ServiceQueue; use App\Services\Qms\DashboardStats; use App\Services\Qms\OrganizationResolver; +use App\Services\Qms\PlanService; use Illuminate\Http\Request; use Illuminate\View\View; @@ -42,7 +43,9 @@ class DashboardController extends Controller $operational = $this->stats->forOrganization($owner, $organization->id, $branchScope); $branches = (clone $branchQuery)->withCount('serviceQueues')->orderBy('name')->get(); + $plans = app(PlanService::class); + $hasPaidPlan = $plans->hasPaidPlan($organization); - return view('qms.dashboard', compact('organization', 'orgStats', 'branches', 'operational')); + return view('qms.dashboard', compact('organization', 'orgStats', 'branches', 'operational', 'hasPaidPlan')); } } diff --git a/app/Http/Controllers/Qms/ProController.php b/app/Http/Controllers/Qms/ProController.php index ec3d4ac..68a0413 100644 --- a/app/Http/Controllers/Qms/ProController.php +++ b/app/Http/Controllers/Qms/ProController.php @@ -24,21 +24,27 @@ class ProController extends Controller $expiresAt = ! empty($settings['plan_expires_at']) ? Carbon::parse($settings['plan_expires_at']) : null; + $planKey = $plans->planKey($organization); + $branchCount = $plans->activeBranchCount($organization); + $enterprisePrice = $plans->enterprisePriceMinor($organization); return view('qms.pro.index', [ 'organization' => $organization, - 'isPro' => $plans->isPro($organization), + 'planKey' => $planKey, + 'isPro' => $planKey === 'pro', + 'isEnterprise' => $planKey === 'enterprise', + 'hasPaidPlan' => $plans->hasPaidPlan($organization), 'canManage' => $canManage, - 'priceMinor' => $plans->proPriceMinor(), + 'proPriceMinor' => $plans->proPriceMinor(), + 'enterprisePricePerBranchMinor' => $plans->enterprisePricePerBranchMinor(), + 'enterprisePriceMinor' => $enterprisePrice, + 'branchCount' => $branchCount, + 'canSubscribeEnterprise' => $plans->canSubscribeEnterprise($organization), + 'enterpriseMinBranches' => (int) config('qms.enterprise.min_branches', 2), 'currency' => (string) config('billing.currency', 'GHS'), 'planExpiresAt' => $expiresAt, - 'proFeatures' => [ - 'Unlimited branches & queues', - 'Advanced routing rules', - 'Digital displays & kiosk devices', - 'Appointment reminders', - 'Analytics & report exports', - ], + 'enterpriseBilledBranches' => (int) ($settings['enterprise_billed_branches'] ?? $branchCount), + 'salesUrl' => ladill_platform_url('contact-sales'), ]); } @@ -47,25 +53,78 @@ class ProController extends Controller $this->authorizeAbility($request, 'settings.manage'); $organization = $this->organization($request); - if ($plans->isPro($organization)) { + if ($plans->hasPaidPlan($organization)) { return redirect()->route('qms.pro.index') - ->with('success', 'Your organization is already on Queue Pro.'); + ->with('success', 'Your organization already has an active paid plan.'); } - $priceMinor = $plans->proPriceMinor(); + return $this->chargePlan( + $organization, + $billing, + $plans->proPriceMinor(), + 'pro', + 'queue_pro', + 'queue-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'), + 'Ladill Queue Pro — monthly subscription', + 'Welcome to Queue Pro — active for one month.', + ); + } + public function subscribeEnterprise(Request $request, PlanService $plans, BillingClient $billing): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + + if ($plans->hasPaidPlan($organization)) { + return redirect()->route('qms.pro.index') + ->with('success', 'Your organization already has an active paid plan.'); + } + + if (! $plans->canSubscribeEnterprise($organization)) { + $min = (int) config('qms.enterprise.min_branches', 2); + + return redirect()->route('qms.pro.index') + ->with('error', "Enterprise billing requires at least {$min} active branches. Add another branch or choose Pro for a single site."); + } + + $priceMinor = $plans->enterprisePriceMinor($organization); + + return $this->chargePlan( + $organization, + $billing, + $priceMinor, + 'enterprise', + 'queue_enterprise', + 'queue-enterprise-'.$organization->id.'-'.now()->format('Y-m-d-His'), + 'Ladill Queue Enterprise — monthly subscription', + 'Welcome to Queue Enterprise — active for one month.', + $plans->activeBranchCount($organization), + ); + } + + private function chargePlan( + $organization, + BillingClient $billing, + int $priceMinor, + string $plan, + string $billingSource, + string $reference, + string $description, + string $successMessage, + ?int $enterpriseBranches = null, + ): RedirectResponse { 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.'); + ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to upgrade.'); } $charged = $billing->debit( $organization->owner_ref, $priceMinor, - 'queue_pro', - 'queue-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'), - 'Ladill Queue Pro — monthly subscription', + $billingSource, + $reference, + $description, ); } catch (\Throwable) { return redirect()->route('qms.pro.index') @@ -74,19 +133,21 @@ class ProController extends Controller if (! $charged) { return redirect()->route('qms.pro.index') - ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to upgrade to Pro.'); + ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to upgrade.'); } $settings = $organization->settings ?? []; - $settings['plan'] = 'pro'; + $settings['plan'] = $plan; $settings['auto_renew'] = true; $settings['plan_expires_at'] = now() ->addDays((int) config('qms.pro.period_days', 30)) ->toIso8601String(); + if ($enterpriseBranches !== null) { + $settings['enterprise_billed_branches'] = $enterpriseBranches; + } 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.'); + return redirect()->route('qms.pro.index')->with('success', $successMessage); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ce224d4..085a127 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -34,16 +34,28 @@ class AppServiceProvider extends ServiceProvider View::composer('partials.sidebar', function ($view) { /** @var User|null $user */ $user = auth()->user(); + $planKey = 'free'; $isPro = false; + $isEnterprise = false; + $hasPaidPlan = false; if ($user) { $organization = app(OrganizationResolver::class)->resolveForUser($user); if ($organization) { - $isPro = app(PlanService::class)->isPro($organization); + $plans = app(PlanService::class); + $planKey = $plans->planKey($organization); + $isPro = $planKey === 'pro'; + $isEnterprise = $planKey === 'enterprise'; + $hasPaidPlan = $plans->hasPaidPlan($organization); } } - $view->with('isPro', $isPro); + $view->with([ + 'planKey' => $planKey, + 'isPro' => $isPro, + 'isEnterprise' => $isEnterprise, + 'hasPaidPlan' => $hasPaidPlan, + ]); }); View::composer(['partials.topbar'], function ($view) { diff --git a/app/Services/Qms/PlanService.php b/app/Services/Qms/PlanService.php index e80949a..5047f4b 100644 --- a/app/Services/Qms/PlanService.php +++ b/app/Services/Qms/PlanService.php @@ -2,6 +2,7 @@ namespace App\Services\Qms; +use App\Models\Branch; use App\Models\Organization; use Carbon\Carbon; @@ -12,7 +13,7 @@ class PlanService $settings = $organization->settings ?? []; $plan = (string) ($settings['plan'] ?? 'free'); - if ($plan === 'pro' && ! empty($settings['plan_expires_at'])) { + if (in_array($plan, ['pro', 'enterprise'], true) && ! empty($settings['plan_expires_at'])) { if (Carbon::parse($settings['plan_expires_at'])->isPast()) { return 'free'; } @@ -31,6 +32,24 @@ class PlanService return $this->planKey($organization) === 'pro'; } + public function isEnterprise(Organization $organization): bool + { + return $this->planKey($organization) === 'enterprise'; + } + + public function hasPaidPlan(Organization $organization): bool + { + return in_array($this->planKey($organization), ['pro', 'enterprise'], true); + } + + public function activeBranchCount(Organization $organization): int + { + return Branch::query() + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->count(); + } + public function maxBranches(Organization $organization): ?int { return config('qms.plans.'.$this->planKey($organization).'.max_branches'); @@ -59,4 +78,21 @@ class PlanService { return (int) config('qms.plans.pro.price_minor', 9900); } + + public function enterprisePricePerBranchMinor(): int + { + return (int) config('qms.plans.enterprise.price_minor_per_branch', 29900); + } + + public function enterprisePriceMinor(Organization $organization): int + { + $branches = max(1, $this->activeBranchCount($organization)); + + return $branches * $this->enterprisePricePerBranchMinor(); + } + + public function canSubscribeEnterprise(Organization $organization): bool + { + return $this->activeBranchCount($organization) >= (int) config('qms.enterprise.min_branches', 2); + } } diff --git a/app/Services/Qms/ProRenewalService.php b/app/Services/Qms/ProRenewalService.php index 34e75df..e527661 100644 --- a/app/Services/Qms/ProRenewalService.php +++ b/app/Services/Qms/ProRenewalService.php @@ -18,7 +18,7 @@ class ProRenewalService public function dueOrganizations(): Collection { return Organization::query() - ->where('settings->plan', 'pro') + ->whereIn('settings->plan', ['pro', 'enterprise']) ->whereNotNull('settings->plan_expires_at') ->get() ->filter(fn (Organization $organization) => $this->isDue($organization)); @@ -27,7 +27,8 @@ class ProRenewalService public function isDue(Organization $organization): bool { $settings = $organization->settings ?? []; - if (($settings['plan'] ?? '') !== 'pro') { + $plan = (string) ($settings['plan'] ?? 'free'); + if (! in_array($plan, ['pro', 'enterprise'], true)) { return false; } if (array_key_exists('auto_renew', $settings) && $settings['auto_renew'] === false) { @@ -47,27 +48,37 @@ class ProRenewalService } $settings = $organization->settings ?? []; - $price = $this->plans->proPriceMinor(); - $reference = 'queue-pro-'.$organization->id.'-'.now()->format('YmdHis'); + $plan = (string) ($settings['plan'] ?? 'free'); + $isEnterprise = $plan === 'enterprise'; + $price = $isEnterprise + ? $this->plans->enterprisePriceMinor($organization) + : $this->plans->proPriceMinor(); + $reference = ($isEnterprise ? 'queue-enterprise-' : 'queue-pro-') + .$organization->id.'-'.now()->format('YmdHis'); try { $charged = $this->billing->debit( $organization->owner_ref, $price, - 'queue_pro_renewal', + $isEnterprise ? 'queue_enterprise_renewal' : 'queue_pro_renewal', $reference, - 'Ladill Queue Pro — monthly renewal', + $isEnterprise + ? 'Ladill Queue Enterprise — monthly renewal' + : 'Ladill Queue Pro — monthly renewal', ); } catch (\Throwable) { $charged = false; } if ($charged) { - $settings['plan'] = 'pro'; + $settings['plan'] = $plan; $settings['auto_renew'] = true; $settings['plan_expires_at'] = now() ->addDays((int) config('qms.pro.period_days', 30)) ->toIso8601String(); + if ($isEnterprise) { + $settings['enterprise_billed_branches'] = $this->plans->activeBranchCount($organization); + } unset($settings['plan_renewal_error']); $organization->update(['settings' => $settings]); @@ -78,7 +89,7 @@ class ProRenewalService $graceEnd = $expires->copy()->addDays((int) config('qms.pro.grace_days', 3)); if ($graceEnd->isPast()) { $settings['plan'] = 'free'; - unset($settings['plan_expires_at']); + unset($settings['plan_expires_at'], $settings['enterprise_billed_branches']); $settings['plan_renewal_error'] = 'Suspended after failed renewal.'; } else { $settings['plan_renewal_error'] = 'Renewal failed — top up your wallet.'; diff --git a/config/qms.php b/config/qms.php index 1ce4913..d790628 100644 --- a/config/qms.php +++ b/config/qms.php @@ -199,6 +199,10 @@ return [ 'period_days' => (int) env('QUEUE_PRO_PERIOD_DAYS', 30), ], + 'enterprise' => [ + 'min_branches' => (int) env('QUEUE_ENTERPRISE_MIN_BRANCHES', 2), + ], + '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 aa38f27..d8c488e 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -98,19 +98,21 @@ Settings - - @php $proActive = request()->routeIs('qms.pro.*'); @endphp - @if (! empty($isPro)) - - - Queue Pro - - @else - - - Upgrade to Pro - - @endif @endif + +
+ @php $proActive = request()->routeIs('qms.pro.*'); @endphp + @if (! empty($hasPaidPlan)) + + + {{ ! empty($isEnterprise) ? 'Queue Enterprise' : 'Queue Pro' }} + + @else + + + Upgrade to Pro + + @endif +
diff --git a/resources/views/qms/dashboard.blade.php b/resources/views/qms/dashboard.blade.php index 1b2e76b..7061388 100644 --- a/resources/views/qms/dashboard.blade.php +++ b/resources/views/qms/dashboard.blade.php @@ -68,6 +68,16 @@ @endif + @if (empty($hasPaidPlan)) +
+
+

Unlock Queue Pro or Enterprise

+

Unlimited branches, advanced routing, Care & Frontdesk integrations — from GHS 99/mo.

+
+ View plans +
+ @endif +

Live operations

diff --git a/resources/views/qms/pro/index.blade.php b/resources/views/qms/pro/index.blade.php index 0637893..0b52aaa 100644 --- a/resources/views/qms/pro/index.blade.php +++ b/resources/views/qms/pro/index.blade.php @@ -1,51 +1,113 @@ - + @php - $price = number_format($priceMinor / 100, 2); + $proPrice = number_format($proPriceMinor / 100, 0); + $enterprisePerBranch = number_format($enterprisePricePerBranchMinor / 100, 0); + $enterpriseTotal = number_format($enterprisePriceMinor / 100, 0); @endphp -
-
-
-
- Pro - @if ($isPro) - Active - @endif -
-

Ladill Queue Pro

-

Unlimited branches, queues, and advanced queue management.

-

{{ $currency }} {{ $price }} / month

+
+ @if (session('success')) +
{{ session('success') }}
+ @endif + @if (session('error')) +
{{ session('error') }}
+ @endif + +
+

Choose your Queue plan

+

Billed monthly from your Ladill wallet. Enterprise includes a one-time setup fee — contact sales for onboarding.

+
+ +
+ {{-- Free --}} +
+

Free

+

GHS 0

+

1 branch · 5 queues

+
    +
  • Core queue & tickets
  • +
  • 1 kiosk · 1 display
  • +
  • Basic analytics
  • +
+ @if ($planKey === 'free') +

Current plan

+ @endif
-
-
    - @foreach ($proFeatures as $feature) -
  • - - {{ $feature }} -
  • - @endforeach + {{-- Pro --}} +
    +

    Pro

    +

    {{ $currency }} {{ $proPrice }}/mo

    +

    Unlimited branches & queues

    +
      +
    • Advanced routing rules
    • +
    • Unlimited kiosks & displays
    • +
    • Care & Frontdesk API
    • +
    • Reports & exports
    - -
    - @if ($isPro) -
    +
    + @if ($planKey === 'pro') +

    + Active @if ($planExpiresAt) - Pro is active until {{ $planExpiresAt->format('d M Y') }}. - @else - Your organization is on Queue Pro. + until {{ $planExpiresAt->format('d M Y') }} @endif -

    + . +

    @elseif ($canManage)
    @csrf - +
    -

    Billed monthly from your Ladill wallet.

    @else -

    Ask an organization administrator to upgrade this workspace to Pro.

    +

    Ask an admin to upgrade.

    + @endif +
    +
    + + {{-- Enterprise --}} +
    +

    Enterprise

    +

    {{ $currency }} {{ $enterprisePerBranch }}/branch/mo

    +

    + @if ($branchCount > 0) + {{ $branchCount }} active {{ str('branch')->plural($branchCount) }} = {{ $currency }} {{ $enterpriseTotal }}/mo + @else + Per-branch billing for multi-site + @endif +

    +
      +
    • Everything in Pro
    • +
    • Full API & integrations
    • +
    • Advanced analytics + SLA
    • +
    • Dedicated onboarding (setup fee)
    • +
    +
    + @if ($planKey === 'enterprise') +

    + Active + @if ($planExpiresAt) + until {{ $planExpiresAt->format('d M Y') }} + @endif + @if ($enterpriseBilledBranches > 0) + · billed for {{ $enterpriseBilledBranches }} {{ str('branch')->plural($enterpriseBilledBranches) }} + @endif + . +

    + @elseif ($canManage) + @if ($canSubscribeEnterprise) +
    + @csrf + +
    + @else +

    Add at least {{ $enterpriseMinBranches }} active branches, or choose Pro for a single site.

    + @endif + Contact sales for setup & SLA + @else +

    Ask an admin to upgrade.

    @endif
    diff --git a/routes/web.php b/routes/web.php index 593c096..6c84b84 100644 --- a/routes/web.php +++ b/routes/web.php @@ -172,5 +172,6 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/pro', [ProController::class, 'index'])->name('qms.pro.index'); Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('qms.pro.subscribe'); + Route::post('/pro/subscribe-enterprise', [ProController::class, 'subscribeEnterprise'])->name('qms.pro.subscribe-enterprise'); }); }); diff --git a/tests/Feature/QmsProTest.php b/tests/Feature/QmsProTest.php index 2ecb67b..cae00e5 100644 --- a/tests/Feature/QmsProTest.php +++ b/tests/Feature/QmsProTest.php @@ -3,6 +3,8 @@ namespace Tests\Feature; use App\Http\Middleware\EnsurePlatformSession; +use App\Models\Branch; +use App\Models\Member; use App\Models\Organization; use App\Models\User; use App\Services\Qms\OrganizationResolver; @@ -40,17 +42,50 @@ class QmsProTest extends TestCase ]); } - public function test_pro_page_renders_for_free_organization(): void + public function test_pro_page_renders_three_tier_pricing(): void { $this->actingAs($this->owner) ->get(route('qms.pro.index')) ->assertOk() - ->assertSee('Ladill Queue Pro') + ->assertSee('Choose your Queue plan') ->assertSee('GHS 99') + ->assertSee('GHS 299') + ->assertSee('Upgrade to Pro') + ->assertSee('Enterprise'); + } + + public function test_sidebar_shows_upgrade_to_pro_on_dashboard(): void + { + $this->actingAs($this->owner) + ->get(route('qms.dashboard')) + ->assertOk() ->assertSee('Upgrade to Pro'); } - public function test_subscribe_upgrades_organization(): void + public function test_sidebar_shows_upgrade_for_operator_without_settings_access(): void + { + $operator = User::create([ + 'public_id' => 'queue-operator-001', + 'name' => 'Operator', + 'email' => 'operator@example.com', + 'password' => bcrypt('password'), + ]); + + Member::create([ + 'owner_ref' => $this->organization->owner_ref, + 'organization_id' => $this->organization->id, + 'user_ref' => $operator->public_id, + 'role' => 'queue_operator', + ]); + + $this->actingAs($operator) + ->get(route('qms.dashboard')) + ->assertOk() + ->assertSee('Upgrade to Pro') + ->assertDontSee(route('qms.settings.edit')); + } + + public function test_subscribe_upgrades_organization_to_pro(): void { Http::fake([ 'billing.test/can-afford*' => Http::response(['affordable' => true]), @@ -65,6 +100,35 @@ class QmsProTest extends TestCase $this->assertSame('pro', $this->organization->fresh()->settings['plan']); } + public function test_subscribe_enterprise_requires_multiple_branches(): 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-enterprise')) + ->assertRedirect(route('qms.pro.index')) + ->assertSessionHas('error'); + + Branch::create([ + 'owner_ref' => $this->organization->owner_ref, + 'organization_id' => $this->organization->id, + 'name' => 'Second', + 'is_active' => true, + ]); + + $this->actingAs($this->owner) + ->post(route('qms.pro.subscribe-enterprise')) + ->assertRedirect(route('qms.pro.index')) + ->assertSessionHas('success'); + + $settings = $this->organization->fresh()->settings; + $this->assertSame('enterprise', $settings['plan']); + $this->assertSame(2, $settings['enterprise_billed_branches']); + } + public function test_pro_renew_extends_subscription_when_wallet_charges(): void { $this->organization->update([ @@ -85,4 +149,37 @@ class QmsProTest extends TestCase $this->assertSame('pro', $settings['plan']); $this->assertTrue(now()->parse($settings['plan_expires_at'])->isFuture()); } + + public function test_enterprise_renew_charges_per_active_branch(): void + { + Branch::create([ + 'owner_ref' => $this->organization->owner_ref, + 'organization_id' => $this->organization->id, + 'name' => 'Second', + 'is_active' => true, + ]); + + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'plan' => 'enterprise', + 'auto_renew' => true, + 'enterprise_billed_branches' => 2, + 'plan_expires_at' => now()->subDay()->toIso8601String(), + ]), + ]); + + Http::fake([ + 'billing.test/debit' => function ($request) { + $this->assertSame(59800, (int) $request['amount_minor']); + + return Http::response(['ok' => true]); + }, + ]); + + $this->artisan('qms:pro-renew')->assertSuccessful(); + + $settings = $this->organization->fresh()->settings; + $this->assertSame('enterprise', $settings['plan']); + $this->assertSame(2, $settings['enterprise_billed_branches']); + } }