Files
ladill-care/app/Http/Controllers/Care/BillController.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

304 lines
11 KiB
PHP

<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Bill;
use App\Models\Visit;
use App\Services\Care\BillService;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\OrganizationResolver;
use App\Services\Payments\MerchantGatewayService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class BillController extends Controller
{
use ScopesToAccount;
public function __construct(
protected BillService $bills,
protected MerchantGatewayService $gateway,
protected CareQueueBridge $queueBridge,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'bills.view');
$organization = $this->organization($request);
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$bills = $this->bills->list(
$this->ownerRef($request),
$organization->id,
$request->only(['status', 'patient_id']),
$branchScope,
);
$branchId = (int) ($branchScope ?: \App\Models\Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->value('id'));
return view('care.bills.index', [
'organization' => $organization,
'bills' => $bills,
'statuses' => config('care.bill_statuses'),
'branchId' => $branchId,
'queueIntegration' => $branchId
? $this->queueBridge->listMeta($organization, CareQueueContexts::BILLING, $branchId)
: ['enabled' => false],
'canManageQueue' => app(\App\Services\Care\CarePermissions::class)
->can($member, 'service_queues.console'),
]);
}
public function callNext(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$organization = $this->organization($request);
$member = $this->member($request);
$branchScope = app(OrganizationResolver::class)->branchScope($member);
$branchId = (int) ($request->input('branch_id') ?: $branchScope);
abort_unless($branchId > 0, 422);
abort_unless($this->queueBridge->isEnabled($organization), 404);
$result = $this->queueBridge->callNext($organization, CareQueueContexts::BILLING, $branchId);
$ticket = $result['ticket'] ?? null;
if (! $ticket) {
return back()->with('error', 'No bills waiting in the billing queue.');
}
return back()->with('success', 'Called '.$ticket['ticket_number'].'.');
}
public function serve(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeBill($request, $bill);
$organization = $this->organization($request);
abort_unless($this->queueBridge->isEnabled($organization), 404);
$ticket = $this->queueBridge->serve($organization, $bill);
if (! $ticket) {
return back()->with('error', 'Could not serve billing ticket.');
}
return back()->with('success', 'Now serving '.$ticket['ticket_number'].'.');
}
public function generate(Request $request, Visit $visit): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeVisit($request, $visit);
$bill = $this->bills->generateFromVisit($visit, $this->ownerRef($request), $this->ownerRef($request));
return redirect()->route('care.bills.show', $bill)
->with('success', 'Bill generated from visit.');
}
public function show(Request $request, Bill $bill): View
{
$this->authorizeAbility($request, 'bills.view');
$this->authorizeBill($request, $bill);
$bill->load(['patient', 'branch', 'visit', 'lineItems', 'payments']);
$canManage = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'bills.manage');
$canPay = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'payments.manage');
$organization = $this->organization($request);
$gatewayConfigured = $this->gateway->isConfigured($organization);
$gateway = $this->gateway->settingFor($organization);
// Manual record form excludes gateway method (online checkout is separate).
$manualMethods = collect(config('care.payment_methods', []))
->except(['gateway'])
->all();
return view('care.bills.show', [
'bill' => $bill,
'statuses' => config('care.bill_statuses'),
'lineTypes' => config('care.bill_line_types'),
'paymentMethods' => config('care.payment_methods'),
'manualPaymentMethods' => $manualMethods,
'canManage' => $canManage,
'canPay' => $canPay,
'gatewayConfigured' => $gatewayConfigured,
'gatewayProvider' => $gateway?->provider,
'gatewayLabels' => config('care.payment_gateways'),
]);
}
public function addLineItem(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeBill($request, $bill);
$this->bills->addManualLineItem(
$bill,
$this->ownerRef($request),
$request->validate([
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.bill_line_types')))],
'description' => ['required', 'string', 'max:255'],
'quantity' => ['required', 'integer', 'min:1'],
'unit_price_minor' => ['required', 'integer', 'min:0'],
]),
$this->ownerRef($request),
);
return back()->with('success', 'Line item added.');
}
public function applyAdjustments(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeBill($request, $bill);
$validated = $request->validate([
'discount_minor' => ['nullable', 'integer', 'min:0'],
'tax_minor' => ['nullable', 'integer', 'min:0'],
]);
$this->bills->applyAdjustments(
$bill,
$this->ownerRef($request),
(int) ($validated['discount_minor'] ?? 0),
(int) ($validated['tax_minor'] ?? 0),
$this->ownerRef($request),
);
return back()->with('success', 'Bill updated.');
}
public function recordPayment(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeBill($request, $bill);
$manualMethods = array_keys(collect(config('care.payment_methods', []))->except(['gateway'])->all());
$this->bills->recordPayment(
$bill,
$this->ownerRef($request),
$request->validate([
'amount_minor' => ['required', 'integer', 'min:1'],
'method' => ['required', 'string', 'in:'.implode(',', $manualMethods)],
'reference' => ['nullable', 'string', 'max:100'],
'notes' => ['nullable', 'string', 'max:500'],
]),
$this->ownerRef($request),
);
return back()->with('success', 'Payment recorded.');
}
public function startGatewayPayment(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'payments.manage');
$this->authorizeBill($request, $bill);
$validated = $request->validate([
'amount_minor' => ['nullable', 'integer', 'min:1'],
]);
$amount = (int) ($validated['amount_minor'] ?? $bill->balance_minor);
try {
$result = $this->bills->startGatewayPayment(
$bill->loadMissing('patient'),
$this->organization($request),
$this->ownerRef($request),
$amount,
$request->user(),
$this->ownerRef($request),
);
} catch (RuntimeException|\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()->away($result['checkout_url']);
}
public function paymentCallback(Request $request): RedirectResponse|View
{
$reference = trim((string) (
$request->query('reference')
?: $request->query('tx_ref')
?: $request->query('clientReference')
?: ''
));
if ($reference === '') {
return view('care.bills.payment-return', [
'redirect' => route('care.bills.index'),
]);
}
try {
$payment = $this->bills->completeGatewayPayment($reference);
} catch (\Throwable) {
session()->flash('error', 'Payment could not be verified. If the customer was charged, re-check the bill or contact support with reference '.$reference.'.');
return view('care.bills.payment-return', [
'redirect' => route('care.bills.index'),
]);
}
$bill = $payment->bill;
session()->flash('success', 'Online payment recorded for '.$bill->invoice_number.'.');
return view('care.bills.payment-return', [
'redirect' => route('care.bills.show', $bill),
]);
}
public function void(Request $request, Bill $bill): RedirectResponse
{
$this->authorizeAbility($request, 'bills.manage');
$this->authorizeBill($request, $bill);
$this->bills->void($bill, $this->ownerRef($request), $this->ownerRef($request));
return redirect()->route('care.bills.index')->with('success', 'Bill voided.');
}
public function print(Request $request, Bill $bill): View
{
$this->authorizeAbility($request, 'bills.view');
$this->authorizeBill($request, $bill);
$bill->load(['patient', 'branch', 'lineItems', 'payments', 'organization']);
return view('care.bills.print', [
'bill' => $bill,
'organization' => $this->organization($request),
]);
}
protected function authorizeBill(Request $request, Bill $bill): void
{
$this->authorizeOwner($request, $bill);
abort_unless($bill->organization_id === $this->organization($request)->id, 404);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null && $bill->branch_id !== $branchId) {
abort(404);
}
}
protected function authorizeVisit(Request $request, Visit $visit): void
{
$this->authorizeOwner($request, $visit);
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
}
}