From 59433f2b7b0878df67cc9e058dff45bd2e3871da Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 16 Jul 2026 08:08:10 +0000 Subject: [PATCH] Harden per-branch billing with seat caps and mid-cycle expansion. Enforce billed_branches when adding or reactivating branches, charge wallet for extra seats mid-cycle, fix Settings for Enterprise, and cover multi-branch checkout and renewal amounts in tests. --- .../Frontdesk/BranchController.php | 14 +- .../Controllers/Frontdesk/ProController.php | 116 ++++++-- .../Frontdesk/SettingsController.php | 6 +- app/Services/Frontdesk/PlanService.php | 44 ++- resources/views/frontdesk/pro/index.blade.php | 26 ++ .../views/frontdesk/settings/edit.blade.php | 12 +- routes/web.php | 2 + tests/Feature/FrontdeskProTest.php | 253 +++++++++++++++++- 8 files changed, 449 insertions(+), 24 deletions(-) diff --git a/app/Http/Controllers/Frontdesk/BranchController.php b/app/Http/Controllers/Frontdesk/BranchController.php index c5ac166..8bedcf5 100644 --- a/app/Http/Controllers/Frontdesk/BranchController.php +++ b/app/Http/Controllers/Frontdesk/BranchController.php @@ -43,13 +43,14 @@ class BranchController extends Controller { $this->authorizeAbility($request, 'admin.branches.manage'); $organization = $this->organization($request); + $plans = app(\App\Services\Frontdesk\PlanService::class); $currentCount = Branch::owned($this->ownerRef($request)) ->where('organization_id', $organization->id) ->count(); - if (! app(\App\Services\Frontdesk\PlanService::class)->canAddBranch($organization, $currentCount)) { - return back()->withInput()->with('error', 'Your plan allows one branch. Upgrade to Frontdesk Pro for unlimited branches.'); + if (! $plans->canAddBranch($organization, $currentCount)) { + return back()->withInput()->with('error', $plans->branchLimitMessage($organization)); } $validated = $request->validate([ @@ -90,6 +91,15 @@ class BranchController extends Controller 'is_active' => ['boolean'], ]); + $activating = $request->boolean('is_active') && ! $branch->is_active; + if ($activating) { + $plans = app(\App\Services\Frontdesk\PlanService::class); + $organization = $this->organization($request); + if (! $plans->canActivateBranch($organization)) { + return back()->withInput()->with('error', $plans->branchLimitMessage($organization)); + } + } + $branch->update([ ...$validated, 'is_active' => $request->boolean('is_active'), diff --git a/app/Http/Controllers/Frontdesk/ProController.php b/app/Http/Controllers/Frontdesk/ProController.php index 03326ec..c31dbb4 100644 --- a/app/Http/Controllers/Frontdesk/ProController.php +++ b/app/Http/Controllers/Frontdesk/ProController.php @@ -27,6 +27,10 @@ class ProController extends Controller : null; $planKey = $plans->planKey($organization); $branchCount = max(1, $plans->activeBranchCount($organization)); + $billedBranches = max( + $plans->billedBranches($organization), + $branchCount, + ); return view('frontdesk.pro.index', [ 'organization' => $organization, @@ -45,7 +49,7 @@ class ProController extends Controller 'prepaidMonths' => (array) config('frontdesk.prepaid_months', [6, 12, 24]), 'currency' => (string) config('billing.currency', 'GHS'), 'planExpiresAt' => $expiresAt, - 'billedBranches' => (int) ($settings['billed_branches'] ?? $branchCount), + 'billedBranches' => $billedBranches, 'salesUrl' => ladill_platform_url('contact-sales'), ]); } @@ -57,14 +61,13 @@ class ProController extends Controller if ($plans->hasPaidPlan($organization)) { return redirect()->route('frontdesk.pro.index') - ->with('success', 'Your organization already has an active paid plan.'); + ->with('success', 'Your organization already has an active paid plan. Use "Add seats" on this page to expand your allotment.'); } - $validated = $request->validate([ - 'branches' => ['required', 'integer', 'min:1', 'max:999'], - ]); - - $branches = $plans->resolveCheckoutBranches($organization, (int) $validated['branches']); + $branches = $plans->resolveCheckoutBranches( + $organization, + $this->requestedBranches($request, $plans, $organization), + ); $amountMinor = $plans->priceForBranches('pro', $branches); return $this->chargeMonthlyWallet( @@ -87,7 +90,7 @@ class ProController extends Controller if ($plans->hasPaidPlan($organization)) { return redirect()->route('frontdesk.pro.index') - ->with('success', 'Your organization already has an active paid plan.'); + ->with('success', 'Your organization already has an active paid plan. Use "Add seats" on this page to expand your allotment.'); } if (! $plans->canSubscribeEnterprise($organization)) { @@ -97,11 +100,10 @@ class ProController extends Controller ->with('error', "Enterprise requires at least {$min} active branch. Add a branch or contact sales."); } - $validated = $request->validate([ - 'branches' => ['required', 'integer', 'min:1', 'max:999'], - ]); - - $branches = $plans->resolveCheckoutBranches($organization, (int) $validated['branches']); + $branches = $plans->resolveCheckoutBranches( + $organization, + $this->requestedBranches($request, $plans, $organization), + ); $amountMinor = $plans->priceForBranches('enterprise', $branches); return $this->chargeMonthlyWallet( @@ -123,13 +125,13 @@ class ProController extends Controller $validated = $request->validate([ 'plan' => ['required', 'in:pro,enterprise'], 'months' => ['required', 'integer', 'in:6,12,24'], - 'branches' => ['required', 'integer', 'min:1', 'max:999'], + 'branches' => ['nullable', 'integer', 'min:1', 'max:999'], ]); $organization = $this->organization($request); if ($plans->hasPaidPlan($organization)) { return redirect()->route('frontdesk.pro.index') - ->with('success', 'Your organization already has an active plan.'); + ->with('success', 'Your organization already has an active plan. Use "Add seats" on this page to expand your allotment.'); } $plan = $validated['plan']; @@ -142,7 +144,10 @@ class ProController extends Controller ->with('error', "Enterprise requires at least {$min} active branch."); } - $branches = $plans->resolveCheckoutBranches($organization, (int) $validated['branches']); + $branches = $plans->resolveCheckoutBranches( + $organization, + (int) ($validated['branches'] ?? $plans->activeBranchCount($organization) ?: 1), + ); $amountMinor = $plans->priceForBranches($plan, $branches) * $months; try { @@ -171,6 +176,76 @@ class ProController extends Controller return redirect()->away($url); } + /** + * Mid-cycle expansion: charge for additional seats and raise billed_branches. + */ + public function expandBranches(Request $request, PlanService $plans, BillingClient $billing): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + + if (! $plans->hasPaidPlan($organization)) { + return redirect()->route('frontdesk.pro.index') + ->with('error', 'Subscribe to a paid plan before adding branch seats.'); + } + + $validated = $request->validate([ + 'branches' => ['required', 'integer', 'min:1', 'max:999'], + ]); + + $plan = $plans->planKey($organization); + $currentBilled = max( + 1, + $plans->billedBranches($organization), + $plans->activeBranchCount($organization), + ); + $newTotal = max((int) $validated['branches'], $currentBilled); + + if ($newTotal <= $currentBilled) { + return redirect()->route('frontdesk.pro.index') + ->with('error', 'Choose a branch count higher than your current allotment ('.$currentBilled.').'); + } + + $extra = $newTotal - $currentBilled; + $rate = $plan === 'enterprise' + ? $plans->enterprisePricePerBranchMinor() + : $plans->proPricePerBranchMinor(); + $amountMinor = $extra * $rate; + $label = $plan === 'enterprise' ? 'Enterprise' : 'Pro'; + $reference = 'frontdesk-'.$plan.'-expand-'.$organization->id.'-'.now()->format('Y-m-d-His'); + + try { + if (! $billing->canAfford($organization->owner_ref, $amountMinor)) { + return redirect()->route('frontdesk.pro.index') + ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to add branches.'); + } + + $charged = $billing->debit( + $organization->owner_ref, + $amountMinor, + 'frontdesk_'.$plan.'_branch_expand', + $reference, + 'Ladill Frontdesk '.$label.' — +'.$extra.' branch seat(s)', + ); + } catch (\Throwable) { + return redirect()->route('frontdesk.pro.index') + ->with('error', 'Could not process branch expansion. Please try again.'); + } + + if (! $charged) { + return redirect()->route('frontdesk.pro.index') + ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to add branches.'); + } + + $settings = $organization->settings ?? []; + $settings['billed_branches'] = $newTotal; + unset($settings['plan_renewal_error']); + $organization->update(['settings' => $settings]); + + return redirect()->route('frontdesk.pro.index') + ->with('success', "Branch allotment updated to {$newTotal}. Charged for {$extra} additional seat(s)."); + } + public function paystackCallback(Request $request, BillingClient $billing, PlanService $plans): RedirectResponse { $reference = (string) $request->query('reference', ''); @@ -216,6 +291,15 @@ class ProController extends Controller ->with('success', "{$label} is active for {$months} months."); } + private function requestedBranches(Request $request, PlanService $plans, Organization $organization): int + { + $validated = $request->validate([ + 'branches' => ['nullable', 'integer', 'min:1', 'max:999'], + ]); + + return (int) ($validated['branches'] ?? max(1, $plans->activeBranchCount($organization))); + } + private function chargeMonthlyWallet( Organization $organization, BillingClient $billing, diff --git a/app/Http/Controllers/Frontdesk/SettingsController.php b/app/Http/Controllers/Frontdesk/SettingsController.php index 983c01c..42e1606 100644 --- a/app/Http/Controllers/Frontdesk/SettingsController.php +++ b/app/Http/Controllers/Frontdesk/SettingsController.php @@ -43,7 +43,11 @@ class SettingsController extends Controller 'notificationEvents' => config('frontdesk.notification_events'), 'plan' => $plan, 'isPro' => $plans->isPro($organization), - 'proPriceMinor' => $plans->proPriceMinor(), + 'isEnterprise' => $plans->isEnterprise($organization), + 'hasPaidPlan' => $plans->hasPaidPlan($organization), + 'billedBranches' => $plans->billedBranches($organization), + 'activeBranchCount' => $plans->activeBranchCount($organization), + 'proPriceMinor' => $plans->proPricePerBranchMinor(), 'monthlyUsage' => $monthlyUsage, 'freeEmailAllowance' => $freeEmailAllowance, 'emailCostFormatted' => $pricing->formatMinor($pricing->emailCostMinor()), diff --git a/app/Services/Frontdesk/PlanService.php b/app/Services/Frontdesk/PlanService.php index 31bf6ff..5bc0b7c 100644 --- a/app/Services/Frontdesk/PlanService.php +++ b/app/Services/Frontdesk/PlanService.php @@ -65,11 +65,53 @@ class PlanService ->count(); } + /** Seat allotment purchased for the current paid period; 0 when free or unset. */ + public function billedBranches(Organization $organization): int + { + return max(0, (int) (($organization->settings ?? [])['billed_branches'] ?? 0)); + } + public function canAddBranch(Organization $organization, int $currentCount): bool { $limit = config('frontdesk.plans.'.$this->planKey($organization).'.max_branches'); + if ($limit !== null) { + return $currentCount < (int) $limit; + } - return $limit === null || $currentCount < (int) $limit; + // Paid plans with a purchased seat allotment cannot exceed it until they expand. + $billed = $this->billedBranches($organization); + if ($this->hasPaidPlan($organization) && $billed > 0) { + return $this->activeBranchCount($organization) < $billed; + } + + return true; + } + + public function canActivateBranch(Organization $organization): bool + { + $limit = config('frontdesk.plans.'.$this->planKey($organization).'.max_branches'); + if ($limit !== null) { + return $this->activeBranchCount($organization) < (int) $limit; + } + + $billed = $this->billedBranches($organization); + if ($this->hasPaidPlan($organization) && $billed > 0) { + return $this->activeBranchCount($organization) < $billed; + } + + return true; + } + + public function branchLimitMessage(Organization $organization): string + { + if ($this->hasPaidPlan($organization) && $this->billedBranches($organization) > 0) { + $n = $this->billedBranches($organization); + + return "Your plan is billed for {$n} active ".str('branch')->plural($n) + .'. Increase your branch allotment on the Plans page before adding more.'; + } + + return 'Your plan allows one branch. Upgrade to Frontdesk Pro for more branches.'; } public function canAddKiosk(Organization $organization): bool diff --git a/resources/views/frontdesk/pro/index.blade.php b/resources/views/frontdesk/pro/index.blade.php index 0b6f885..0349178 100644 --- a/resources/views/frontdesk/pro/index.blade.php +++ b/resources/views/frontdesk/pro/index.blade.php @@ -116,6 +116,19 @@ .

SMS host alerts bill at platform rates from your wallet.

+ @if ($canManage) +
+ @csrf + + +
+ @endif @elseif ($canManage)
@csrf @@ -171,6 +184,19 @@ @endif .

+ @if ($canManage) + + @csrf + + +
+ @endif @elseif ($canManage) @if ($canSubscribeEnterprise)
diff --git a/resources/views/frontdesk/settings/edit.blade.php b/resources/views/frontdesk/settings/edit.blade.php index 58845cd..7f7b34c 100644 --- a/resources/views/frontdesk/settings/edit.blade.php +++ b/resources/views/frontdesk/settings/edit.blade.php @@ -8,14 +8,20 @@

- @if ($isPro) + @if ($hasPaidPlan) {{ $plan['label'] ?? 'Pro' }} — unlimited kiosks, integrations, and unlimited host emails, billed per branch (SMS still billed per segment). + @if ($billedBranches > 0) + Billed for {{ $billedBranches }} {{ str('branch')->plural($billedBranches) }} + ({{ $activeBranchCount }} active). + @endif @else Free — one branch, one kiosk, core check-in and badges. Host alerts billed after {{ number_format($freeEmailAllowance) }} free emails per month. @endif

- @if (! $isPro) + @if (! $hasPaidPlan) Upgrade to Pro (GHS {{ number_format($proPriceMinor / 100, 0) }}/branch/mo) + @else + Manage plan @endif
@@ -23,7 +29,7 @@
Host emails this month
@if ($freeEmailAllowance === null) - {{ number_format($monthlyUsage->email_count) }} sent (unlimited on Pro) + {{ number_format($monthlyUsage->email_count) }} sent (unlimited on paid plans) @else {{ number_format($monthlyUsage->email_count) }} / {{ number_format($freeEmailAllowance) }} free @endif diff --git a/routes/web.php b/routes/web.php index 82d71d9..e359070 100644 --- a/routes/web.php +++ b/routes/web.php @@ -180,6 +180,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('frontdesk.pro.subscribe'); Route::post('/pro/subscribe-enterprise', [ProController::class, 'subscribeEnterprise'])->name('frontdesk.pro.subscribe-enterprise'); Route::post('/pro/subscribe-prepaid', [ProController::class, 'subscribePrepaid'])->name('frontdesk.pro.subscribe-prepaid'); + Route::post('/pro/expand-branches', [ProController::class, 'expandBranches'])->name('frontdesk.pro.expand-branches'); Route::get('/pro/paystack/callback', [ProController::class, 'paystackCallback'])->name('frontdesk.pro.paystack.callback'); Route::get('/report-issue', [IssueReportController::class, 'create'])->name('frontdesk.issues.create'); @@ -187,6 +188,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/settings', [SettingsController::class, 'edit'])->name('frontdesk.settings'); Route::put('/settings', [SettingsController::class, 'update'])->name('frontdesk.settings.update'); + // Legacy alias — defaults branches to active count when omitted. Route::post('/settings/upgrade', [ProController::class, 'subscribe'])->name('frontdesk.settings.upgrade'); Route::get('/branches', [BranchController::class, 'index'])->name('frontdesk.branches.index'); diff --git a/tests/Feature/FrontdeskProTest.php b/tests/Feature/FrontdeskProTest.php index cbaf9ee..4b1fefb 100644 --- a/tests/Feature/FrontdeskProTest.php +++ b/tests/Feature/FrontdeskProTest.php @@ -7,6 +7,7 @@ use App\Models\Branch; use App\Models\Member; use App\Models\Organization; use App\Models\User; +use App\Services\Frontdesk\PlanService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use Tests\TestCase; @@ -96,6 +97,95 @@ class FrontdeskProTest extends TestCase $this->assertSame(1, (int) ($settings['billed_branches'] ?? 0)); } + public function test_subscribe_defaults_branches_when_omitted(): void + { + $rate = app(PlanService::class)->proPricePerBranchMinor(); + + Http::fake([ + 'billing.test/can-afford*' => Http::response(['affordable' => true]), + 'billing.test/debit' => Http::response(['ok' => true]), + ]); + + $this->actingAs($this->owner) + ->post(route('frontdesk.settings.upgrade')) + ->assertRedirect(route('frontdesk.pro.index')) + ->assertSessionHas('success'); + + Http::assertSent(function ($request) use ($rate) { + return $request->url() === 'https://billing.test/debit' + && (int) $request['amount_minor'] === $rate; + }); + + $this->assertSame(1, (int) ($this->organization->fresh()->settings['billed_branches'] ?? 0)); + } + + public function test_subscribe_charges_per_branch_for_multiple_seats(): void + { + Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'East', + 'is_active' => true, + ]); + Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'West', + 'is_active' => true, + ]); + + $rate = app(PlanService::class)->proPricePerBranchMinor(); + $expected = $rate * 3; + + Http::fake([ + 'billing.test/can-afford*' => Http::response(['affordable' => true]), + 'billing.test/debit' => Http::response(['ok' => true]), + ]); + + $this->actingAs($this->owner) + ->post(route('frontdesk.pro.subscribe'), [ + 'branches' => 3, + ]) + ->assertRedirect(route('frontdesk.pro.index')) + ->assertSessionHas('success'); + + Http::assertSent(function ($request) use ($expected) { + return $request->url() === 'https://billing.test/debit' + && (int) $request['amount_minor'] === $expected + && str_contains((string) $request['description'], '3 branch'); + }); + + $this->assertSame(3, (int) ($this->organization->fresh()->settings['billed_branches'] ?? 0)); + } + + public function test_subscribe_enterprise_charges_per_branch(): void + { + $rate = app(PlanService::class)->enterprisePricePerBranchMinor(); + $expected = $rate * 2; + + Http::fake([ + 'billing.test/can-afford*' => Http::response(['affordable' => true]), + 'billing.test/debit' => Http::response(['ok' => true]), + ]); + + $this->actingAs($this->owner) + ->post(route('frontdesk.pro.subscribe-enterprise'), [ + 'branches' => 2, + ]) + ->assertRedirect(route('frontdesk.pro.index')) + ->assertSessionHas('success'); + + Http::assertSent(function ($request) use ($expected) { + return $request->url() === 'https://billing.test/debit' + && (int) $request['amount_minor'] === $expected + && (string) $request['source'] === 'frontdesk_enterprise'; + }); + + $settings = $this->organization->fresh()->settings; + $this->assertSame('enterprise', $settings['plan']); + $this->assertSame(2, (int) ($settings['billed_branches'] ?? 0)); + } + public function test_subscribe_prepaid_redirects_to_paystack(): void { Http::fake([ @@ -114,6 +204,33 @@ class FrontdeskProTest extends TestCase ->assertRedirect('https://checkout.paystack.com/test'); } + public function test_subscribe_prepaid_sends_multi_branch_amount(): void + { + $rate = app(PlanService::class)->proPricePerBranchMinor(); + $expected = $rate * 4 * 6; + + Http::fake([ + 'billing.test/plan-checkout' => Http::response([ + 'checkout_url' => 'https://checkout.paystack.com/multi', + 'reference' => 'ref-multi', + ]), + ]); + + $this->actingAs($this->owner) + ->post(route('frontdesk.pro.subscribe-prepaid'), [ + 'plan' => 'pro', + 'months' => 6, + 'branches' => 4, + ]) + ->assertRedirect('https://checkout.paystack.com/multi'); + + Http::assertSent(function ($request) use ($expected) { + return str_ends_with($request->url(), '/plan-checkout') + && (int) $request['amount_minor'] === $expected + && (int) data_get($request, 'metadata.billed_branches') === 4; + }); + } + public function test_paystack_callback_activates_prepaid_pro(): void { Http::fake([ @@ -121,7 +238,10 @@ class FrontdeskProTest extends TestCase 'paid' => true, 'plan' => 'pro', 'months' => 12, - 'metadata' => ['organization_id' => $this->organization->id], + 'metadata' => [ + 'organization_id' => $this->organization->id, + 'billed_branches' => 3, + ], ]), ]); @@ -134,6 +254,7 @@ class FrontdeskProTest extends TestCase $this->assertSame('pro', $settings['plan']); $this->assertFalse($settings['auto_renew']); $this->assertSame('paystack_prepaid', $settings['billing_method']); + $this->assertSame(3, (int) ($settings['billed_branches'] ?? 0)); } public function test_pro_renew_extends_subscription_when_wallet_charges(): void @@ -143,6 +264,7 @@ class FrontdeskProTest extends TestCase 'onboarded' => true, 'plan' => 'pro', 'auto_renew' => true, + 'billed_branches' => 1, 'plan_expires_at' => now()->subDay()->toIso8601String(), ], ]); @@ -158,6 +280,43 @@ class FrontdeskProTest extends TestCase $this->assertTrue(now()->parse($settings['plan_expires_at'])->isFuture()); } + public function test_pro_renew_charges_max_of_billed_and_active_branches(): void + { + Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'East', + 'is_active' => true, + ]); + // 2 active branches; billed was 1 — true-up to 2. + $rate = app(PlanService::class)->proPricePerBranchMinor(); + $expected = $rate * 2; + + $this->organization->update([ + 'settings' => [ + 'onboarded' => true, + 'plan' => 'pro', + 'auto_renew' => true, + 'billed_branches' => 1, + 'plan_expires_at' => now()->subDay()->toIso8601String(), + ], + ]); + + Http::fake([ + 'billing.test/debit' => Http::response(['ok' => true]), + ]); + + $this->artisan('frontdesk:pro-renew')->assertSuccessful(); + + Http::assertSent(function ($request) use ($expected) { + return $request->url() === 'https://billing.test/debit' + && (int) $request['amount_minor'] === $expected + && str_contains((string) $request['description'], '2 branch'); + }); + + $this->assertSame(2, (int) ($this->organization->fresh()->settings['billed_branches'] ?? 0)); + } + public function test_prepaid_pro_skipped_by_renewal_command(): void { $this->organization->update([ @@ -208,4 +367,96 @@ class FrontdeskProTest extends TestCase ->assertSee('Frontdesk Enterprise') ->assertDontSee('Upgrade to Pro'); } + + public function test_settings_shows_enterprise_as_paid_not_free(): void + { + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'plan' => 'enterprise', + 'billed_branches' => 2, + 'plan_expires_at' => now()->addYear()->toIso8601String(), + ]), + ]); + + $this->actingAs($this->owner) + ->get(route('frontdesk.settings')) + ->assertOk() + ->assertSee('Enterprise') + ->assertSee('Billed for 2') + ->assertSee('Manage plan') + ->assertDontSee('Upgrade to Pro'); + } + + public function test_paid_plan_blocks_branch_beyond_allotment(): void + { + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'plan' => 'pro', + 'billed_branches' => 1, + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ]), + ]); + + $this->actingAs($this->owner) + ->post(route('frontdesk.branches.store'), [ + 'name' => 'Overflow Branch', + ]) + ->assertRedirect() + ->assertSessionHas('error'); + + $this->assertDatabaseMissing('frontdesk_branches', ['name' => 'Overflow Branch']); + } + + public function test_expand_branches_charges_extra_seats(): void + { + $rate = app(PlanService::class)->proPricePerBranchMinor(); + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'plan' => 'pro', + 'billed_branches' => 1, + 'billing_method' => 'wallet_monthly', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ]), + ]); + + Http::fake([ + 'billing.test/can-afford*' => Http::response(['affordable' => true]), + 'billing.test/debit' => Http::response(['ok' => true]), + ]); + + $this->actingAs($this->owner) + ->post(route('frontdesk.pro.expand-branches'), [ + 'branches' => 3, + ]) + ->assertRedirect(route('frontdesk.pro.index')) + ->assertSessionHas('success'); + + Http::assertSent(function ($request) use ($rate) { + return $request->url() === 'https://billing.test/debit' + && (int) $request['amount_minor'] === $rate * 2 + && str_contains((string) $request['source'], 'branch_expand'); + }); + + $this->assertSame(3, (int) ($this->organization->fresh()->settings['billed_branches'] ?? 0)); + } + + public function test_after_expand_can_create_additional_branch(): void + { + $this->organization->update([ + 'settings' => array_merge($this->organization->settings ?? [], [ + 'plan' => 'pro', + 'billed_branches' => 2, + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ]), + ]); + + $this->actingAs($this->owner) + ->post(route('frontdesk.branches.store'), [ + 'name' => 'Second Branch', + ]) + ->assertRedirect(route('frontdesk.branches.index')) + ->assertSessionHas('success'); + + $this->assertDatabaseHas('frontdesk_branches', ['name' => 'Second Branch']); + } }