Gate Care branch management behind Pro like Frontdesk.
Deploy Ladill Care / deploy (push) Successful in 1m25s

Free plans keep a single setup branch but get an upgrade screen for Branches; Pro/Enterprise unlock multi-branch management via plan features.
This commit is contained in:
isaacclad
2026-07-16 09:14:58 +00:00
parent 0181c958f5
commit 85eb04d2a3
9 changed files with 185 additions and 8 deletions
@@ -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'],
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Care\Concerns;
use App\Services\Care\PlanService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
trait RequiresPlanFeature
{
/**
* For page views: return an upgrade screen when the org lacks the feature.
*/
protected function proFeatureUpgradeView(
Request $request,
string $feature,
string $title,
string $description,
): ?View {
$organization = $this->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);
}
}
@@ -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(),
+7
View File
@@ -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()
+16
View File
@@ -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',
],
],
],
+9 -3
View File
@@ -49,7 +49,7 @@
</x-settings.card>
@if ($canViewBranches || $canViewTeam)
<x-settings.card title="Branches & team" description="Locations and staff access for this facility. Multi-branch requires Care Pro.">
<x-settings.card title="Branches & team" description="Locations and staff access for this facility. Multi-branch management requires Care Pro.">
<ul class="space-y-3">
@if ($canViewBranches)
<li>
@@ -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">
<span>
Branches
@if (! $hasPaidPlan)
@if (! $hasBranchesFeature)
<span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Pro</span>
@endif
<span class="mt-0.5 block text-xs text-slate-500">Clinic locations and sites ({{ $branchCount }} configured)</span>
<span class="mt-0.5 block text-xs text-slate-500">
@if ($hasBranchesFeature)
Clinic locations and sites ({{ $branchCount }} configured)
@else
Free includes one branch from setup · manage locations on Pro
@endif
</span>
</span>
<span class="text-slate-400">&rarr;</span>
</a>
@@ -0,0 +1,20 @@
<x-app-layout :title="$title">
<x-settings.page :title="$title" :description="$description">
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm">
<a href="{{ route('care.settings') }}" class="text-slate-500 hover:text-slate-800">Settings</a>
<span class="text-slate-300">/</span>
<span class="font-medium text-slate-900">{{ $title }}</span>
</div>
<x-settings.card title="Upgrade your plan">
<p class="text-sm text-slate-600">
GHS {{ number_format($proPriceMinor / 100, 0) }} / branch / month for Pro
multi-branch locations, laboratory, pharmacy, encounter billing, and Ladill Queue integration.
</p>
<div class="mt-4 flex flex-wrap items-center gap-3">
<a href="{{ route('care.pro.index') }}" class="btn-primary">View plans</a>
<a href="{{ route('care.wallet') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Top up</a>
</div>
</x-settings.card>
</x-settings.page>
</x-app-layout>
+32
View File
@@ -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([
+4 -3
View File
@@ -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