Fix cashier desk: unpaid bills on every demo branch.
Deploy Ladill Care / deploy (push) Successful in 39s

Demo seeding used i%3 for both branch and paid status, so Ridge Clinic only got paid invoices. Cashiers now default to outstanding balances and Call next is secondary to the walk-up unpaid list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 23:22:54 +00:00
co-authored by Cursor
parent b84c7e31ba
commit a34d6579cd
7 changed files with 143 additions and 29 deletions
+10 -2
View File
@@ -55,10 +55,17 @@ class BillController extends Controller
]); ]);
} }
$filters = $request->only(['status', 'patient_id']);
// Cashiers land on collectible balances; walk-up unpaid list is the primary path
// (booth Call next is secondary when financial gates are off).
if (! $request->has('status') && $member?->role === 'cashier') {
$filters['status'] = 'outstanding';
}
$bills = $this->bills->list( $bills = $this->bills->list(
$this->ownerRef($request), $this->ownerRef($request),
$organization->id, $organization->id,
$request->only(['status', 'patient_id']), $filters,
$branchId > 0 ? $branchId : null, $branchId > 0 ? $branchId : null,
); );
@@ -66,6 +73,7 @@ class BillController extends Controller
'organization' => $organization, 'organization' => $organization,
'bills' => $bills, 'bills' => $bills,
'statuses' => config('care.bill_statuses'), 'statuses' => config('care.bill_statuses'),
'statusFilter' => $filters['status'] ?? '',
'branches' => $branches, 'branches' => $branches,
'branchId' => $branchId, 'branchId' => $branchId,
'canSwitchBranch' => $this->branchContext->canSwitch($member), 'canSwitchBranch' => $this->branchContext->canSwitch($member),
@@ -91,7 +99,7 @@ class BillController extends Controller
if (! $ticket) { if (! $ticket) {
return back()->with( return back()->with(
'info', 'info',
'No patients waiting at the billing booth. Open an unpaid invoice below to record a walk-up payment.', 'No patients waiting at the billing booth. Open an unpaid invoice above to record a walk-up payment.',
); );
} }
+5 -1
View File
@@ -46,7 +46,11 @@ class BillService
$query->where('branch_id', $branchId); $query->where('branch_id', $branchId);
} }
if ($status = $filters['status'] ?? null) { $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); $query->where('status', $status);
} }
+31 -8
View File
@@ -692,7 +692,10 @@ class DemoTenantSeeder
$branch = $hq; $branch = $hq;
if ($role !== 'hospital_admin' && $branches !== []) { if ($role !== 'hospital_admin' && $branches !== []) {
$branch = $branches[$index % count($branches)]; // Cashiers always staff HQ so demo unpaid invoices on branch[0] are collectible.
$branch = $role === 'cashier'
? $hq
: $branches[$index % count($branches)];
} }
$email = strtolower((string) $staff['email']); $email = strtolower((string) $staff['email']);
@@ -1750,10 +1753,27 @@ class DemoTenantSeeder
); );
} }
$branchCount = max(1, count($branches));
for ($i = 0; $i < $volumes['bills']; $i++) { for ($i = 0; $i < $volumes['bills']; $i++) {
$patient = $patients[$i % count($patients)]; $patient = $patients[$i % count($patients)];
$branch = $branches[$i % count($branches)]; $branch = $branches[$i % $branchCount];
$fee = 5000 + (($i % 5) * 1000); $fee = 5000 + (($i % 5) * 1000);
// Payment mix is per-branch (not i%3 with branchCount=3), otherwise HQ
// only ever gets paid invoices and the Ridge cashier has nothing to collect.
$slot = intdiv($i, $branchCount) % 3;
if ($slot === 0) {
$status = Bill::STATUS_PAID;
$amountPaid = $fee;
$balance = 0;
} elseif ($slot === 1) {
$status = Bill::STATUS_PARTIAL;
$amountPaid = (int) floor($fee / 2);
$balance = $fee - $amountPaid;
} else {
$status = Bill::STATUS_OPEN;
$amountPaid = 0;
$balance = $fee;
}
$visit = Visit::withTrashed()->updateOrCreate( $visit = Visit::withTrashed()->updateOrCreate(
['uuid' => $this->demoUuid("bill-visit|{$ownerRef}|{$i}")], ['uuid' => $this->demoUuid("bill-visit|{$ownerRef}|{$i}")],
@@ -1779,13 +1799,13 @@ class DemoTenantSeeder
'visit_id' => $visit->id, 'visit_id' => $visit->id,
'patient_id' => $patient->id, 'patient_id' => $patient->id,
'invoice_number' => sprintf('DEMO-INV-%s-%04d', Str::upper(Str::substr(md5($ownerRef), 0, 4)), $i + 1), 'invoice_number' => sprintf('DEMO-INV-%s-%04d', Str::upper(Str::substr(md5($ownerRef), 0, 4)), $i + 1),
'status' => $i % 3 === 0 ? Bill::STATUS_PAID : Bill::STATUS_OPEN, 'status' => $status,
'subtotal_minor' => $fee, 'subtotal_minor' => $fee,
'discount_minor' => 0, 'discount_minor' => 0,
'tax_minor' => 0, 'tax_minor' => 0,
'total_minor' => $fee, 'total_minor' => $fee,
'amount_paid_minor' => $i % 3 === 0 ? $fee : 0, 'amount_paid_minor' => $amountPaid,
'balance_minor' => $i % 3 === 0 ? 0 : $fee, 'balance_minor' => $balance,
'created_by' => $ownerRef, 'created_by' => $ownerRef,
'finalized_at' => now()->subDays($i % 12), 'finalized_at' => now()->subDays($i % 12),
'deleted_at' => null, 'deleted_at' => null,
@@ -1806,13 +1826,14 @@ class DemoTenantSeeder
], ],
); );
if ($bill->status === Bill::STATUS_PAID) { $paymentUuid = $this->demoUuid("pay|{$ownerRef}|{$i}");
if ($amountPaid > 0) {
Payment::query()->updateOrCreate( Payment::query()->updateOrCreate(
['uuid' => $this->demoUuid("pay|{$ownerRef}|{$i}")], ['uuid' => $paymentUuid],
[ [
'owner_ref' => $ownerRef, 'owner_ref' => $ownerRef,
'bill_id' => $bill->id, 'bill_id' => $bill->id,
'amount_minor' => $fee, 'amount_minor' => $amountPaid,
'method' => Payment::METHOD_CASH, 'method' => Payment::METHOD_CASH,
'status' => Payment::STATUS_PAID, 'status' => Payment::STATUS_PAID,
'reference' => 'DEMO-PAY-'.($i + 1), 'reference' => 'DEMO-PAY-'.($i + 1),
@@ -1820,6 +1841,8 @@ class DemoTenantSeeder
'recorded_by' => $ownerRef, 'recorded_by' => $ownerRef,
], ],
); );
} else {
Payment::query()->where('uuid', $paymentUuid)->delete();
} }
} }
} }
+27 -17
View File
@@ -3,7 +3,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-xl font-semibold text-slate-900">Bills & invoices</h1> <h1 class="text-xl font-semibold text-slate-900">Bills & invoices</h1>
<p class="mt-1 text-sm text-slate-500">Encounter billing and outstanding balances</p> <p class="mt-1 text-sm text-slate-500">Outstanding balances ready for walk-up payment</p>
</div> </div>
@if (app(\App\Services\Care\CareFeatures::class)->enabled($organization, \App\Services\Care\CareFeatures::WORKFLOW_ENGINE)) @if (app(\App\Services\Care\CareFeatures::class)->enabled($organization, \App\Services\Care\CareFeatures::WORKFLOW_ENGINE))
<a href="{{ route('care.obligations.index') }}" class="btn-primary">Financial gates</a> <a href="{{ route('care.obligations.index') }}" class="btn-primary">Financial gates</a>
@@ -21,27 +21,15 @@
<span class="flex items-center text-sm text-slate-500">{{ $branches->first()->name }}</span> <span class="flex items-center text-sm text-slate-500">{{ $branches->first()->name }}</span>
@endif @endif
<select name="status" class="rounded-lg border-slate-300 text-sm"> <select name="status" class="rounded-lg border-slate-300 text-sm">
<option value="">All statuses</option> <option value="outstanding" @selected(($statusFilter ?? '') === 'outstanding')>Outstanding</option>
<option value="" @selected(($statusFilter ?? '') === '')>All statuses</option>
@foreach ($statuses as $value => $label) @foreach ($statuses as $value => $label)
<option value="{{ $value }}" @selected(request('status') === $value)>{{ $label }}</option> <option value="{{ $value }}" @selected(($statusFilter ?? '') === $value)>{{ $label }}</option>
@endforeach @endforeach
</select> </select>
<button type="submit" class="btn-primary">Filter</button> <button type="submit" class="btn-primary">Filter</button>
</form> </form>
@if (! empty($canManageQueue) && ! empty($queueIntegration['enabled']))
<div class="mt-4">
@include('care.partials.queue-ops', [
'queueIntegration' => $queueIntegration,
'queueCallNextRoute' => 'care.bills.call-next',
'queueCallNextParams' => [],
'queueCallNextDescription' => 'Call the next patient waiting at the billing booth. Unpaid invoices below stay open for walk-up payment.',
'branchId' => $branchId ?? null,
'variant' => 'bar',
])
</div>
@endif
<div class="mt-4 overflow-x-auto rounded-2xl border border-slate-200 bg-white"> <div class="mt-4 overflow-x-auto rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm"> <table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500"> <thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
@@ -101,10 +89,32 @@
</td> </td>
</tr> </tr>
@empty @empty
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No bills yet.</td></tr> <tr>
<td colspan="7" class="px-4 py-8 text-center text-slate-500">
@if (($statusFilter ?? '') === 'outstanding')
No outstanding invoices at this branch. Choose “All statuses” to browse paid bills.
@else
No bills yet.
@endif
</td>
</tr>
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="mt-4">{{ $bills->links() }}</div> <div class="mt-4">{{ $bills->links() }}</div>
@if (! empty($canManageQueue) && ! empty($queueIntegration['enabled']))
<div class="mt-6 border-t border-slate-100 pt-4">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-slate-400">Booth queue (optional)</p>
@include('care.partials.queue-ops', [
'queueIntegration' => $queueIntegration,
'queueCallNextRoute' => 'care.bills.call-next',
'queueCallNextParams' => [],
'queueCallNextDescription' => 'Only when a patient is waiting at billing. Otherwise open an unpaid invoice above.',
'branchId' => $branchId ?? null,
'variant' => 'muted',
])
</div>
@endif
</x-app-layout> </x-app-layout>
@@ -33,6 +33,18 @@
</svg> </svg>
</span> </span>
</button> </button>
@elseif ($variant === 'muted')
<button type="submit" class="group flex w-full items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 text-left transition hover:border-slate-300 hover:bg-slate-50">
<span class="min-w-0">
<span class="block text-sm font-medium text-slate-700">Call next</span>
<span class="mt-0.5 block text-xs text-slate-500">{{ $callNextDescription }}</span>
</span>
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-500 transition group-hover:bg-slate-200">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</span>
</button>
@else @else
<button type="submit" class="btn-primary flex w-full items-center justify-center text-sm">Call next</button> <button type="submit" class="btn-primary flex w-full items-center justify-center text-sm">Call next</button>
@endif @endif
+36 -1
View File
@@ -159,6 +159,41 @@ class CareBillTest extends TestCase
->assertSee('INV-'); ->assertSee('INV-');
} }
public function test_cashier_bills_index_defaults_to_outstanding(): void
{
$this->actingAs($this->user)
->post(route('care.bills.generate', $this->visit));
$open = Bill::firstOrFail();
$paid = Bill::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->visit->branch_id,
'visit_id' => $this->visit->id,
'patient_id' => $this->visit->patient_id,
'invoice_number' => 'INV-PAID-001',
'status' => Bill::STATUS_PAID,
'subtotal_minor' => 1000,
'total_minor' => 1000,
'amount_paid_minor' => 1000,
'balance_minor' => 0,
]);
$this->actingAs($this->user)
->get(route('care.bills.index'))
->assertOk()
->assertSee($open->invoice_number)
->assertSee('Pay')
->assertDontSee($paid->invoice_number, false);
$this->actingAs($this->user)
->get(route('care.bills.index', ['status' => '']))
->assertOk()
->assertSee($paid->invoice_number);
}
public function test_cashier_bills_index_links_each_invoice_for_payment(): void public function test_cashier_bills_index_links_each_invoice_for_payment(): void
{ {
$this->actingAs($this->user) $this->actingAs($this->user)
@@ -191,6 +226,6 @@ class CareBillTest extends TestCase
$this->actingAs($this->user) $this->actingAs($this->user)
->post(route('care.bills.call-next'), ['branch_id' => Branch::firstOrFail()->id]) ->post(route('care.bills.call-next'), ['branch_id' => Branch::firstOrFail()->id])
->assertRedirect() ->assertRedirect()
->assertSessionHas('info', 'No patients waiting at the billing booth. Open an unpaid invoice below to record a walk-up payment.'); ->assertSessionHas('info', 'No patients waiting at the billing booth. Open an unpaid invoice above to record a walk-up payment.');
} }
} }
+22
View File
@@ -377,6 +377,28 @@ class DemoSeedCommandTest extends TestCase
$this->assertNotNull($waiter->practitioner_id, 'Demo waiters must all have a doctor assigned'); $this->assertNotNull($waiter->practitioner_id, 'Demo waiters must all have a doctor assigned');
$this->assertNotSame('unresolved', $waiter->queue_routing_status); $this->assertNotSame('unresolved', $waiter->queue_routing_status);
} }
$branches = Branch::query()->where('organization_id', $org->id)->orderBy('id')->get();
$this->assertGreaterThanOrEqual(3, $branches->count());
foreach ($branches as $branch) {
$unpaid = Bill::query()
->where('organization_id', $org->id)
->where('branch_id', $branch->id)
->where('balance_minor', '>', 0)
->count();
$this->assertGreaterThan(
0,
$unpaid,
"Branch {$branch->name} must have unpaid demo invoices for cashiers",
);
}
$cashier = \App\Models\Member::query()
->where('organization_id', $org->id)
->where('role', 'cashier')
->first();
$this->assertNotNull($cashier);
$this->assertSame((int) $branches->first()->id, (int) $cashier->branch_id);
} }
public function test_specialty_doctor_links_after_sso_public_id_remap(): void public function test_specialty_doctor_links_after_sso_public_id_remap(): void