Files
ladill-care/app/Services/Care/BillService.php
T
isaaccladandCursor 5a1d47b9cc
Deploy Ladill Care / deploy (push) Successful in 51s
Add specialty module and GP service pricing settings.
Admin dashboard module cards open per-module settings with editable service prices; GP consultation and clinic fees live under Settings → GP pricing (inventory drugs unchanged).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:19:18 +00:00

604 lines
22 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\Bill;
use App\Models\BillLineItem;
use App\Models\FinancialObligation;
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\Care\Workflow\WorkflowStageCompletionService;
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,
protected WorkflowStageCompletionService $workflowCompletion,
) {}
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']);
}
/**
* 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<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);
$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.');
}
}
}