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); } $status = $filters['status'] ?? null; if ($status === 'outstanding') { $query->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL]) ->where('balance_minor', '>', 0); } elseif ($status) { $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); $bill = $bill->fresh(['lineItems', 'patient', 'payments']); if ($visit->organization) { $this->queueBridge->issueForBill($visit->organization, $bill); } 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)) { return $bill; } $visit = $bill->visit()->with(['consultations'])->firstOrFail(); $bill->lineItems() ->whereNotNull('source_type') ->where('source_type', '!=', FinancialObligation::class) ->delete(); $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()) { $org = Organization::query()->find($visit->organization_id); $fee = $org ? app(CarePricingService::class)->consultationFee($org) : (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, $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' => $chargeCode, '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, $workflowChargeCodes) { if (in_array('pharmacy', $workflowChargeCodes, true)) { return; } 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']); } 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 $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'] ?? Payment::METHOD_CASH, 'status' => Payment::STATUS_PAID, 'reference' => $data['reference'] ?? null, 'paid_at' => $data['paid_at'] ?? now(), 'recorded_by' => $actorRef, 'notes' => $data['notes'] ?? null, ]); $this->syncPaymentTotals($bill); AuditLogger::record($ownerRef, 'payment.recorded', $bill->organization_id, $actorRef, Payment::class, $payment->id, [ 'bill_id' => $bill->id, 'amount_minor' => $amount, ]); return $payment; } /** * Start an online checkout (Paystack / Flutterwave / Hubtel) against the bill balance. * * @return array{payment: Payment, checkout_url: string, reference: string, provider: string} */ public function startGatewayPayment( Bill $bill, Organization $organization, string $ownerRef, int $amountMinor, ?User $actor = null, ?string $actorRef = null, ): array { if ($bill->status === Bill::STATUS_VOID) { throw new InvalidArgumentException('Cannot pay a void bill.'); } if ($bill->balance_minor <= 0) { throw new InvalidArgumentException('This bill is already fully paid.'); } $amount = max(1, min($amountMinor, (int) $bill->balance_minor)); $email = trim((string) ($bill->patient?->email ?? '')); if ($email === '' || ! str_contains($email, '@')) { $email = trim((string) ($actor?->email ?? '')); } if ($email === '' || ! str_contains($email, '@')) { $email = 'care+'.($organization->id).'@checkout.ladill.local'; } $reference = 'CARE-'.strtoupper(Str::random(16)); $setting = $this->gateway->settingFor($organization); $payment = Payment::create([ 'owner_ref' => $ownerRef, 'bill_id' => $bill->id, 'amount_minor' => $amount, 'method' => Payment::METHOD_GATEWAY, 'status' => Payment::STATUS_PENDING, 'gateway_provider' => $setting?->provider, 'reference' => $reference, 'paid_at' => now(), // overwritten when verified; pending rows are excluded from totals 'recorded_by' => $actorRef, 'notes' => 'Online checkout initiated', ]); try { $checkout = $this->gateway->initializeCheckout( $organization, $amount, (string) config('care.billing.currency', 'GHS'), $email, route('care.bills.payments.callback'), $reference, [ 'title' => 'Invoice '.$bill->invoice_number, 'bill_id' => $bill->id, 'bill_uuid' => $bill->uuid, 'payment_id' => $payment->id, 'organization_id' => $organization->id, 'channel' => 'care_encounter', ], ); } catch (RuntimeException $e) { $payment->update(['status' => Payment::STATUS_FAILED, 'notes' => $e->getMessage()]); throw $e; } $payment->update([ 'reference' => $checkout['reference'], 'gateway_provider' => $checkout['provider'], ]); AuditLogger::record($ownerRef, 'payment.checkout_started', $bill->organization_id, $actorRef, Payment::class, $payment->id, [ 'bill_id' => $bill->id, 'amount_minor' => $amount, 'provider' => $checkout['provider'], 'reference' => $checkout['reference'], ]); return [ 'payment' => $payment->fresh(), 'checkout_url' => $checkout['checkout_url'], 'reference' => $checkout['reference'], 'provider' => $checkout['provider'], ]; } public function completeGatewayPayment(string $reference): Payment { $existingPaid = Payment::query() ->where('reference', $reference) ->where('status', Payment::STATUS_PAID) ->where('method', Payment::METHOD_GATEWAY) ->with(['bill']) ->first(); if ($existingPaid) { return $existingPaid; } $payment = Payment::query() ->where('reference', $reference) ->where('status', Payment::STATUS_PENDING) ->where('method', Payment::METHOD_GATEWAY) ->with(['bill.organization', 'bill.patient']) ->firstOrFail(); $bill = $payment->bill; $organization = $bill->organization ?? Organization::query()->findOrFail($bill->organization_id); $result = $this->gateway->verify($organization, $reference); if (! $result['paid']) { $payment->update([ 'status' => Payment::STATUS_FAILED, 'notes' => 'Gateway reported payment incomplete.', ]); throw new RuntimeException('Payment was not completed.'); } $verifiedAmount = (int) ($result['amount_minor'] ?: $payment->amount_minor); if ($verifiedAmount <= 0) { $verifiedAmount = (int) $payment->amount_minor; } $payment->update([ 'status' => Payment::STATUS_PAID, 'amount_minor' => $verifiedAmount, 'paid_at' => now(), 'gateway_provider' => $result['provider'] ?? $payment->gateway_provider, 'notes' => 'Online payment verified', ]); $this->syncPaymentTotals($bill); AuditLogger::record($payment->owner_ref, 'payment.recorded', $bill->organization_id, $payment->recorded_by, Payment::class, $payment->id, [ 'bill_id' => $bill->id, 'amount_minor' => $verifiedAmount, 'provider' => $payment->gateway_provider, 'reference' => $reference, 'channel' => 'gateway', ]); return $payment->fresh(['bill']); } 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 = $this->paidSum($bill); $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 syncPaymentTotals(Bill $bill): void { $bill->refresh(); $paid = $this->paidSum($bill); $balance = max(0, $bill->total_minor - $paid); $wasPaid = $bill->status === Bill::STATUS_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; } if ($bill->status === Bill::STATUS_VOID) { $status = Bill::STATUS_VOID; } $bill->update([ 'amount_paid_minor' => $paid, 'balance_minor' => $balance, 'status' => $status, ]); if (! $wasPaid && $status === Bill::STATUS_PAID) { $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'), ); } } } } protected function paidSum(Bill $bill): int { return (int) $bill->payments() ->where('status', Payment::STATUS_PAID) ->sum('amount_minor'); } 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.'); } } }