Files
ladill-care/app/Services/Care/Workflow/FinancialGateService.php
T
isaaccladandCursor 5a1d47b9cc
Deploy Ladill Care / deploy (push) Successful in 51s
Add specialty module and GP service pricing settings.
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>
2026-07-18 17:19:18 +00:00

213 lines
7.5 KiB
PHP

<?php
namespace App\Services\Care\Workflow;
use App\Models\FinancialObligation;
use App\Models\InvestigationRequest;
use App\Models\Organization;
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
* gate is satisfied for a given visit.
*
* A "before" gate blocks entry to the stage's service queue until the stage's
* obligation is cleared (paid / authorized / waived / deferred). An "after"
* gate never blocks entry but is created so the balance is collected at exit.
*/
class FinancialGateService
{
public function __construct(
protected CareFeatures $features,
) {}
/**
* Create (or return existing) obligation for a visit + stage. Only stages
* that require payment produce an obligation.
*
* @param array<string, mixed> $overrides
*/
public function obligationFor(Visit $visit, WorkflowStage $stage, array $overrides = []): ?FinancialObligation
{
if (! $stage->requires_payment) {
return null;
}
$organization = Organization::query()->find($visit->organization_id);
if ($organization && ! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) {
return null;
}
$amount = (int) ($overrides['amount_minor']
?? $stage->default_amount_minor
?? $this->defaultAmountFor($stage, $visit));
$defaults = array_merge([
'owner_ref' => $visit->owner_ref,
'organization_id' => $visit->organization_id,
'branch_id' => $visit->branch_id,
'patient_id' => $visit->patient_id,
'stage_code' => $stage->code,
'charge_code' => $stage->charge_code,
'label' => $stage->charge_label ?? $stage->name,
'amount_minor' => $amount,
'status' => $amount > 0
? FinancialObligation::STATUS_PENDING
: FinancialObligation::STATUS_WAIVED,
'clearance_method' => $amount > 0 ? null : 'no_charge',
'cleared_at' => $amount > 0 ? null : now(),
], $overrides);
$obligation = FinancialObligation::firstOrCreate(
[
'visit_id' => $visit->id,
'workflow_stage_id' => $stage->id,
],
$defaults,
);
// A stage may initially have no billable items (for example pharmacy
// before a prescription is saved). Re-evaluate no-charge clearances
// whenever the workflow gate is refreshed.
if ($obligation->clearance_method === 'no_charge' && $amount > 0) {
$obligation->forceFill([
'amount_minor' => $amount,
'status' => FinancialObligation::STATUS_PENDING,
'clearance_method' => null,
'cleared_at' => null,
])->save();
}
return $obligation;
}
/**
* Whether the visit may enter the stage. Stages with a "before" payment
* gate require a cleared obligation; everything else is allowed.
*/
public function isSatisfied(Visit $visit, WorkflowStage $stage): bool
{
if (! $stage->gatesBeforeService()) {
return true;
}
$organization = Organization::query()->find($visit->organization_id);
if ($organization && ! $this->features->enabled($organization, CareFeatures::FINANCIAL_GATES)) {
return true;
}
$obligation = FinancialObligation::query()
->where('visit_id', $visit->id)
->where('workflow_stage_id', $stage->id)
->whereNull('deleted_at')
->first();
return $obligation !== null && $obligation->isCleared();
}
public function blockedReason(Visit $visit, WorkflowStage $stage): ?string
{
if ($this->isSatisfied($visit, $stage)) {
return null;
}
return 'awaiting_payment';
}
/**
* Clear an obligation (paid / insurance authorized / waived / credit).
*/
public function clear(
FinancialObligation $obligation,
string $method,
?string $actorRef = null,
?string $paymentMode = null,
?string $externalReference = null,
): FinancialObligation {
$status = match ($method) {
'insurance' => FinancialObligation::STATUS_AUTHORIZED,
'waiver', 'override' => FinancialObligation::STATUS_WAIVED,
'credit' => FinancialObligation::STATUS_DEFERRED,
default => FinancialObligation::STATUS_PAID,
};
$obligation->forceFill([
'status' => $status,
'clearance_method' => $method,
'payment_mode' => $paymentMode,
'external_reference' => $externalReference,
'cleared_by' => $actorRef,
'cleared_at' => now(),
])->save();
return $obligation;
}
public function void(FinancialObligation $obligation): FinancialObligation
{
$obligation->forceFill(['status' => FinancialObligation::STATUS_VOID])->save();
return $obligation;
}
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' => $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),
'visit_total' => (int) $visit->bills()
->whereNotIn('status', ['paid', 'void'])
->sum('balance_minor'),
default => 0,
};
}
protected function investigationTotal(Visit $visit, bool $imaging): int
{
$imagingCategories = ['xray', 'ultrasound', 'ct', 'mri'];
return (int) InvestigationRequest::query()
->where('visit_id', $visit->id)
->with('investigationType')
->get()
->filter(function (InvestigationRequest $request) use ($imaging, $imagingCategories) {
$isImaging = in_array($request->investigationType?->category, $imagingCategories, true);
return $imaging ? $isImaging : ! $isImaging;
})
->sum(fn (InvestigationRequest $request) => (int) ($request->investigationType?->price_minor ?? 0));
}
protected function pharmacyTotal(Visit $visit): int
{
return (int) Prescription::query()
->where('visit_id', $visit->id)
->with('items.drug')
->get()
->sum(function (Prescription $prescription) {
return $prescription->items->sum(function ($item) {
if ($item->is_procedure) {
return 0;
}
$quantity = max(1, (int) preg_replace('/\D/', '', (string) $item->quantity) ?: 1);
return $quantity * (int) ($item->drug?->unit_price_minor ?? 0);
});
});
}
}