Add specialty module and GP service pricing settings.
Deploy Ladill Care / deploy (push) Successful in 51s

Admin dashboard module cards open per-module settings with editable service prices; GP consultation and clinic fees live under Settings → GP pricing (inventory drugs unchanged).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 17:19:18 +00:00
co-authored by Cursor
parent ce782382c5
commit 5a1d47b9cc
13 changed files with 631 additions and 19 deletions
+4 -1
View File
@@ -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',
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace App\Services\Care;
use App\Models\Organization;
/**
* Org-level price lists for GP (general practice) and specialty module services.
* Drug prices stay on Inventory (care_drugs.unit_price_minor); lab prices on investigation types.
*/
class CarePricingService
{
/**
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
*/
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<array{code: string, label: string, amount_minor: int, type: string}>
*/
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<array{code: string, amount_minor: int|string, label?: string}> $rows
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
*/
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<array{code: string, amount?: float|int|string, label?: string}> $rows
* @return list<array{code: string, label: string, amount_minor: int, type: string}>
*/
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;
}
}
@@ -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),