diff --git a/app/Console/Commands/RenewProSubscriptionsCommand.php b/app/Console/Commands/RenewProSubscriptionsCommand.php new file mode 100644 index 0000000..dc3cc71 --- /dev/null +++ b/app/Console/Commands/RenewProSubscriptionsCommand.php @@ -0,0 +1,22 @@ +dueOrganizations(); + $this->info("Renewing {$due->count()} due organization(s)."); + $due->each(fn ($organization) => $renewals->renewIfDue($organization)); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Care/ProController.php b/app/Http/Controllers/Care/ProController.php new file mode 100644 index 0000000..93a39d4 --- /dev/null +++ b/app/Http/Controllers/Care/ProController.php @@ -0,0 +1,92 @@ +organization($request); + $canManage = app(CarePermissions::class)->can($this->member($request), 'settings.manage'); + $settings = $organization->settings ?? []; + $expiresAt = ! empty($settings['plan_expires_at']) + ? Carbon::parse($settings['plan_expires_at']) + : null; + + return view('care.pro.index', [ + 'organization' => $organization, + 'isPro' => $plans->isPro($organization), + 'canManage' => $canManage, + 'priceMinor' => $plans->proPriceMinor(), + 'currency' => (string) config('billing.currency', 'GHS'), + 'planExpiresAt' => $expiresAt, + 'proFeatures' => [ + 'Unlimited branches', + 'Full clinical workflows', + 'Laboratory & pharmacy modules', + 'Finance reports & audit exports', + 'Queue integration', + ], + ]); + } + + public function subscribe(Request $request, PlanService $plans, BillingClient $billing): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + + if ($plans->isPro($organization)) { + return redirect()->route('care.pro.index') + ->with('success', 'Your organization is already on Care Pro.'); + } + + $priceMinor = $plans->proPriceMinor(); + + 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.'); + } + + $charged = $billing->debit( + $organization->owner_ref, + $priceMinor, + 'care_pro', + 'care-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'), + 'Ladill Care Pro — monthly subscription', + ); + } catch (\Throwable) { + return redirect()->route('care.pro.index') + ->with('error', 'Could not process upgrade. Please try again.'); + } + + if (! $charged) { + return redirect()->route('care.pro.index') + ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to upgrade to Pro.'); + } + + $settings = $organization->settings ?? []; + $settings['plan'] = 'pro'; + $settings['auto_renew'] = true; + $settings['plan_expires_at'] = now() + ->addDays((int) config('care.pro.period_days', 30)) + ->toIso8601String(); + 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.'); + } +} diff --git a/app/Services/Care/PlanService.php b/app/Services/Care/PlanService.php index c12ea41..47a78ae 100644 --- a/app/Services/Care/PlanService.php +++ b/app/Services/Care/PlanService.php @@ -3,22 +3,37 @@ namespace App\Services\Care; use App\Models\Organization; +use Carbon\Carbon; class PlanService { + public function planKey(Organization $organization): string + { + $settings = $organization->settings ?? []; + $plan = (string) ($settings['plan'] ?? 'free'); + + if ($plan === 'pro' && ! empty($settings['plan_expires_at'])) { + if (Carbon::parse($settings['plan_expires_at'])->isPast()) { + return 'free'; + } + } + + return array_key_exists($plan, config('care.plans', [])) ? $plan : 'free'; + } + public function plan(Organization $organization): string { - return (string) data_get($organization->settings, 'plan', 'free'); + return $this->planKey($organization); } public function isPro(Organization $organization): bool { - return $this->plan($organization) === 'pro'; + return $this->planKey($organization) === 'pro'; } public function maxBranches(Organization $organization): ?int { - $plan = config('care.plans.'.$this->plan($organization)); + $plan = config('care.plans.'.$this->planKey($organization)); return $plan['max_branches'] ?? null; } diff --git a/app/Services/Care/ProRenewalService.php b/app/Services/Care/ProRenewalService.php new file mode 100644 index 0000000..bfd5850 --- /dev/null +++ b/app/Services/Care/ProRenewalService.php @@ -0,0 +1,89 @@ + */ + public function dueOrganizations(): Collection + { + return Organization::query() + ->where('settings->plan', 'pro') + ->whereNotNull('settings->plan_expires_at') + ->get() + ->filter(fn (Organization $organization) => $this->isDue($organization)); + } + + public function isDue(Organization $organization): bool + { + $settings = $organization->settings ?? []; + if (($settings['plan'] ?? '') !== 'pro') { + return false; + } + if (array_key_exists('auto_renew', $settings) && $settings['auto_renew'] === false) { + return false; + } + if (empty($settings['plan_expires_at'])) { + return false; + } + + return Carbon::parse($settings['plan_expires_at'])->lte(now()); + } + + public function renewIfDue(Organization $organization): void + { + if (! $this->isDue($organization)) { + return; + } + + $settings = $organization->settings ?? []; + $price = $this->plans->proPriceMinor(); + $reference = 'care-pro-'.$organization->id.'-'.now()->format('YmdHis'); + + try { + $charged = $this->billing->debit( + $organization->owner_ref, + $price, + 'care_pro_renewal', + $reference, + 'Ladill Care Pro — monthly renewal', + ); + } catch (\Throwable) { + $charged = false; + } + + if ($charged) { + $settings['plan'] = 'pro'; + $settings['auto_renew'] = true; + $settings['plan_expires_at'] = now() + ->addDays((int) config('care.pro.period_days', 30)) + ->toIso8601String(); + unset($settings['plan_renewal_error']); + $organization->update(['settings' => $settings]); + + return; + } + + $expires = Carbon::parse($settings['plan_expires_at']); + $graceEnd = $expires->copy()->addDays((int) config('care.pro.grace_days', 3)); + if ($graceEnd->isPast()) { + $settings['plan'] = 'free'; + unset($settings['plan_expires_at']); + $settings['plan_renewal_error'] = 'Suspended after failed renewal.'; + } else { + $settings['plan_renewal_error'] = 'Renewal failed — top up your wallet.'; + } + + $organization->update(['settings' => $settings]); + } +} diff --git a/config/care.php b/config/care.php index ceee2e1..8c999e5 100644 --- a/config/care.php +++ b/config/care.php @@ -217,6 +217,11 @@ return [ ], ], + 'pro' => [ + 'grace_days' => (int) env('CARE_PRO_GRACE_DAYS', 3), + 'period_days' => (int) env('CARE_PRO_PERIOD_DAYS', 30), + ], + 'queue' => [ 'api_url' => env('QUEUE_API_URL', 'https://queue.ladill.com/api/v1'), 'api_key' => env('QUEUE_API_KEY_CARE'), diff --git a/resources/views/care/pro/index.blade.php b/resources/views/care/pro/index.blade.php new file mode 100644 index 0000000..d01a991 --- /dev/null +++ b/resources/views/care/pro/index.blade.php @@ -0,0 +1,54 @@ + + @php + $price = number_format($priceMinor / 100, 2); + @endphp + + + + + + Pro + @if ($isPro) + Active + @endif + + Ladill Care Pro + Unlimited branches and the full hospital management toolkit. + {{ $currency }} {{ $price }} / month + + + + + @foreach ($proFeatures as $feature) + + + {{ $feature }} + + @endforeach + + + + @if ($isPro) + + @if ($planExpiresAt) + Pro is active until {{ $planExpiresAt->format('d M Y') }}. + @else + Your organization is on Care Pro. + @endif + + @elseif ($canManage) + + @csrf + + Upgrade to Pro — {{ $currency }} {{ $price }}/mo from wallet + + + Billed monthly from your Ladill wallet. + @else + Ask an organization administrator to upgrade this workspace to Pro. + @endif + + + + + diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 700c93b..0f4bedf 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -78,7 +78,7 @@ 'icon' => '']; } - $settingsActive = request()->routeIs('care.settings*'); + $settingsActive = request()->routeIs('care.settings*') && ! request()->routeIs('care.pro.*'); @endphp @@ -110,5 +110,18 @@ Settings @endif + + @php $proActive = request()->routeIs('care.pro.*'); @endphp + @if (! empty($isPro)) + + + Care Pro + + @else + + + Upgrade to Pro + + @endif diff --git a/routes/console.php b/routes/console.php index 44217da..8b70452 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,3 +1,5 @@ dailyAt('02:20')->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index 955cea8..3f03ebe 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,7 @@ use App\Http\Controllers\Care\OnboardingController; use App\Http\Controllers\Care\PatientController; use App\Http\Controllers\Care\PrescriptionController; use App\Http\Controllers\Care\QueueController; +use App\Http\Controllers\Care\ProController; use App\Http\Controllers\Care\ReportController; use App\Http\Controllers\Care\ServiceQueueController; use App\Http\Controllers\Care\SettingsController; @@ -130,6 +131,9 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/settings', [SettingsController::class, 'edit'])->name('care.settings'); Route::put('/settings', [SettingsController::class, 'update'])->name('care.settings.update'); + Route::get('/pro', [ProController::class, 'index'])->name('care.pro.index'); + Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('care.pro.subscribe'); + Route::get('/branches', [BranchController::class, 'index'])->name('care.branches.index'); Route::get('/branches/create', [BranchController::class, 'create'])->name('care.branches.create'); Route::post('/branches', [BranchController::class, 'store'])->name('care.branches.store'); diff --git a/tests/Feature/CareProTest.php b/tests/Feature/CareProTest.php new file mode 100644 index 0000000..07fda1e --- /dev/null +++ b/tests/Feature/CareProTest.php @@ -0,0 +1,103 @@ +withoutMiddleware(EnsurePlatformSession::class); + config(['billing.api_url' => 'https://billing.test']); + + $this->owner = User::create([ + 'public_id' => 'care-owner-001', + 'name' => 'Owner', + 'email' => 'owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Care Org', + 'slug' => 'care-org', + 'settings' => ['onboarded' => true, 'facility_type' => 'clinic'], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + + Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'HQ', + 'is_active' => true, + ]); + } + + public function test_pro_page_renders_for_free_organization(): void + { + $this->actingAs($this->owner) + ->get(route('care.pro.index')) + ->assertOk() + ->assertSee('Ladill Care Pro') + ->assertSee('GHS 199') + ->assertSee('Upgrade to Pro'); + } + + public function test_subscribe_upgrades_organization(): 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')) + ->assertRedirect(route('care.pro.index')) + ->assertSessionHas('success'); + + $this->assertSame('pro', $this->organization->fresh()->settings['plan']); + } + + public function test_pro_renew_extends_subscription_when_wallet_charges(): void + { + $this->organization->update([ + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'clinic', + 'plan' => 'pro', + 'auto_renew' => true, + 'plan_expires_at' => now()->subDay()->toIso8601String(), + ], + ]); + + Http::fake([ + 'billing.test/debit' => Http::response(['ok' => true]), + ]); + + $this->artisan('care:pro-renew')->assertSuccessful(); + + $settings = $this->organization->fresh()->settings; + $this->assertSame('pro', $settings['plan']); + $this->assertTrue(now()->parse($settings['plan_expires_at'])->isFuture()); + } +}
Unlimited branches and the full hospital management toolkit.
{{ $currency }} {{ $price }} / month
Billed monthly from your Ladill wallet.
Ask an organization administrator to upgrade this workspace to Pro.