Deploy Ladill Care / deploy (push) Failing after 13s
Healthcare management app: patients, appointments, consultations, lab, pharmacy inventory, billing, and reports at care.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
308 lines
11 KiB
PHP
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.');
|
|
}
|
|
}
|
|
}
|