diff --git a/app/Http/Controllers/Care/BillController.php b/app/Http/Controllers/Care/BillController.php index 460ef39..0defcc5 100644 --- a/app/Http/Controllers/Care/BillController.php +++ b/app/Http/Controllers/Care/BillController.php @@ -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( $this->ownerRef($request), $organization->id, - $request->only(['status', 'patient_id']), + $filters, $branchId > 0 ? $branchId : null, ); @@ -66,6 +73,7 @@ class BillController extends Controller 'organization' => $organization, 'bills' => $bills, 'statuses' => config('care.bill_statuses'), + 'statusFilter' => $filters['status'] ?? '', 'branches' => $branches, 'branchId' => $branchId, 'canSwitchBranch' => $this->branchContext->canSwitch($member), @@ -91,7 +99,7 @@ class BillController extends Controller if (! $ticket) { return back()->with( '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.', ); } diff --git a/app/Services/Care/BillService.php b/app/Services/Care/BillService.php index 81e325e..5ba6d01 100644 --- a/app/Services/Care/BillService.php +++ b/app/Services/Care/BillService.php @@ -46,7 +46,11 @@ class BillService $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); } diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index d0b7495..0d4b213 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -692,7 +692,10 @@ class DemoTenantSeeder $branch = $hq; 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']); @@ -1750,10 +1753,27 @@ class DemoTenantSeeder ); } + $branchCount = max(1, count($branches)); for ($i = 0; $i < $volumes['bills']; $i++) { $patient = $patients[$i % count($patients)]; - $branch = $branches[$i % count($branches)]; + $branch = $branches[$i % $branchCount]; $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( ['uuid' => $this->demoUuid("bill-visit|{$ownerRef}|{$i}")], @@ -1779,13 +1799,13 @@ class DemoTenantSeeder 'visit_id' => $visit->id, 'patient_id' => $patient->id, '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, 'discount_minor' => 0, 'tax_minor' => 0, 'total_minor' => $fee, - 'amount_paid_minor' => $i % 3 === 0 ? $fee : 0, - 'balance_minor' => $i % 3 === 0 ? 0 : $fee, + 'amount_paid_minor' => $amountPaid, + 'balance_minor' => $balance, 'created_by' => $ownerRef, 'finalized_at' => now()->subDays($i % 12), '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( - ['uuid' => $this->demoUuid("pay|{$ownerRef}|{$i}")], + ['uuid' => $paymentUuid], [ 'owner_ref' => $ownerRef, 'bill_id' => $bill->id, - 'amount_minor' => $fee, + 'amount_minor' => $amountPaid, 'method' => Payment::METHOD_CASH, 'status' => Payment::STATUS_PAID, 'reference' => 'DEMO-PAY-'.($i + 1), @@ -1820,6 +1841,8 @@ class DemoTenantSeeder 'recorded_by' => $ownerRef, ], ); + } else { + Payment::query()->where('uuid', $paymentUuid)->delete(); } } } diff --git a/resources/views/care/bills/index.blade.php b/resources/views/care/bills/index.blade.php index 8f434b7..6449192 100644 --- a/resources/views/care/bills/index.blade.php +++ b/resources/views/care/bills/index.blade.php @@ -3,7 +3,7 @@

Bills & invoices

-

Encounter billing and outstanding balances

+

Outstanding balances ready for walk-up payment

@if (app(\App\Services\Care\CareFeatures::class)->enabled($organization, \App\Services\Care\CareFeatures::WORKFLOW_ENGINE)) Financial gates @@ -21,27 +21,15 @@ {{ $branches->first()->name }} @endif - @if (! empty($canManageQueue) && ! empty($queueIntegration['enabled'])) -
- @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', - ]) -
- @endif -
@@ -101,10 +89,32 @@ @empty - + + + @endforelse
No bills yet.
+ @if (($statusFilter ?? '') === 'outstanding') + No outstanding invoices at this branch. Choose “All statuses” to browse paid bills. + @else + No bills yet. + @endif +
{{ $bills->links() }}
+ + @if (! empty($canManageQueue) && ! empty($queueIntegration['enabled'])) +
+

Booth queue (optional)

+ @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', + ]) +
+ @endif diff --git a/resources/views/care/partials/queue-ops.blade.php b/resources/views/care/partials/queue-ops.blade.php index 84af63c..086f806 100644 --- a/resources/views/care/partials/queue-ops.blade.php +++ b/resources/views/care/partials/queue-ops.blade.php @@ -33,6 +33,18 @@ + @elseif ($variant === 'muted') + @else @endif diff --git a/tests/Feature/CareBillTest.php b/tests/Feature/CareBillTest.php index 322f1ef..09368d0 100644 --- a/tests/Feature/CareBillTest.php +++ b/tests/Feature/CareBillTest.php @@ -159,6 +159,41 @@ class CareBillTest extends TestCase ->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 { $this->actingAs($this->user) @@ -191,6 +226,6 @@ class CareBillTest extends TestCase $this->actingAs($this->user) ->post(route('care.bills.call-next'), ['branch_id' => Branch::firstOrFail()->id]) ->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.'); } } diff --git a/tests/Feature/DemoSeedCommandTest.php b/tests/Feature/DemoSeedCommandTest.php index 2580f09..2d388d0 100644 --- a/tests/Feature/DemoSeedCommandTest.php +++ b/tests/Feature/DemoSeedCommandTest.php @@ -377,6 +377,28 @@ class DemoSeedCommandTest extends TestCase $this->assertNotNull($waiter->practitioner_id, 'Demo waiters must all have a doctor assigned'); $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