where('organization_id', $organizationId); } /** * @param array $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 $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 $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 $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.'); } } }