Limit Pro to 3 branches and leave Business unlimited.
Deploy Ladill POS / deploy (push) Successful in 2m15s

Enforce plan location caps in SubscriptionService, surface upgrade
copy on plans and branch settings, and cover the limits in tests.
This commit is contained in:
isaacclad
2026-07-15 15:24:51 +00:00
parent e2b608a506
commit 5e31bdf629
7 changed files with 142 additions and 7 deletions
@@ -55,7 +55,7 @@ class LocationController extends Controller
if (! $this->subscriptions->canAddLocation($account, $owner)) {
return redirect()->route('pos.pro.index')
->with('upsell', 'Multi-branch POS requires Ladill POS Pro or Business.');
->with('upsell', $this->subscriptions->locationLimitMessage($account));
}
$data = $request->validate([
+47 -3
View File
@@ -111,6 +111,32 @@ class SubscriptionService
return PosLocation::owned($ownerRef)->count();
}
/**
* Max branches for the account's plan. null = unlimited.
*/
public function maxLocations(User $user): ?int
{
if (! $this->gatingActive()) {
return null;
}
$key = $this->planKey($user);
if ($key === 'free') {
return (int) config('pos.free.max_locations', 1);
}
if ($key === ProSubscription::PLAN_ENTERPRISE) {
$max = config('pos.plans.enterprise.max_locations');
return $max === null ? null : (int) $max;
}
$max = config('pos.plans.pro.max_locations', 3);
return $max === null ? null : (int) $max;
}
public function canUseMultiLocation(User $user): bool
{
return $this->isPro($user);
@@ -118,11 +144,29 @@ class SubscriptionService
public function canAddLocation(User $user, string $ownerRef): bool
{
if (! $this->canUseMultiLocation($user)) {
return $this->locationCount($ownerRef) < (int) config('pos.free.max_locations', 1);
$max = $this->maxLocations($user);
if ($max === null) {
return true;
}
return true;
return $this->locationCount($ownerRef) < $max;
}
public function locationLimitMessage(User $user): string
{
$key = $this->planKey($user);
$max = $this->maxLocations($user);
if ($key === 'free') {
return 'Your Free plan allows 1 branch. Upgrade to Pro for up to 3 branches, or Business for unlimited.';
}
if ($key === ProSubscription::PLAN_PRO && $max !== null) {
return "Your Pro plan allows up to {$max} branches. Upgrade to Business for unlimited locations.";
}
return 'Branch limit reached for your current plan.';
}
public function canManageTeam(User $user): bool
+10 -2
View File
@@ -19,8 +19,16 @@ return [
],
'plans' => [
'pro' => ['price_minor' => (int) env('POS_PRO_PRICE_MINOR', 79000)],
'enterprise' => ['price_minor' => (int) env('POS_ENTERPRISE_PRICE_MINOR', 249000)],
'pro' => [
'price_minor' => (int) env('POS_PRO_PRICE_MINOR', 79000),
// null = unlimited
'max_locations' => (int) env('POS_PRO_MAX_LOCATIONS', 3),
],
'enterprise' => [
'price_minor' => (int) env('POS_ENTERPRISE_PRICE_MINOR', 249000),
// null = unlimited branches
'max_locations' => null,
],
],
'prepaid_months' => [6, 12, 24],
@@ -52,6 +52,14 @@
<p class="mt-4 rounded-xl bg-amber-50 px-4 py-3 text-sm text-amber-900">
Multi-branch POS is available on <a href="{{ route('pos.pro.index') }}" class="font-semibold underline">Pro or Business</a>.
</p>
@else
@if (! $canAddBranch)
<p class="mt-4 rounded-xl bg-amber-50 px-4 py-3 text-sm text-amber-900">
You've reached the branch limit on Pro (up to 3).
<a href="{{ route('pos.pro.index') }}" class="font-semibold underline">Upgrade to Business</a>
for unlimited locations.
</p>
@endif
@endunless
</x-settings.card>
</x-settings.page>
+2 -1
View File
@@ -61,6 +61,7 @@
<x-plan-tier-price :currency="$currency" :monthly-minor="$proPriceMinor" />
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700">
<li>Unlimited products</li>
<li>Up to 3 locations</li>
<li>Restaurant mode</li>
<li>Catalog import</li>
</ul>
@@ -81,7 +82,7 @@
<x-plan-tier-price :currency="$currency" :monthly-minor="$enterprisePriceMinor" dark />
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-200">
<li>Everything in Pro</li>
<li>Multi-location</li>
<li>Unlimited locations</li>
<li>Advanced reporting</li>
</ul>
<div class="mt-6">
@@ -47,6 +47,14 @@
<a href="{{ route('pos.pro.index') }}" class="font-semibold underline">Pro or Business</a>.
Your account is limited to one branch.
</p>
@else
@if (! empty($canManageBranches) && ! $canAddBranch)
<p class="rounded-xl bg-amber-50 px-4 py-3 text-sm text-amber-900">
You've reached the branch limit on Pro (up to 3).
<a href="{{ route('pos.pro.index') }}" class="font-semibold underline">Upgrade to Business</a>
for unlimited locations.
</p>
@endif
@endunless
</div>
</x-settings.card>
+66
View File
@@ -47,6 +47,20 @@ class PosMultiBranchTest extends TestCase
]);
}
private function activateBusiness(User $user): void
{
ProSubscription::create([
'user_id' => $user->id,
'plan' => ProSubscription::PLAN_ENTERPRISE,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => 24900,
'currency' => 'GHS',
'auto_renew' => true,
'started_at' => now(),
'current_period_end' => now()->addMonth(),
]);
}
protected function setUp(): void
{
parent::setUp();
@@ -104,6 +118,58 @@ class PosMultiBranchTest extends TestCase
$this->assertSame(2, PosLocation::owned($owner->public_id)->count());
}
public function test_pro_account_cannot_add_fourth_branch(): void
{
$owner = $this->owner();
$this->activatePro($owner);
foreach (['Main', 'Mall', 'Airport'] as $i => $name) {
PosLocation::create([
'owner_ref' => $owner->public_id,
'name' => $name,
'currency' => 'GHS',
'is_default' => $i === 0,
]);
}
$this->assertSame(3, PosLocation::owned($owner->public_id)->count());
$this->actingAs($owner)
->post(route('pos.branches.store'), [
'name' => 'Fourth branch',
'currency' => 'GHS',
'service_style' => 'retail',
])
->assertRedirect(route('pos.pro.index'));
$this->assertSame(3, PosLocation::owned($owner->public_id)->count());
}
public function test_business_account_can_add_beyond_three_branches(): void
{
$owner = $this->owner();
$this->activateBusiness($owner);
foreach (['Main', 'Mall', 'Airport'] as $i => $name) {
PosLocation::create([
'owner_ref' => $owner->public_id,
'name' => $name,
'currency' => 'GHS',
'is_default' => $i === 0,
]);
}
$this->actingAs($owner)
->post(route('pos.branches.store'), [
'name' => 'Fourth branch',
'currency' => 'GHS',
'service_style' => 'retail',
])
->assertRedirect(route('pos.settings').'#branches');
$this->assertSame(4, PosLocation::owned($owner->public_id)->count());
}
public function test_cashier_is_limited_to_assigned_branch_sales(): void
{
$owner = $this->owner();