Deploy Ladill Care / deploy (push) Successful in 1m0s
Clinics on Pro/Enterprise can connect their own gateway in Integrations and collect card or MoMo on bills with 0% Ladill fee, while cash payments and balance totals stay correct for pending checkouts.
479 lines
17 KiB
PHP
479 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,
|
|
) {}
|
|
|
|
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'] ?? 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);
|
|
|
|
$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,
|
|
]);
|
|
}
|
|
|
|
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.');
|
|
}
|
|
}
|
|
}
|