Files
ladill-care/app/Services/Care/BillService.php
T
isaaccladandCursor 015a4cc7fe
Deploy Ladill Care / deploy (push) Successful in 1m45s
Wire Care lists into Ladill Queue workflows instead of reception panels.
When Queue integration is on, role pages issue tickets into their own
department queues and drive Call next → Serve → Complete on the native
lists. Integration off leaves existing UI unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:27:48 +00:00

493 lines
17 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\User;
use App\Models\Visit;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
class BillService
{
public function __construct(
protected InvoiceNumberGenerator $invoices,
protected MerchantGatewayService $gateway,
protected CareQueueBridge $queueBridge,
) {}
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);
$bill = $bill->fresh(['lineItems', 'patient', 'payments']);
if ($visit->organization) {
$this->queueBridge->issueForBill($visit->organization, $bill);
}
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'] ?? 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);
}
}
}
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.');
}
}
}