Activate Care workflows across check-in and financial clearance.
Deploy Ladill Care / deploy (push) Failing after 1m9s
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:
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user