Activate Care workflows across check-in and financial clearance.
Deploy Ladill Care / deploy (push) Failing after 1m9s

Enforce service-queue gates, cashier settlements, and clinical stage progression so patient journeys cannot bypass configured payment or authorization rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 21:13:06 +00:00
co-authored by Cursor
parent 86bfce1e17
commit 3ee59a0956
38 changed files with 1953 additions and 107 deletions
+113 -5
View File
@@ -4,12 +4,14 @@ namespace App\Services\Care;
use App\Models\Bill;
use App\Models\BillLineItem;
use App\Models\FinancialObligation;
use App\Models\InvestigationRequest;
use App\Models\Organization;
use App\Models\Payment;
use App\Models\Prescription;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\Workflow\WorkflowStageCompletionService;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@@ -23,6 +25,7 @@ class BillService
protected InvoiceNumberGenerator $invoices,
protected MerchantGatewayService $gateway,
protected CareQueueBridge $queueBridge,
protected WorkflowStageCompletionService $workflowCompletion,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
@@ -90,6 +93,63 @@ class BillService
return $bill->fresh(['lineItems', 'patient', 'payments']);
}
/**
* Attach a workflow charge intent to an editable visit bill. A later stage
* may create a new bill when the previous encounter bill is already paid.
*/
public function billForObligation(FinancialObligation $obligation, string $ownerRef, ?string $actorRef = null): Bill
{
if ($obligation->bill_id) {
$linked = Bill::query()->find($obligation->bill_id);
if ($linked) {
return $linked;
}
}
$visit = $obligation->visit()->with(['organization', 'patient'])->firstOrFail();
$bill = Bill::owned($ownerRef)
->where('visit_id', $visit->id)
->whereIn('status', [Bill::STATUS_DRAFT, Bill::STATUS_OPEN, Bill::STATUS_PARTIAL])
->latest('id')
->first();
if (! $bill) {
$bill = Bill::create([
'owner_ref' => $ownerRef,
'organization_id' => $visit->organization_id,
'branch_id' => $visit->branch_id,
'visit_id' => $visit->id,
'patient_id' => $visit->patient_id,
'invoice_number' => $this->invoices->generate($visit->organization),
'status' => Bill::STATUS_OPEN,
'created_by' => $actorRef,
]);
AuditLogger::record($ownerRef, 'bill.created', $visit->organization_id, $actorRef, Bill::class, $bill->id);
}
$exists = $bill->lineItems()
->where('source_type', FinancialObligation::class)
->where('source_id', $obligation->id)
->exists();
if (! $exists) {
$this->addLineItem($bill, $ownerRef, [
'type' => $this->lineTypeForCharge($obligation->charge_code),
'description' => $obligation->label ?? 'Workflow charge',
'quantity' => 1,
'unit_price_minor' => $obligation->amount_minor,
'source_type' => FinancialObligation::class,
'source_id' => $obligation->id,
]);
$this->recalculate($bill);
}
$obligation->forceFill(['bill_id' => $bill->id])->save();
return $bill->fresh(['lineItems', 'payments', 'patient']);
}
public function syncLineItemsFromVisit(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill
{
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
@@ -98,9 +158,19 @@ class BillService
$visit = $bill->visit()->with(['consultations'])->firstOrFail();
$bill->lineItems()->whereNotNull('source_type')->delete();
$bill->lineItems()
->whereNotNull('source_type')
->where('source_type', '!=', FinancialObligation::class)
->delete();
if ($visit->consultations()->where('status', 'completed')->exists()) {
$workflowChargeCodes = FinancialObligation::query()
->where('bill_id', $bill->id)
->pluck('charge_code')
->filter()
->all();
if (! in_array('consultation', $workflowChargeCodes, true)
&& $visit->consultations()->where('status', 'completed')->exists()) {
$fee = (int) config('care.billing.consultation_fee_minor', 5000);
$this->addLineItem($bill, $ownerRef, [
'type' => 'consultation',
@@ -117,9 +187,18 @@ class BillService
->whereIn('status', [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED])
->with('investigationType')
->get()
->each(function (InvestigationRequest $request) use ($bill, $ownerRef) {
->each(function (InvestigationRequest $request) use ($bill, $ownerRef, $workflowChargeCodes) {
$chargeCode = in_array(
$request->investigationType?->category,
['xray', 'ultrasound', 'ct', 'mri'],
true,
) ? 'imaging' : 'lab';
if (in_array($chargeCode, $workflowChargeCodes, true)) {
return;
}
$this->addLineItem($bill, $ownerRef, [
'type' => 'lab',
'type' => $chargeCode,
'description' => $request->investigationType->name,
'quantity' => 1,
'unit_price_minor' => $request->investigationType->price_minor,
@@ -133,7 +212,11 @@ class BillService
->where('status', Prescription::STATUS_DISPENSED)
->with('items.drug')
->get()
->each(function (Prescription $rx) use ($bill, $ownerRef) {
->each(function (Prescription $rx) use ($bill, $ownerRef, $workflowChargeCodes) {
if (in_array('pharmacy', $workflowChargeCodes, true)) {
return;
}
foreach ($rx->items as $item) {
if ($item->is_procedure) {
$this->addLineItem($bill, $ownerRef, [
@@ -167,6 +250,21 @@ class BillService
return $bill->fresh(['lineItems', 'payments', 'patient']);
}
protected function lineTypeForCharge(?string $chargeCode): string
{
$candidate = match ($chargeCode) {
'consultation' => 'consultation',
'lab' => 'lab',
'imaging' => 'imaging',
'pharmacy' => 'pharmacy',
default => 'misc',
};
return array_key_exists($candidate, (array) config('care.bill_line_types', []))
? $candidate
: 'misc';
}
/**
* @param array<string, mixed> $data
*/
@@ -472,6 +570,16 @@ class BillService
$organization = Organization::query()->find($bill->organization_id);
if ($organization) {
$this->queueBridge->complete($organization, $bill);
$visit = $bill->visit;
if ($visit) {
$this->workflowCompletion->complete(
$organization,
$visit,
CareQueueContexts::BILLING,
(string) $bill->owner_ref,
$bill->payments()->latest('id')->value('recorded_by'),
);
}
}
}
}