diff --git a/app/Http/Controllers/Care/SettingsGpPricingController.php b/app/Http/Controllers/Care/SettingsGpPricingController.php new file mode 100644 index 0000000..145349c --- /dev/null +++ b/app/Http/Controllers/Care/SettingsGpPricingController.php @@ -0,0 +1,48 @@ +authorizeAbility($request, 'settings.view'); + $organization = $this->organization($request); + $canManage = app(CarePermissions::class)->can($this->member($request), 'settings.manage'); + + return view('care.settings.gp-pricing', [ + 'organization' => $organization, + 'services' => $pricing->gpServices($organization), + 'canManage' => $canManage, + 'currency' => strtoupper((string) config('care.billing.currency', 'GHS')), + ]); + } + + public function update(Request $request, CarePricingService $pricing): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + + $validated = $request->validate([ + 'services' => ['required', 'array'], + 'services.*.code' => ['required', 'string', 'max:64'], + 'services.*.amount' => ['required', 'numeric', 'min:0', 'max:999999'], + ]); + + $pricing->updateGpServices($organization, $validated['services']); + + return redirect() + ->route('care.settings.gp-pricing') + ->with('success', 'GP service prices saved.'); + } +} diff --git a/app/Http/Controllers/Care/SettingsModulesController.php b/app/Http/Controllers/Care/SettingsModulesController.php index 90ad70c..b6de031 100644 --- a/app/Http/Controllers/Care/SettingsModulesController.php +++ b/app/Http/Controllers/Care/SettingsModulesController.php @@ -2,10 +2,13 @@ namespace App\Http\Controllers\Care; -use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; +use App\Http\Controllers\Controller; +use App\Services\Care\CarePermissions; +use App\Services\Care\CarePricingService; use App\Services\Care\PlanService; use App\Services\Care\SpecialtyModuleService; +use App\Services\Care\SpecialtyShellService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -19,7 +22,7 @@ class SettingsModulesController extends Controller $this->authorizeAbility($request, 'settings.view'); $organization = $this->organization($request); $member = $this->member($request); - $canManage = app(\App\Services\Care\CarePermissions::class)->can($member, 'settings.manage'); + $canManage = app(CarePermissions::class)->can($member, 'settings.manage'); if ($modules->canManage($organization)) { $modules->ensureDefaultModulesProvisioned($organization, $this->ownerRef($request)); @@ -36,6 +39,71 @@ class SettingsModulesController extends Controller ]); } + public function show( + Request $request, + string $module, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + CarePricingService $pricing, + ): View { + $this->authorizeAbility($request, 'settings.view'); + $definition = $modules->definition($module); + abort_unless($definition, 404); + + $organization = $this->organization($request); + $member = $this->member($request); + $canManage = app(CarePermissions::class)->can($member, 'settings.manage'); + $canUse = $modules->canManage($organization); + + if ($canUse && $modules->isEnabled($organization, $module)) { + $shell->seedServices($organization, $module); + $organization->refresh(); + } + + $services = $canUse + ? $shell->provisionedServices($organization, $module) + : $shell->catalogServices($module); + + return view('care.settings.module-show', [ + 'organization' => $organization, + 'moduleKey' => $module, + 'definition' => $definition, + 'services' => $services, + 'enabled' => $modules->isEnabled($organization, $module), + 'canManage' => $canManage && $canUse, + 'canUseSpecialtyModules' => $canUse, + 'canOpenWorkspace' => $modules->memberCanAccess($organization, $member, $module), + 'currency' => strtoupper((string) config('care.billing.currency', 'GHS')), + ]); + } + + public function updateServices( + Request $request, + string $module, + SpecialtyModuleService $modules, + SpecialtyShellService $shell, + CarePricingService $pricing, + ): RedirectResponse { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + abort_unless($modules->definition($module), 404); + abort_unless($modules->canManage($organization), 403); + + $validated = $request->validate([ + 'services' => ['required', 'array'], + 'services.*.code' => ['required', 'string', 'max:64'], + 'services.*.amount' => ['required', 'numeric', 'min:0', 'max:999999'], + 'services.*.label' => ['nullable', 'string', 'max:255'], + ]); + + $shell->seedServices($organization, $module); + $pricing->updateSpecialtyServices($organization->fresh(), $module, $validated['services'], $shell); + + return redirect() + ->route('care.settings.modules.show', $module) + ->with('success', 'Service prices saved.'); + } + public function update(Request $request, SpecialtyModuleService $modules): RedirectResponse { $this->authorizeAbility($request, 'settings.manage'); diff --git a/app/Services/Care/BillService.php b/app/Services/Care/BillService.php index e1d22cc..81e325e 100644 --- a/app/Services/Care/BillService.php +++ b/app/Services/Care/BillService.php @@ -171,7 +171,10 @@ class BillService if (! in_array('consultation', $workflowChargeCodes, true) && $visit->consultations()->where('status', 'completed')->exists()) { - $fee = (int) config('care.billing.consultation_fee_minor', 5000); + $org = Organization::query()->find($visit->organization_id); + $fee = $org + ? app(CarePricingService::class)->consultationFee($org) + : (int) config('care.billing.consultation_fee_minor', 5000); $this->addLineItem($bill, $ownerRef, [ 'type' => 'consultation', 'description' => 'Consultation fee', diff --git a/app/Services/Care/CarePricingService.php b/app/Services/Care/CarePricingService.php new file mode 100644 index 0000000..a6ac8c4 --- /dev/null +++ b/app/Services/Care/CarePricingService.php @@ -0,0 +1,183 @@ + + */ + public function defaultGpServices(): array + { + return [ + [ + 'code' => 'gp.consultation', + 'label' => 'GP consultation', + 'amount_minor' => (int) config('care.billing.consultation_fee_minor', 5000), + 'type' => 'consultation', + ], + [ + 'code' => 'gp.follow_up', + 'label' => 'Follow-up consultation', + 'amount_minor' => (int) config('care.billing.follow_up_fee_minor', 3000), + 'type' => 'consultation', + ], + [ + 'code' => 'gp.patient_card', + 'label' => 'Patient card / registration', + 'amount_minor' => (int) config('care.billing.patient_card_fee_minor', 2000), + 'type' => 'misc', + ], + [ + 'code' => 'gp.vitals', + 'label' => 'Vitals / nursing charge', + 'amount_minor' => (int) config('care.billing.vitals_fee_minor', 1000), + 'type' => 'nursing', + ], + [ + 'code' => 'gp.procedure', + 'label' => 'Minor procedure', + 'amount_minor' => (int) config('care.billing.minor_procedure_fee_minor', 5000), + 'type' => 'procedure', + ], + [ + 'code' => 'gp.home_visit', + 'label' => 'Home visit', + 'amount_minor' => (int) config('care.billing.home_visit_fee_minor', 15000), + 'type' => 'consultation', + ], + ]; + } + + /** + * @return list + */ + public function gpServices(Organization $organization): array + { + $catalog = collect($this->defaultGpServices())->keyBy(fn ($s) => (string) $s['code']); + $stored = data_get($organization->settings, 'gp_services'); + if (is_array($stored)) { + foreach ($stored as $service) { + $code = (string) ($service['code'] ?? ''); + if ($code === '' || ! $catalog->has($code)) { + continue; + } + $prior = $catalog->get($code, []); + $catalog->put($code, array_merge(is_array($prior) ? $prior : [], [ + 'code' => $code, + 'label' => (string) ($service['label'] ?? $prior['label'] ?? $code), + 'amount_minor' => (int) ($service['amount_minor'] ?? $prior['amount_minor'] ?? 0), + 'type' => (string) ($service['type'] ?? $prior['type'] ?? 'misc'), + ])); + } + } + + return $catalog->values()->all(); + } + + public function gpAmount(Organization $organization, string $code): int + { + $match = collect($this->gpServices($organization)) + ->first(fn ($s) => ($s['code'] ?? '') === $code); + + return (int) ($match['amount_minor'] ?? 0); + } + + public function consultationFee(Organization $organization): int + { + return $this->gpAmount($organization, 'gp.consultation'); + } + + public function patientCardFee(Organization $organization): int + { + return $this->gpAmount($organization, 'gp.patient_card'); + } + + /** + * @param list $rows + * @return list + */ + public function updateGpServices(Organization $organization, array $rows): array + { + $defaults = collect($this->defaultGpServices())->keyBy(fn ($s) => (string) $s['code']); + $merged = []; + + foreach ($rows as $row) { + $code = (string) ($row['code'] ?? ''); + if ($code === '' || ! $defaults->has($code)) { + continue; + } + $base = $defaults->get($code); + $merged[] = [ + 'code' => $code, + 'label' => (string) ($base['label'] ?? $code), + 'amount_minor' => max(0, (int) round(((float) ($row['amount'] ?? 0)) * 100)), + 'type' => (string) ($base['type'] ?? 'misc'), + ]; + } + + // Keep any defaults not submitted. + $byCode = collect($merged)->keyBy('code'); + foreach ($defaults as $code => $base) { + if (! $byCode->has($code)) { + $byCode->put($code, $base); + } + } + + $list = $byCode->values()->all(); + $settings = $organization->settings ?? []; + $settings['gp_services'] = $list; + $organization->update(['settings' => $settings]); + + return $list; + } + + /** + * Persist specialty module service amounts (major currency input → minor). + * + * @param list $rows + * @return list + */ + public function updateSpecialtyServices( + Organization $organization, + string $moduleKey, + array $rows, + SpecialtyShellService $shell, + ): array { + $current = collect($shell->provisionedServices($organization, $moduleKey)) + ->keyBy(fn ($s) => (string) ($s['code'] ?? '')); + + foreach ($rows as $row) { + $code = (string) ($row['code'] ?? ''); + if ($code === '' || ! $current->has($code)) { + continue; + } + $prior = $current->get($code); + $current->put($code, array_merge(is_array($prior) ? $prior : [], [ + 'code' => $code, + 'label' => (string) ($row['label'] ?? $prior['label'] ?? $code), + 'amount_minor' => max(0, (int) round(((float) ($row['amount'] ?? 0)) * 100)), + 'type' => (string) ($prior['type'] ?? 'misc'), + ])); + } + + $list = $current->filter(fn ($s, $code) => $code !== '')->values()->all(); + $settings = $organization->settings ?? []; + $provisioning = is_array($settings['specialty_module_provisioning'] ?? null) + ? $settings['specialty_module_provisioning'] + : []; + $record = is_array($provisioning[$moduleKey] ?? null) ? $provisioning[$moduleKey] : []; + $record['services'] = $list; + $provisioning[$moduleKey] = $record; + $settings['specialty_module_provisioning'] = $provisioning; + $organization->update(['settings' => $settings]); + + return $list; + } +} diff --git a/app/Services/Care/Workflow/FinancialGateService.php b/app/Services/Care/Workflow/FinancialGateService.php index ac12170..5abf67b 100644 --- a/app/Services/Care/Workflow/FinancialGateService.php +++ b/app/Services/Care/Workflow/FinancialGateService.php @@ -9,6 +9,7 @@ use App\Models\Prescription; use App\Models\Visit; use App\Models\WorkflowStage; use App\Services\Care\CareFeatures; +use App\Services\Care\CarePricingService; /** * Owns FinancialObligation lifecycle and answers whether a stage's financial @@ -154,9 +155,16 @@ class FinancialGateService protected function defaultAmountFor(WorkflowStage $stage, Visit $visit): int { + $org = Organization::query()->find($visit->organization_id); + $pricing = app(CarePricingService::class); + return match ($stage->charge_code) { - 'patient_card', 'registration' => (int) config('care.billing.patient_card_fee_minor', 0), - 'consultation' => (int) config('care.billing.consultation_fee_minor', 0), + 'patient_card', 'registration' => $org + ? $pricing->patientCardFee($org) + : (int) config('care.billing.patient_card_fee_minor', 0), + 'consultation' => $org + ? $pricing->consultationFee($org) + : (int) config('care.billing.consultation_fee_minor', 0), 'lab' => $this->investigationTotal($visit, imaging: false), 'imaging' => $this->investigationTotal($visit, imaging: true), 'pharmacy' => $this->pharmacyTotal($visit), diff --git a/config/care.php b/config/care.php index 8f0e6ea..7b01dc2 100644 --- a/config/care.php +++ b/config/care.php @@ -253,6 +253,10 @@ return [ 'billing' => [ 'patient_card_fee_minor' => (int) env('CARE_PATIENT_CARD_FEE_MINOR', 2000), 'consultation_fee_minor' => (int) env('CARE_CONSULTATION_FEE_MINOR', 5000), + 'follow_up_fee_minor' => (int) env('CARE_FOLLOW_UP_FEE_MINOR', 3000), + 'vitals_fee_minor' => (int) env('CARE_VITALS_FEE_MINOR', 1000), + 'minor_procedure_fee_minor' => (int) env('CARE_MINOR_PROCEDURE_FEE_MINOR', 5000), + 'home_visit_fee_minor' => (int) env('CARE_HOME_VISIT_FEE_MINOR', 15000), 'currency' => env('CARE_CURRENCY', 'GHS'), ], diff --git a/resources/views/care/dashboard.blade.php b/resources/views/care/dashboard.blade.php index 33c7d92..9a781af 100644 --- a/resources/views/care/dashboard.blade.php +++ b/resources/views/care/dashboard.blade.php @@ -197,10 +197,19 @@

Specialty modules

diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index 180bd92..6e5dddd 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -116,6 +116,16 @@ @endif +
  • + + + GP pricing + Consultation, registration, and clinic service prices + + + +
  • @@ -124,7 +134,7 @@ @if (empty($canUseSpecialtyModules)) Pro @endif - Dentistry, eye care, physiotherapy, maternity, radiology + Dentistry, eye care, physiotherapy, maternity, radiology — activate & set prices diff --git a/resources/views/care/settings/gp-pricing.blade.php b/resources/views/care/settings/gp-pricing.blade.php new file mode 100644 index 0000000..3d46e1f --- /dev/null +++ b/resources/views/care/settings/gp-pricing.blade.php @@ -0,0 +1,61 @@ + + +

    + ← Facility settings +

    + + @if (session('success')) +

    {{ session('success') }}

    + @endif + + +
    + @csrf + @method('PUT') +
    + + + + + + + + + + @foreach ($services as $i => $service) + + + + + + @endforeach + +
    ServiceTypePrice ({{ $currency }})
    + +

    {{ $service['label'] }}

    +

    {{ $service['code'] }}

    +
    {{ ucfirst($service['type'] ?? 'misc') }} + +
    +
    + @if ($canManage) +
    + +
    + @endif +
    +
    +
    +
    diff --git a/resources/views/care/settings/module-show.blade.php b/resources/views/care/settings/module-show.blade.php new file mode 100644 index 0000000..09ab64c --- /dev/null +++ b/resources/views/care/settings/module-show.blade.php @@ -0,0 +1,82 @@ + + +

    + ← Specialty modules +

    + + @if (session('success')) +

    {{ session('success') }}

    + @endif + + +
    + @if ($enabled) + Active + @else + Inactive + @endif + @if ($canOpenWorkspace) + Open module workspace → + @endif + Manage activation +
    +
    + + @if (! $canUseSpecialtyModules) + + View plans + + @else + +
    + @csrf + @method('PUT') +
    + + + + + + + + + + @foreach ($services as $i => $service) + + + + + + @endforeach + +
    ServiceTypePrice ({{ $currency }})
    + + +

    {{ $service['label'] }}

    +

    {{ $service['code'] }}

    +
    {{ ucfirst($service['type'] ?? 'misc') }} + +
    +
    + @if ($canManage) +
    + +
    + @endif +
    +
    + @endif +
    +
    diff --git a/resources/views/care/settings/modules.blade.php b/resources/views/care/settings/modules.blade.php index 43225f8..2c180b2 100644 --- a/resources/views/care/settings/modules.blade.php +++ b/resources/views/care/settings/modules.blade.php @@ -1,7 +1,9 @@ - +

    ← Facility settings + · + GP pricing

    @if (session('success')) @@ -24,27 +26,30 @@
    @foreach ($catalog as $key => $module) @php $isDefault = ! empty($module['default_on_paid_plans']); @endphp -
    diff --git a/routes/web.php b/routes/web.php index 04ad968..4bff339 100644 --- a/routes/web.php +++ b/routes/web.php @@ -33,6 +33,7 @@ use App\Http\Controllers\Care\QueueController; use App\Http\Controllers\Care\ReportController; use App\Http\Controllers\Care\ServiceQueueController; use App\Http\Controllers\Care\SettingsController; +use App\Http\Controllers\Care\SettingsGpPricingController; use App\Http\Controllers\Care\SettingsModulesController; use App\Http\Controllers\Care\DentistryWorkspaceController; use App\Http\Controllers\Care\SpecialtyModuleController; @@ -227,8 +228,12 @@ 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('/settings/gp-pricing', [SettingsGpPricingController::class, 'edit'])->name('care.settings.gp-pricing'); + Route::put('/settings/gp-pricing', [SettingsGpPricingController::class, 'update'])->name('care.settings.gp-pricing.update'); Route::get('/settings/modules', [SettingsModulesController::class, 'edit'])->name('care.settings.modules'); Route::put('/settings/modules', [SettingsModulesController::class, 'update'])->name('care.settings.modules.update'); + Route::get('/settings/modules/{module}', [SettingsModulesController::class, 'show'])->name('care.settings.modules.show'); + Route::put('/settings/modules/{module}/services', [SettingsModulesController::class, 'updateServices'])->name('care.settings.modules.services.update'); Route::get('/integrations', [IntegrationsController::class, 'edit'])->name('care.integrations'); Route::post('/integrations/sms', [IntegrationsController::class, 'saveSms'])->name('care.integrations.sms.save'); diff --git a/tests/Feature/CareServicePricingSettingsTest.php b/tests/Feature/CareServicePricingSettingsTest.php new file mode 100644 index 0000000..af18df9 --- /dev/null +++ b/tests/Feature/CareServicePricingSettingsTest.php @@ -0,0 +1,126 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'pricing-owner', + 'name' => 'Owner', + 'email' => 'pricing-owner@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Pricing Clinic', + 'slug' => 'pricing-clinic', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'clinic', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + } + + public function test_gp_pricing_page_saves_org_overrides(): void + { + $this->actingAs($this->owner) + ->get(route('care.settings.gp-pricing')) + ->assertOk() + ->assertSee('GP consultation') + ->assertSee('Patient card'); + + $this->actingAs($this->owner) + ->put(route('care.settings.gp-pricing.update'), [ + 'services' => [ + ['code' => 'gp.consultation', 'amount' => 75.50], + ['code' => 'gp.follow_up', 'amount' => 40], + ['code' => 'gp.patient_card', 'amount' => 25], + ['code' => 'gp.vitals', 'amount' => 10], + ['code' => 'gp.procedure', 'amount' => 50], + ['code' => 'gp.home_visit', 'amount' => 150], + ], + ]) + ->assertRedirect(route('care.settings.gp-pricing')); + + $this->organization->refresh(); + $pricing = app(CarePricingService::class); + $this->assertSame(7550, $pricing->consultationFee($this->organization)); + $this->assertSame(2500, $pricing->patientCardFee($this->organization)); + } + + public function test_specialty_module_settings_update_service_prices(): void + { + app(SpecialtyModuleService::class)->activate( + $this->organization, + $this->owner->public_id, + 'dentistry', + ); + + $this->actingAs($this->owner) + ->get(route('care.settings.modules.show', 'dentistry')) + ->assertOk() + ->assertSee('Dental consultation') + ->assertSee('Service prices'); + + $this->actingAs($this->owner) + ->put(route('care.settings.modules.services.update', 'dentistry'), [ + 'services' => [ + ['code' => 'den.consult', 'amount' => 80, 'label' => 'Dental consultation'], + ['code' => 'den.fill', 'amount' => 150, 'label' => 'Filling'], + ], + ]) + ->assertRedirect(route('care.settings.modules.show', 'dentistry')); + + $this->organization->refresh(); + $services = collect(app(\App\Services\Care\SpecialtyShellService::class) + ->provisionedServices($this->organization, 'dentistry')) + ->keyBy('code'); + + $this->assertSame(8000, (int) $services['den.consult']['amount_minor']); + $this->assertSame(15000, (int) $services['den.fill']['amount_minor']); + } + + public function test_admin_dashboard_links_modules_to_settings(): void + { + app(SpecialtyModuleService::class)->activate( + $this->organization, + $this->owner->public_id, + 'dentistry', + ); + + $this->actingAs($this->owner) + ->get(route('care.dashboard')) + ->assertOk() + ->assertSee(route('care.settings.modules.show', 'dentistry', false)) + ->assertSee('Settings & pricing'); + } +}