Files
ladill-care/app/Services/Care/BillService.php
T
isaaccladandCursor 6c9c742ed8
Deploy Ladill Care / deploy (push) Failing after 13s
Initial Ladill Care release.
Healthcare management app: patients, appointments, consultations, lab,
pharmacy inventory, billing, and reports at care.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 11:36:22 +00:00

308 lines
11 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\Bill;
use App\Models\BillLineItem;
use App\Models\InvestigationRequest;
use App\Models\Organization;
use App\Models\Payment;
use App\Models\Prescription;
use App\Models\Visit;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use InvalidArgumentException;
class BillService
{
public function __construct(
protected InvoiceNumberGenerator $invoices,
) {}
public function queryForOrganization(string $ownerRef, int $organizationId): Builder
{
return Bill::owned($ownerRef)->where('organization_id', $organizationId);
}
/**
* @param array<string, mixed> $filters
*/
public function list(string $ownerRef, int $organizationId, array $filters = [], ?int $branchId = null): LengthAwarePaginator
{
$query = $this->queryForOrganization($ownerRef, $organizationId)
->with(['patient', 'branch', 'visit'])
->orderByDesc('created_at');
if ($branchId !== null) {
$query->where('branch_id', $branchId);
}
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if ($patientId = $filters['patient_id'] ?? null) {
$query->where('patient_id', $patientId);
}
return $query->paginate((int) ($filters['per_page'] ?? 20))->withQueryString();
}
public function generateFromVisit(Visit $visit, string $ownerRef, ?string $actorRef = null): Bill
{
$existing = Bill::owned($ownerRef)
->where('visit_id', $visit->id)
->whereNotIn('status', [Bill::STATUS_VOID])
->first();
if ($existing) {
return $this->syncLineItemsFromVisit($existing, $ownerRef, $actorRef);
}
$visit->load(['patient', 'consultations', 'organization']);
$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,
]);
$this->syncLineItemsFromVisit($bill, $ownerRef, $actorRef);
AuditLogger::record($ownerRef, 'bill.created', $visit->organization_id, $actorRef, Bill::class, $bill->id);
return $bill->fresh(['lineItems', 'patient', 'payments']);
}
public function syncLineItemsFromVisit(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill
{
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
return $bill;
}
$visit = $bill->visit()->with(['consultations'])->firstOrFail();
$bill->lineItems()->whereNotNull('source_type')->delete();
if ($visit->consultations()->where('status', 'completed')->exists()) {
$fee = (int) config('care.billing.consultation_fee_minor', 5000);
$this->addLineItem($bill, $ownerRef, [
'type' => 'consultation',
'description' => 'Consultation fee',
'quantity' => 1,
'unit_price_minor' => $fee,
'source_type' => Visit::class,
'source_id' => $visit->id,
]);
}
InvestigationRequest::owned($ownerRef)
->where('visit_id', $visit->id)
->whereIn('status', [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED])
->with('investigationType')
->get()
->each(function (InvestigationRequest $request) use ($bill, $ownerRef) {
$this->addLineItem($bill, $ownerRef, [
'type' => 'lab',
'description' => $request->investigationType->name,
'quantity' => 1,
'unit_price_minor' => $request->investigationType->price_minor,
'source_type' => InvestigationRequest::class,
'source_id' => $request->id,
]);
});
Prescription::owned($ownerRef)
->where('visit_id', $visit->id)
->where('status', Prescription::STATUS_DISPENSED)
->with('items.drug')
->get()
->each(function (Prescription $rx) use ($bill, $ownerRef) {
foreach ($rx->items as $item) {
if ($item->is_procedure) {
$this->addLineItem($bill, $ownerRef, [
'type' => 'procedure',
'description' => $item->name,
'quantity' => 1,
'unit_price_minor' => 0,
'source_type' => Prescription::class,
'source_id' => $rx->id,
]);
continue;
}
$price = $item->drug?->unit_price_minor ?? 0;
$qty = max(1, (int) preg_replace('/\D/', '', (string) $item->quantity) ?: 1);
$this->addLineItem($bill, $ownerRef, [
'type' => 'pharmacy',
'description' => $item->name,
'quantity' => $qty,
'unit_price_minor' => $price,
'source_type' => Prescription::class,
'source_id' => $rx->id,
]);
}
});
$this->recalculate($bill);
AuditLogger::record($ownerRef, 'bill.updated', $bill->organization_id, $actorRef, Bill::class, $bill->id);
return $bill->fresh(['lineItems', 'payments', 'patient']);
}
/**
* @param array<string, mixed> $data
*/
public function addLineItem(Bill $bill, string $ownerRef, array $data): BillLineItem
{
$this->assertEditable($bill);
$quantity = max(1, (int) ($data['quantity'] ?? 1));
$unitPrice = (int) ($data['unit_price_minor'] ?? 0);
return BillLineItem::create([
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'type' => $data['type'] ?? 'misc',
'description' => $data['description'],
'quantity' => $quantity,
'unit_price_minor' => $unitPrice,
'total_minor' => $quantity * $unitPrice,
'source_type' => $data['source_type'] ?? null,
'source_id' => $data['source_id'] ?? null,
]);
}
/**
* @param array<string, mixed> $data
*/
public function addManualLineItem(Bill $bill, string $ownerRef, array $data, ?string $actorRef = null): Bill
{
$this->addLineItem($bill, $ownerRef, $data);
$this->recalculate($bill);
AuditLogger::record($ownerRef, 'bill.line_item_added', $bill->organization_id, $actorRef, Bill::class, $bill->id);
return $bill->fresh(['lineItems', 'payments']);
}
public function applyAdjustments(Bill $bill, string $ownerRef, int $discountMinor = 0, int $taxMinor = 0, ?string $actorRef = null): Bill
{
$this->assertEditable($bill);
$bill->update([
'discount_minor' => max(0, $discountMinor),
'tax_minor' => max(0, $taxMinor),
]);
$this->recalculate($bill);
AuditLogger::record($ownerRef, 'bill.updated', $bill->organization_id, $actorRef, Bill::class, $bill->id);
return $bill->fresh(['lineItems', 'payments']);
}
/**
* @param array<string, mixed> $data
*/
public function recordPayment(Bill $bill, string $ownerRef, array $data, ?string $actorRef = null): Payment
{
if ($bill->status === Bill::STATUS_VOID) {
throw new InvalidArgumentException('Cannot pay a void bill.');
}
$amount = (int) $data['amount_minor'];
if ($amount <= 0) {
throw new InvalidArgumentException('Payment amount must be positive.');
}
$payment = Payment::create([
'owner_ref' => $ownerRef,
'bill_id' => $bill->id,
'amount_minor' => $amount,
'method' => $data['method'] ?? 'cash',
'reference' => $data['reference'] ?? null,
'paid_at' => $data['paid_at'] ?? now(),
'recorded_by' => $actorRef,
'notes' => $data['notes'] ?? null,
]);
$bill->refresh();
$paid = (int) $bill->payments()->sum('amount_minor');
$balance = max(0, $bill->total_minor - $paid);
$status = Bill::STATUS_OPEN;
if ($paid > 0 && $balance > 0) {
$status = Bill::STATUS_PARTIAL;
} elseif ($balance === 0 && $bill->total_minor > 0) {
$status = Bill::STATUS_PAID;
} elseif ($paid >= $bill->total_minor) {
$status = Bill::STATUS_PAID;
}
$bill->update([
'amount_paid_minor' => $paid,
'balance_minor' => $balance,
'status' => $status,
]);
AuditLogger::record($ownerRef, 'payment.recorded', $bill->organization_id, $actorRef, Payment::class, $payment->id, [
'bill_id' => $bill->id,
'amount_minor' => $amount,
]);
return $payment;
}
public function void(Bill $bill, string $ownerRef, ?string $actorRef = null): Bill
{
if ($bill->status === Bill::STATUS_PAID) {
throw new InvalidArgumentException('Paid bills cannot be voided.');
}
$bill->update(['status' => Bill::STATUS_VOID, 'balance_minor' => 0]);
AuditLogger::record($ownerRef, 'bill.voided', $bill->organization_id, $actorRef, Bill::class, $bill->id);
return $bill->fresh();
}
protected function recalculate(Bill $bill): void
{
$bill->refresh();
$subtotal = (int) $bill->lineItems()->sum('total_minor');
$total = max(0, $subtotal - $bill->discount_minor + $bill->tax_minor);
$paid = (int) $bill->payments()->sum('amount_minor');
$balance = max(0, $total - $paid);
$status = $bill->status;
if ($status !== Bill::STATUS_VOID) {
if ($paid === 0) {
$status = Bill::STATUS_OPEN;
} elseif ($balance > 0) {
$status = Bill::STATUS_PARTIAL;
} else {
$status = Bill::STATUS_PAID;
}
}
$bill->update([
'subtotal_minor' => $subtotal,
'total_minor' => $total,
'amount_paid_minor' => $paid,
'balance_minor' => $balance,
'status' => $status,
]);
}
protected function assertEditable(Bill $bill): void
{
if (in_array($bill->status, [Bill::STATUS_PAID, Bill::STATUS_VOID], true)) {
throw new InvalidArgumentException('Bill cannot be modified.');
}
}
}