From 22646dfb62d95cf4b8c4886e653a57ca08e84442 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 30 Jun 2026 01:55:38 +0000 Subject: [PATCH] Add Care Enterprise per-branch billing and Paystack prepaid plans. Mirrors Queue with GHS 499/branch Enterprise and 6/12/24 month Paystack checkout. Co-authored-by: Cursor --- app/Http/Controllers/Care/ProController.php | 221 ++++++++++++++++++-- app/Providers/AppServiceProvider.php | 16 +- app/Services/Billing/BillingClient.php | 44 ++++ app/Services/Care/PlanService.php | 38 +++- app/Services/Care/ProRenewalService.php | 27 ++- config/care.php | 11 + resources/views/care/pro/index.blade.php | 167 +++++++++++---- resources/views/partials/sidebar.blade.php | 8 +- routes/web.php | 3 + tests/Feature/CareProTest.php | 121 ++++++++++- 10 files changed, 581 insertions(+), 75 deletions(-) diff --git a/app/Http/Controllers/Care/ProController.php b/app/Http/Controllers/Care/ProController.php index 93a39d4..30ac6a8 100644 --- a/app/Http/Controllers/Care/ProController.php +++ b/app/Http/Controllers/Care/ProController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Care; use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; +use App\Models\Organization; use App\Services\Billing\BillingClient; use App\Services\Care\CarePermissions; use App\Services\Care\PlanService; @@ -24,21 +25,28 @@ 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('care.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('care.enterprise.min_branches', 2), + 'prepaidMonths' => (array) config('care.prepaid_months', [6, 12, 24]), 'currency' => (string) config('billing.currency', 'GHS'), 'planExpiresAt' => $expiresAt, - 'proFeatures' => [ - 'Unlimited branches', - 'Full clinical workflows', - 'Laboratory & pharmacy modules', - 'Finance reports & audit exports', - 'Queue integration', - ], + 'enterpriseBilledBranches' => (int) ($settings['enterprise_billed_branches'] ?? $branchCount), + 'salesUrl' => ladill_platform_url('contact-sales'), ]); } @@ -47,25 +55,177 @@ class ProController extends Controller $this->authorizeAbility($request, 'settings.manage'); $organization = $this->organization($request); - if ($plans->isPro($organization)) { + if ($plans->hasPaidPlan($organization)) { return redirect()->route('care.pro.index') - ->with('success', 'Your organization is already on Care Pro.'); + ->with('success', 'Your organization already has an active paid plan.'); } - $priceMinor = $plans->proPriceMinor(); + return $this->chargeMonthlyWallet( + $organization, + $billing, + $plans->proPriceMinor(), + 'pro', + 'care_pro', + 'care-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'), + 'Ladill Care Pro — monthly subscription', + 'Welcome to Care 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('care.pro.index') + ->with('success', 'Your organization already has an active paid plan.'); + } + + if (! $plans->canSubscribeEnterprise($organization)) { + $min = (int) config('care.enterprise.min_branches', 2); + + return redirect()->route('care.pro.index') + ->with('error', "Enterprise billing requires at least {$min} active branches. Add another branch or choose Pro for a single site."); + } + + return $this->chargeMonthlyWallet( + $organization, + $billing, + $plans->enterprisePriceMinor($organization), + 'enterprise', + 'care_enterprise', + 'care-enterprise-'.$organization->id.'-'.now()->format('Y-m-d-His'), + 'Ladill Care Enterprise — monthly subscription', + 'Welcome to Care Enterprise — active for one month.', + $plans->activeBranchCount($organization), + ); + } + + public function subscribePrepaid(Request $request, PlanService $plans, BillingClient $billing): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $validated = $request->validate([ + 'plan' => ['required', 'in:pro,enterprise'], + 'months' => ['required', 'integer', 'in:6,12,24'], + ]); + + $organization = $this->organization($request); + if ($plans->hasPaidPlan($organization)) { + return redirect()->route('care.pro.index') + ->with('success', 'Your organization already has an active paid plan.'); + } + + $plan = $validated['plan']; + $months = (int) $validated['months']; + + if ($plan === 'enterprise' && ! $plans->canSubscribeEnterprise($organization)) { + $min = (int) config('care.enterprise.min_branches', 2); + + return redirect()->route('care.pro.index') + ->with('error', "Enterprise billing requires at least {$min} active branches."); + } + + $monthlyMinor = $plan === 'enterprise' + ? $plans->enterprisePriceMinor($organization) + : $plans->proPriceMinor(); + $amountMinor = $monthlyMinor * $months; + $branches = $plan === 'enterprise' ? $plans->activeBranchCount($organization) : null; + + try { + $checkout = $billing->initiatePlanCheckout( + $organization->owner_ref, + $plan, + $months, + $amountMinor, + route('care.pro.paystack.callback'), + array_filter([ + 'organization_id' => $organization->id, + 'enterprise_billed_branches' => $branches, + ], static fn ($v) => $v !== null), + ); + } catch (\Throwable) { + return redirect()->route('care.pro.index') + ->with('error', 'Could not start Paystack checkout. Please try again.'); + } + + $url = trim((string) ($checkout['checkout_url'] ?? '')); + if ($url === '') { + return redirect()->route('care.pro.index') + ->with('error', 'Paystack did not return a checkout URL.'); + } + + return redirect()->away($url); + } + + public function paystackCallback(Request $request, BillingClient $billing, PlanService $plans): RedirectResponse + { + $reference = (string) $request->query('reference', ''); + if ($reference === '') { + return redirect()->route('care.pro.index')->with('error', 'Missing payment reference.'); + } + + try { + $result = $billing->verifyPlanCheckout($reference); + } catch (\Throwable) { + return redirect()->route('care.pro.index') + ->with('error', 'Could not verify payment. Contact support with reference: '.$reference); + } + + if (! ($result['paid'] ?? false)) { + return redirect()->route('care.pro.index') + ->with('error', 'Payment was not completed.'); + } + + $metadata = (array) ($result['metadata'] ?? []); + $organization = Organization::query()->find((int) ($metadata['organization_id'] ?? 0)); + if (! $organization) { + return redirect()->route('care.pro.index') + ->with('error', 'Organization not found for this payment.'); + } + + $this->authorizeAbility($request, 'settings.manage'); + if ($this->organization($request)->id !== $organization->id) { + abort(403); + } + + $plan = (string) ($result['plan'] ?? 'pro'); + $months = (int) ($result['months'] ?? 0); + $branches = isset($metadata['enterprise_billed_branches']) + ? (int) $metadata['enterprise_billed_branches'] + : null; + + $this->activatePrepaidPlan($organization, $plan, $months, $branches); + + $label = $plan === 'enterprise' ? 'Care Enterprise' : 'Care Pro'; + + return redirect()->route('care.pro.index') + ->with('success', "{$label} is active for {$months} months."); + } + + private function chargeMonthlyWallet( + Organization $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('care.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, - 'care_pro', - 'care-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'), - 'Ladill Care Pro — monthly subscription', + $billingSource, + $reference, + $description, ); } catch (\Throwable) { return redirect()->route('care.pro.index') @@ -74,19 +234,40 @@ class ProController extends Controller if (! $charged) { return redirect()->route('care.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['billing_method'] = 'wallet_monthly'; $settings['plan_expires_at'] = now() ->addDays((int) config('care.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('care.pro.index') - ->with('success', 'Welcome to Care Pro — active for one month.'); + return redirect()->route('care.pro.index')->with('success', $successMessage); + } + + private function activatePrepaidPlan( + Organization $organization, + string $plan, + int $months, + ?int $enterpriseBranches = null, + ): void { + $settings = $organization->settings ?? []; + $settings['plan'] = $plan; + $settings['auto_renew'] = false; + $settings['billing_method'] = 'paystack_prepaid'; + $settings['plan_expires_at'] = now()->addMonths($months)->toIso8601String(); + if ($enterpriseBranches !== null) { + $settings['enterprise_billed_branches'] = $enterpriseBranches; + } + unset($settings['plan_renewal_error']); + $organization->update(['settings' => $settings]); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7bda633..f27a0d8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -25,16 +25,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/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php index 4b97295..6a287ec 100644 --- a/app/Services/Billing/BillingClient.php +++ b/app/Services/Billing/BillingClient.php @@ -61,4 +61,48 @@ class BillingClient return true; } + + /** + * @param array $metadata + * @return array{checkout_url: string, reference: string} + */ + public function initiatePlanCheckout( + string $publicId, + string $plan, + int $months, + int $amountMinor, + string $returnUrl, + array $metadata = [], + ): array { + $res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/plan-checkout', [ + 'user' => $publicId, + 'app' => (string) config('billing.service', 'care'), + 'plan' => $plan, + 'months' => $months, + 'amount_minor' => $amountMinor, + 'return_url' => $returnUrl, + 'metadata' => $metadata, + ]); + $res->throw(); + + return [ + 'checkout_url' => (string) $res->json('checkout_url'), + 'reference' => (string) $res->json('reference'), + ]; + } + + /** @return array */ + public function verifyPlanCheckout(string $reference): array + { + $res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/plan-checkout/verify', [ + 'reference' => $reference, + ]); + + if ($res->status() === 422) { + return ['paid' => false, 'error' => $res->json('error')]; + } + $res->throw(); + + return $res->json(); + } } diff --git a/app/Services/Care/PlanService.php b/app/Services/Care/PlanService.php index 47a78ae..ccc9e3a 100644 --- a/app/Services/Care/PlanService.php +++ b/app/Services/Care/PlanService.php @@ -2,6 +2,7 @@ namespace App\Services\Care; +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 { $plan = config('care.plans.'.$this->planKey($organization)); @@ -49,4 +68,21 @@ class PlanService { return (int) config('care.plans.pro.price_minor', 19900); } + + public function enterprisePricePerBranchMinor(): int + { + return (int) config('care.plans.enterprise.price_minor_per_branch', 49900); + } + + 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('care.enterprise.min_branches', 2); + } } diff --git a/app/Services/Care/ProRenewalService.php b/app/Services/Care/ProRenewalService.php index bfd5850..450a201 100644 --- a/app/Services/Care/ProRenewalService.php +++ b/app/Services/Care/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 = 'care-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 ? 'care-enterprise-' : 'care-pro-') + .$organization->id.'-'.now()->format('YmdHis'); try { $charged = $this->billing->debit( $organization->owner_ref, $price, - 'care_pro_renewal', + $isEnterprise ? 'care_enterprise_renewal' : 'care_pro_renewal', $reference, - 'Ladill Care Pro — monthly renewal', + $isEnterprise + ? 'Ladill Care Enterprise — monthly renewal' + : 'Ladill Care 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('care.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('care.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/care.php b/config/care.php index 8c999e5..6a45a99 100644 --- a/config/care.php +++ b/config/care.php @@ -215,8 +215,19 @@ return [ 'price_minor' => (int) env('CARE_PRO_PRICE_MINOR', 19900), 'max_branches' => null, ], + 'enterprise' => [ + 'label' => 'Enterprise', + 'price_minor_per_branch' => (int) env('CARE_ENTERPRISE_PRICE_PER_BRANCH_MINOR', 49900), + 'max_branches' => null, + ], ], + 'enterprise' => [ + 'min_branches' => (int) env('CARE_ENTERPRISE_MIN_BRANCHES', 2), + ], + + 'prepaid_months' => [6, 12, 24], + 'pro' => [ 'grace_days' => (int) env('CARE_PRO_GRACE_DAYS', 3), 'period_days' => (int) env('CARE_PRO_PERIOD_DAYS', 30), diff --git a/resources/views/care/pro/index.blade.php b/resources/views/care/pro/index.blade.php index d01a991..e5df0bf 100644 --- a/resources/views/care/pro/index.blade.php +++ b/resources/views/care/pro/index.blade.php @@ -1,51 +1,146 @@ - + @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 Care Pro

-

Unlimited branches and the full hospital management toolkit.

-

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

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

Choose your Care plan

+

+ Pay monthly from your Ladill wallet, or prepay 6/12/24 months via Paystack. + Enterprise includes a one-time setup fee — contact sales for onboarding. +

+
+ + @foreach ($prepaidMonths as $months) + + @endforeach +
+
+ +
+ {{-- Free --}} +
+

Free

+

GHS 0

+

1 branch

+
    +
  • Core patient records
  • +
  • Appointments & consults
  • +
  • Basic workflows
  • +
+ @if ($planKey === 'free') +

Current plan

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

    Pro

    +

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

    +

    + +

    +

    Unlimited branches

    +
      +
    • Full clinical workflows
    • +
    • Laboratory & pharmacy
    • +
    • Encounter billing
    • +
    • Queue integration
    - -
    - @if ($isPro) -
    +
    + @if ($planKey === 'pro') +

    + Active @if ($planExpiresAt) - Pro is active until {{ $planExpiresAt->format('d M Y') }}. - @else - Your organization is on Care 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
    • +
    • Multi-department workflows
    • +
    • Consolidated reporting
    • +
    • 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/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 0f4bedf..047bedd 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -112,13 +112,13 @@ @endif @php $proActive = request()->routeIs('care.pro.*'); @endphp - @if (! empty($isPro)) - + @if (! empty($hasPaidPlan)) + - Care Pro + {{ ! empty($isEnterprise) ? 'Care Enterprise' : 'Care Pro' }} @else - + Upgrade to Pro diff --git a/routes/web.php b/routes/web.php index 3f03ebe..49e051d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -133,6 +133,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/pro', [ProController::class, 'index'])->name('care.pro.index'); Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('care.pro.subscribe'); + Route::post('/pro/subscribe-enterprise', [ProController::class, 'subscribeEnterprise'])->name('care.pro.subscribe-enterprise'); + Route::post('/pro/subscribe-prepaid', [ProController::class, 'subscribePrepaid'])->name('care.pro.subscribe-prepaid'); + Route::get('/pro/paystack/callback', [ProController::class, 'paystackCallback'])->name('care.pro.paystack.callback'); Route::get('/branches', [BranchController::class, 'index'])->name('care.branches.index'); Route::get('/branches/create', [BranchController::class, 'create'])->name('care.branches.create'); diff --git a/tests/Feature/CareProTest.php b/tests/Feature/CareProTest.php index 07fda1e..c934faa 100644 --- a/tests/Feature/CareProTest.php +++ b/tests/Feature/CareProTest.php @@ -53,17 +53,27 @@ class CareProTest 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('care.pro.index')) ->assertOk() - ->assertSee('Ladill Care Pro') + ->assertSee('Choose your Care plan') ->assertSee('GHS 199') + ->assertSee('GHS 499') + ->assertSee('Upgrade to Pro') + ->assertSee('Enterprise'); + } + + public function test_sidebar_shows_upgrade_to_pro_on_dashboard(): void + { + $this->actingAs($this->owner) + ->get(route('care.dashboard')) + ->assertOk() ->assertSee('Upgrade to Pro'); } - public function test_subscribe_upgrades_organization(): void + public function test_subscribe_upgrades_organization_to_pro(): void { Http::fake([ 'billing.test/can-afford*' => Http::response(['affordable' => true]), @@ -75,7 +85,77 @@ class CareProTest extends TestCase ->assertRedirect(route('care.pro.index')) ->assertSessionHas('success'); - $this->assertSame('pro', $this->organization->fresh()->settings['plan']); + $settings = $this->organization->fresh()->settings; + $this->assertSame('pro', $settings['plan']); + $this->assertSame('wallet_monthly', $settings['billing_method']); + } + + 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('care.pro.subscribe-enterprise')) + ->assertRedirect(route('care.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('care.pro.subscribe-enterprise')) + ->assertRedirect(route('care.pro.index')) + ->assertSessionHas('success'); + + $settings = $this->organization->fresh()->settings; + $this->assertSame('enterprise', $settings['plan']); + $this->assertSame(2, $settings['enterprise_billed_branches']); + } + + public function test_subscribe_prepaid_redirects_to_paystack(): void + { + Http::fake([ + 'billing.test/plan-checkout' => Http::response([ + 'checkout_url' => 'https://checkout.paystack.com/test', + 'reference' => 'ref-123', + ]), + ]); + + $this->actingAs($this->owner) + ->post(route('care.pro.subscribe-prepaid'), [ + 'plan' => 'pro', + 'months' => 12, + ]) + ->assertRedirect('https://checkout.paystack.com/test'); + } + + public function test_paystack_callback_activates_prepaid_pro(): void + { + Http::fake([ + 'billing.test/plan-checkout/verify' => Http::response([ + 'paid' => true, + 'plan' => 'pro', + 'months' => 12, + 'metadata' => ['organization_id' => $this->organization->id], + ]), + ]); + + $this->actingAs($this->owner) + ->get(route('care.pro.paystack.callback', ['reference' => 'ref-123'])) + ->assertRedirect(route('care.pro.index')) + ->assertSessionHas('success'); + + $settings = $this->organization->fresh()->settings; + $this->assertSame('pro', $settings['plan']); + $this->assertFalse($settings['auto_renew']); + $this->assertSame('paystack_prepaid', $settings['billing_method']); } public function test_pro_renew_extends_subscription_when_wallet_charges(): void @@ -100,4 +180,37 @@ class CareProTest 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(99800, (int) $request['amount_minor']); + + return Http::response(['ok' => true]); + }, + ]); + + $this->artisan('care:pro-renew')->assertSuccessful(); + + $settings = $this->organization->fresh()->settings; + $this->assertSame('enterprise', $settings['plan']); + $this->assertSame(2, $settings['enterprise_billed_branches']); + } }