From 85eb04d2a3e44d699465904072f3f49e6a7b8b8d Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 16 Jul 2026 09:14:58 +0000 Subject: [PATCH] Gate Care branch management behind Pro like Frontdesk. Free plans keep a single setup branch but get an upgrade screen for Branches; Pro/Enterprise unlock multi-branch management via plan features. --- .../Controllers/Care/BranchController.php | 43 +++++++++++++++ .../Care/Concerns/RequiresPlanFeature.php | 53 +++++++++++++++++++ .../Controllers/Care/SettingsController.php | 3 +- app/Services/Care/PlanService.php | 7 +++ config/care.php | 16 ++++++ resources/views/care/settings/edit.blade.php | 12 +++-- .../views/care/settings/pro-feature.blade.php | 20 +++++++ tests/Feature/CareProTest.php | 32 +++++++++++ tests/Feature/CareWebTest.php | 7 +-- 9 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/Care/Concerns/RequiresPlanFeature.php create mode 100644 resources/views/care/settings/pro-feature.blade.php diff --git a/app/Http/Controllers/Care/BranchController.php b/app/Http/Controllers/Care/BranchController.php index b35fbb9..4be394b 100644 --- a/app/Http/Controllers/Care/BranchController.php +++ b/app/Http/Controllers/Care/BranchController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Care; use App\Http\Controllers\Controller; +use App\Http\Controllers\Care\Concerns\RequiresPlanFeature; use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Models\Branch; use App\Services\Care\AuditLogger; @@ -13,11 +14,26 @@ use Illuminate\View\View; class BranchController extends Controller { + use RequiresPlanFeature; use ScopesToAccount; + private const FEATURE = 'branches'; + + private const UPGRADE_MESSAGE = 'Multi-branch management requires Care Pro.'; + public function index(Request $request): View { $this->authorizeAbility($request, 'admin.branches.view'); + + if ($upgrade = $this->proFeatureUpgradeView( + $request, + self::FEATURE, + 'Branches', + 'Manage clinic locations and sites with Care Pro. Free plans include one branch created during setup.', + )) { + return $upgrade; + } + $organization = $this->organization($request); $branches = Branch::owned($this->ownerRef($request)) @@ -39,12 +55,26 @@ class BranchController extends Controller { $this->authorizeAbility($request, 'admin.branches.manage'); + if ($upgrade = $this->proFeatureUpgradeView( + $request, + self::FEATURE, + 'Branches', + 'Add and manage multiple clinic locations with Care Pro.', + )) { + return $upgrade; + } + return view('care.admin.branches.create', ['organization' => $this->organization($request)]); } public function store(Request $request): RedirectResponse { $this->authorizeAbility($request, 'admin.branches.manage'); + + if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) { + return $deny; + } + $organization = $this->organization($request); $owner = $this->ownerRef($request); @@ -83,6 +113,15 @@ class BranchController extends Controller $this->authorizeAbility($request, 'admin.branches.manage'); $this->authorizeOwner($request, $branch); + if ($upgrade = $this->proFeatureUpgradeView( + $request, + self::FEATURE, + 'Branches', + 'Edit clinic locations with Care Pro. Free plans include one branch created during setup.', + )) { + return $upgrade; + } + return view('care.admin.branches.edit', compact('branch')); } @@ -91,6 +130,10 @@ class BranchController extends Controller $this->authorizeAbility($request, 'admin.branches.manage'); $this->authorizeOwner($request, $branch); + if ($deny = $this->denyWithoutFeature($request, self::FEATURE, self::UPGRADE_MESSAGE)) { + return $deny; + } + $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'code' => ['nullable', 'string', 'max:50'], diff --git a/app/Http/Controllers/Care/Concerns/RequiresPlanFeature.php b/app/Http/Controllers/Care/Concerns/RequiresPlanFeature.php new file mode 100644 index 0000000..1ecf48e --- /dev/null +++ b/app/Http/Controllers/Care/Concerns/RequiresPlanFeature.php @@ -0,0 +1,53 @@ +organization($request); + $plans = app(PlanService::class); + + if ($plans->hasFeature($organization, $feature)) { + return null; + } + + return view('care.settings.pro-feature', [ + 'organization' => $organization, + 'title' => $title, + 'description' => $description, + 'proPriceMinor' => $plans->proPricePerBranchMinor(), + ]); + } + + /** + * For mutations: redirect free-plan orgs to the plans page. + */ + protected function denyWithoutFeature( + Request $request, + string $feature, + string $message, + ): ?RedirectResponse { + $organization = $this->organization($request); + if (app(PlanService::class)->hasFeature($organization, $feature)) { + return null; + } + + return redirect() + ->route('care.pro.index') + ->with('upsell', $message); + } +} diff --git a/app/Http/Controllers/Care/SettingsController.php b/app/Http/Controllers/Care/SettingsController.php index 7b2023a..987fe6d 100644 --- a/app/Http/Controllers/Care/SettingsController.php +++ b/app/Http/Controllers/Care/SettingsController.php @@ -34,7 +34,6 @@ class SettingsController extends Controller ->count(); $plans = app(PlanService::class); - $hasPaidPlan = $plans->hasPaidPlan($organization); return view('care.settings.edit', [ 'organization' => $organization, @@ -42,7 +41,7 @@ class SettingsController extends Controller 'branchCount' => $branchCount, 'canViewBranches' => $permissions->can($member, 'admin.branches.view'), 'canViewTeam' => $permissions->can($member, 'admin.members.view'), - 'hasPaidPlan' => $hasPaidPlan, + 'hasBranchesFeature' => $plans->hasFeature($organization, 'branches'), 'canUseQueueIntegration' => $plans->canUseQueueIntegration($organization), 'messagingSmsReady' => $credential->hasValidSms(), 'messagingEmailReady' => $credential->hasValidBird(), diff --git a/app/Services/Care/PlanService.php b/app/Services/Care/PlanService.php index 2a50a64..12e694f 100644 --- a/app/Services/Care/PlanService.php +++ b/app/Services/Care/PlanService.php @@ -53,6 +53,13 @@ class PlanService return $this->hasPaidPlan($organization); } + public function hasFeature(Organization $organization, string $feature): bool + { + $features = config('care.plans.'.$this->planKey($organization).'.features', []); + + return in_array($feature, $features, true); + } + public function activeBranchCount(Organization $organization): int { return Branch::query() diff --git a/config/care.php b/config/care.php index dacdf3a..2213270 100644 --- a/config/care.php +++ b/config/care.php @@ -216,12 +216,21 @@ return [ 'price_minor' => 0, 'max_branches' => 1, // Free: core patient records, appointments & consults, basic workflows only. + // One branch is created during onboarding; multi-branch management is Pro. + 'features' => [], ], 'pro' => [ 'label' => 'Pro', // Billed per active branch / month (unlimited branches). 'price_minor_per_branch' => (int) env('CARE_PRO_PRICE_PER_BRANCH_MINOR', env('CARE_PRO_PRICE_MINOR', 199000)), 'max_branches' => null, + 'features' => [ + 'branches', + 'lab', + 'pharmacy', + 'billing', + 'queue_integration', + ], ], 'enterprise' => [ 'label' => 'Enterprise', @@ -229,6 +238,13 @@ return [ // Differentiator: multi-dept custom workflows, priority support, analytics, AI. 'price_minor_per_branch' => (int) env('CARE_ENTERPRISE_PRICE_PER_BRANCH_MINOR', env('CARE_ENTERPRISE_PRICE_MINOR', 499000)), 'max_branches' => null, + 'features' => [ + 'branches', + 'lab', + 'pharmacy', + 'billing', + 'queue_integration', + ], ], ], diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index 2947d2c..459035c 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -49,7 +49,7 @@ @if ($canViewBranches || $canViewTeam) - +
    @if ($canViewBranches)
  • @@ -57,10 +57,16 @@ class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700"> Branches - @if (! $hasPaidPlan) + @if (! $hasBranchesFeature) Pro @endif - Clinic locations and sites ({{ $branchCount }} configured) + + @if ($hasBranchesFeature) + Clinic locations and sites ({{ $branchCount }} configured) + @else + Free includes one branch from setup · manage locations on Pro + @endif + diff --git a/resources/views/care/settings/pro-feature.blade.php b/resources/views/care/settings/pro-feature.blade.php new file mode 100644 index 0000000..e08dd49 --- /dev/null +++ b/resources/views/care/settings/pro-feature.blade.php @@ -0,0 +1,20 @@ + + +
    + Settings + / + {{ $title }} +
    + + +

    + GHS {{ number_format($proPriceMinor / 100, 0) }} / branch / month for Pro — + multi-branch locations, laboratory, pharmacy, encounter billing, and Ladill Queue integration. +

    + +
    +
    +
    diff --git a/tests/Feature/CareProTest.php b/tests/Feature/CareProTest.php index fccac11..3c4f2d1 100644 --- a/tests/Feature/CareProTest.php +++ b/tests/Feature/CareProTest.php @@ -95,6 +95,38 @@ class CareProTest extends TestCase ->assertSessionHas('upsell'); } + public function test_free_plan_is_blocked_from_branch_management(): void + { + $this->actingAs($this->owner) + ->get(route('care.branches.index')) + ->assertOk() + ->assertSee('Upgrade your plan') + ->assertDontSee('All branches'); + + $this->actingAs($this->owner) + ->post(route('care.branches.store'), [ + 'name' => 'Second site', + ]) + ->assertRedirect(route('care.pro.index')) + ->assertSessionHas('upsell'); + } + + public function test_pro_plan_can_manage_branches(): void + { + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ]), + ]); + + $this->actingAs($this->owner) + ->get(route('care.branches.index')) + ->assertOk() + ->assertSee('All branches') + ->assertSee('HQ'); + } + public function test_pro_plan_allows_unlimited_branches(): void { $this->organization->update([ diff --git a/tests/Feature/CareWebTest.php b/tests/Feature/CareWebTest.php index 6432371..da9adda 100644 --- a/tests/Feature/CareWebTest.php +++ b/tests/Feature/CareWebTest.php @@ -128,12 +128,13 @@ class CareWebTest extends TestCase public function test_branches_index_loads(): void { + // Free plan: multi-branch management is Pro-gated (setup branch still exists). $this->actingAs($this->user) ->get(route('care.branches.index')) ->assertOk() - ->assertSee('All branches') - ->assertSee('Main Branch') - ->assertSee('Settings'); + ->assertSee('Upgrade your plan') + ->assertSee('View plans') + ->assertDontSee('Main Branch'); } public function test_members_index_loads(): void