withoutMiddleware(EnsurePlatformSession::class); $this->owner = User::create([ 'public_id' => 'financial-workflow-owner', 'name' => 'Workflow Admin', 'email' => 'financial-workflow@example.com', ]); $this->organization = Organization::create([ 'owner_ref' => $this->owner->public_id, 'name' => 'Financial Gate Hospital', 'slug' => 'financial-gate-hospital', 'settings' => [ 'onboarded' => true, 'facility_category' => 'hospital', 'plan' => 'pro', 'plan_expires_at' => now()->addMonth()->toIso8601String(), 'queue_integration_enabled' => true, 'rollout' => [ CareFeatures::WORKFLOW_ENGINE => true, CareFeatures::FINANCIAL_GATES => true, ], ], ]); Member::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'user_ref' => $this->owner->public_id, 'role' => 'hospital_admin', ]); $this->branch = Branch::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'Main', 'is_active' => true, ]); $this->patient = Patient::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_number' => 'WF-0001', 'first_name' => 'Akosua', 'last_name' => 'Boateng', ]); app(WorkflowTemplateInstaller::class) ->install($this->organization, $this->branch, 'public_hospital_gh'); } protected function checkIn(): Visit { return app(VisitService::class)->checkIn( $this->organization, $this->owner->public_id, $this->patient, $this->branch->id, $this->owner->public_id, ); } protected function reachConsultation(Visit $visit): FinancialObligation { $engine = app(WorkflowEngine::class); $gate = app(FinancialGateService::class); $registration = FinancialObligation::query() ->where('visit_id', $visit->id) ->where('stage_code', 'registration') ->firstOrFail(); $gate->clear($registration, 'override', $this->owner->public_id); $engine->refreshGate($visit); $engine->advance($visit); // triage $engine->advance($visit); // consultation return FinancialObligation::query() ->where('visit_id', $visit->id) ->where('stage_code', 'consultation') ->firstOrFail(); } public function test_check_in_starts_workflow_and_blocks_initial_queue_release(): void { $visit = $this->checkIn(); $advance = app(WorkflowEngine::class)->currentAdvance($visit); $this->assertSame('registration', $advance->stage_code); $this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status); $this->assertDatabaseHas('care_financial_obligations', [ 'visit_id' => $visit->id, 'stage_code' => 'registration', 'status' => FinancialObligation::STATUS_PENDING, ]); $this->assertFalse(app(WorkflowQueueGate::class)->canRelease( $this->organization, $visit, CareQueueContexts::CONSULTATION, )); } public function test_queue_bridge_cannot_backfill_a_gated_appointment(): void { Http::fake(); $visit = $this->checkIn(); $appointment = Appointment::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $this->patient->id, 'visit_id' => $visit->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_WAITING, 'scheduled_at' => now(), 'checked_in_at' => now(), 'waiting_at' => now(), 'queue_position' => 1, ]); app(CareQueueBridge::class)->issueForAppointment($this->organization, $appointment); Http::assertNothingSent(); $this->assertNull($appointment->fresh()->queue_ticket_uuid); } public function test_appointment_page_shows_current_workflow_stage(): void { $visit = $this->checkIn(); $appointment = Appointment::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $this->patient->id, 'visit_id' => $visit->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_WAITING, 'scheduled_at' => now(), 'checked_in_at' => now(), 'waiting_at' => now(), 'queue_position' => 1, ]); $this->actingAs($this->owner) ->get(route('care.appointments.show', $appointment)) ->assertOk() ->assertSee('Patient journey') ->assertSee('Registration') ->assertSee('financial clearance required') ->assertDontSee('Start consultation'); } public function test_blocked_visit_cannot_advance_until_current_gate_is_cleared(): void { $visit = $this->checkIn(); $this->actingAs($this->owner) ->from(route('care.appointments.index')) ->post(route('care.visits.workflow.advance', $visit)) ->assertRedirect(route('care.appointments.index')) ->assertSessionHas('error'); $this->assertSame('registration', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code); } public function test_cashier_clearance_creates_bill_payment_and_releases_stage(): void { $visit = $this->checkIn(); $consultation = $this->reachConsultation($visit); $this->actingAs($this->owner) ->get(route('care.obligations.index')) ->assertOk() ->assertSee('Financial gates') ->assertSee('Akosua Boateng') ->assertSee('Consultation fee'); $this->actingAs($this->owner) ->post(route('care.obligations.clear', $consultation), [ 'method' => 'payment', 'payment_mode' => 'internal_cashier', 'payment_method' => 'cash', 'reference' => 'CASH-1001', ]) ->assertRedirect() ->assertSessionHas('success'); $consultation->refresh(); $this->assertSame(FinancialObligation::STATUS_PAID, $consultation->status); $this->assertNotNull($consultation->bill_id); $this->assertDatabaseHas('care_bill_line_items', [ 'bill_id' => $consultation->bill_id, 'source_type' => FinancialObligation::class, 'source_id' => $consultation->id, 'total_minor' => (int) config('care.billing.consultation_fee_minor'), ]); $this->assertDatabaseHas('care_payments', [ 'bill_id' => $consultation->bill_id, 'method' => Payment::METHOD_CASH, 'status' => Payment::STATUS_PAID, 'reference' => 'CASH-1001', ]); $this->assertSame(Bill::STATUS_PAID, $consultation->bill->fresh()->status); $this->assertSame(VisitStageAdvance::STATUS_ACTIVE, app(WorkflowEngine::class)->currentAdvance($visit)->status); $this->assertTrue(app(WorkflowQueueGate::class)->canRelease( $this->organization, $visit, CareQueueContexts::CONSULTATION, )); // A repeated submission must not create a second financial payment. $this->actingAs($this->owner) ->post(route('care.obligations.clear', $consultation), [ 'method' => 'payment', 'payment_mode' => 'internal_cashier', 'payment_method' => 'cash', ]) ->assertSessionHas('info'); $this->assertSame(1, Payment::query()->where('bill_id', $consultation->bill_id)->count()); } public function test_clinical_stage_cannot_be_manually_skipped(): void { $visit = $this->checkIn(); $consultation = $this->reachConsultation($visit); app(FinancialGateService::class)->clear($consultation, 'insurance', $this->owner->public_id); app(WorkflowEngine::class)->refreshGate($visit); $this->actingAs($this->owner) ->post(route('care.visits.workflow.advance', $visit)) ->assertSessionHas('error'); $this->assertSame('consultation', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code); } public function test_non_insurance_stage_rejects_insurance_clearance(): void { app(WorkflowTemplateInstaller::class) ->install($this->organization, $this->branch, 'herbal_hospital'); $visit = $this->checkIn(); $obligation = FinancialObligation::query()->where('visit_id', $visit->id)->firstOrFail(); $this->actingAs($this->owner) ->post(route('care.obligations.clear', $obligation), [ 'method' => 'insurance', ]) ->assertSessionHasErrors('method'); $this->assertSame(FinancialObligation::STATUS_PENDING, $obligation->fresh()->status); } public function test_lab_stage_obligation_uses_requested_test_prices(): void { $visit = $this->checkIn(); $consultation = $this->reachConsultation($visit); app(FinancialGateService::class)->clear($consultation, 'insurance', $this->owner->public_id); app(WorkflowEngine::class)->refreshGate($visit); $type = InvestigationType::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'Full blood count', 'code' => 'FBC', 'category' => 'blood', 'price_minor' => 3500, 'is_active' => true, ]); InvestigationRequest::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'visit_id' => $visit->id, 'patient_id' => $this->patient->id, 'investigation_type_id' => $type->id, 'status' => InvestigationRequest::STATUS_PENDING, 'priority' => 'routine', ]); $advance = app(WorkflowEngine::class)->advance($visit, 'laboratory'); $this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status); $this->assertDatabaseHas('care_financial_obligations', [ 'visit_id' => $visit->id, 'stage_code' => 'laboratory', 'amount_minor' => 3500, 'status' => FinancialObligation::STATUS_PENDING, ]); } public function test_imaging_requests_use_the_imaging_queue_context(): void { $visit = $this->checkIn(); $type = InvestigationType::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'Chest X-ray', 'code' => 'CXR', 'category' => 'xray', 'price_minor' => 8000, 'is_active' => true, ]); $request = InvestigationRequest::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'visit_id' => $visit->id, 'patient_id' => $this->patient->id, 'investigation_type_id' => $type->id, 'status' => InvestigationRequest::STATUS_PENDING, 'priority' => 'routine', ]); $this->assertSame( CareQueueContexts::IMAGING, app(CareQueueBridge::class)->contextForInvestigation($request), ); } public function test_settings_exposes_workflow_controls_and_herbal_categories(): void { $this->actingAs($this->owner) ->get(route('care.settings')) ->assertOk() ->assertSee('Patient journey workflow') ->assertSee('Enforce payment and authorization gates') ->assertSee('Herbal hospital') ->assertSee('Herbal clinic'); } public function test_completing_consultation_appointment_advances_instead_of_closing_visit(): void { Http::fake(); $visit = $this->checkIn(); $consultationObligation = $this->reachConsultation($visit); app(FinancialGateService::class)->clear($consultationObligation, 'payment', $this->owner->public_id); app(WorkflowEngine::class)->refreshGate($visit); $appointment = Appointment::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $this->patient->id, 'visit_id' => $visit->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_IN_CONSULTATION, 'scheduled_at' => now(), 'checked_in_at' => now(), 'waiting_at' => now(), 'started_at' => now(), 'queue_position' => 1, ]); app(AppointmentService::class)->complete( $appointment, $this->owner->public_id, $this->owner->public_id, ); $this->assertSame(Appointment::STATUS_COMPLETED, $appointment->fresh()->status); $this->assertNotSame(Visit::STATUS_COMPLETED, $visit->fresh()->status); $this->assertSame('pharmacy', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code); } public function test_completed_lab_stage_returns_patient_to_a_new_consultation_queue(): void { Http::fake(); $visit = $this->checkIn(); $consultation = $this->reachConsultation($visit); app(FinancialGateService::class)->clear($consultation, 'insurance', $this->owner->public_id); app(WorkflowEngine::class)->refreshGate($visit); $appointment = Appointment::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $this->patient->id, 'visit_id' => $visit->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_COMPLETED, 'scheduled_at' => now(), 'checked_in_at' => now(), 'completed_at' => now(), 'queue_position' => 1, ]); $type = InvestigationType::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'name' => 'Malaria test', 'code' => 'MAL', 'category' => 'blood', 'price_minor' => 3000, 'is_active' => true, ]); InvestigationRequest::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'visit_id' => $visit->id, 'patient_id' => $this->patient->id, 'investigation_type_id' => $type->id, 'status' => InvestigationRequest::STATUS_COMPLETED, 'priority' => 'routine', ]); $labAdvance = app(WorkflowEngine::class)->advance($visit, 'laboratory'); $labObligation = FinancialObligation::query() ->where('workflow_stage_id', $labAdvance->workflow_stage_id) ->where('visit_id', $visit->id) ->firstOrFail(); app(FinancialGateService::class)->clear($labObligation, 'insurance', $this->owner->public_id); app(WorkflowEngine::class)->refreshGate($visit); app(WorkflowStageCompletionService::class)->complete( $this->organization, $visit, CareQueueContexts::LABORATORY, $this->owner->public_id, $this->owner->public_id, ); $this->assertSame('consultation', app(WorkflowEngine::class)->currentAdvance($visit)->stage_code); $this->assertSame(Appointment::STATUS_WAITING, $appointment->fresh()->status); } public function test_disabled_workflow_keeps_legacy_check_in_behavior(): void { $settings = $this->organization->settings; data_set($settings, 'rollout.'.CareFeatures::WORKFLOW_ENGINE, false); data_set($settings, 'rollout.'.CareFeatures::FINANCIAL_GATES, false); $this->organization->update(['settings' => $settings]); $visit = $this->checkIn(); $this->assertDatabaseMissing('care_visit_stage_advances', ['visit_id' => $visit->id]); $this->assertTrue(app(WorkflowQueueGate::class)->canRelease( $this->organization->fresh(), $visit, CareQueueContexts::CONSULTATION, )); } public function test_enabling_financial_gates_rechecks_an_active_visit_stage(): void { $settings = $this->organization->settings; data_set($settings, 'rollout.'.CareFeatures::FINANCIAL_GATES, false); $this->organization->update(['settings' => $settings]); $visit = $this->checkIn(); $this->assertSame(VisitStageAdvance::STATUS_ACTIVE, app(WorkflowEngine::class)->currentAdvance($visit)->status); $this->assertDatabaseMissing('care_financial_obligations', ['visit_id' => $visit->id]); data_set($settings, 'rollout.'.CareFeatures::FINANCIAL_GATES, true); $this->organization->update(['settings' => $settings]); $advance = app(WorkflowEngine::class)->refreshGate($visit); $this->assertSame(VisitStageAdvance::STATUS_BLOCKED, $advance->status); $this->assertDatabaseHas('care_financial_obligations', [ 'visit_id' => $visit->id, 'stage_code' => 'registration', 'status' => FinancialObligation::STATUS_PENDING, ]); } public function test_awaiting_payment_issues_a_billing_cashier_ticket(): void { Http::fake(); $visit = $this->checkIn(); $obligation = FinancialObligation::query() ->where('visit_id', $visit->id) ->where('stage_code', 'registration') ->firstOrFail(); $this->assertNotNull($obligation->bill_id); $bill = $obligation->bill()->first(); $this->assertNotNull($bill); $this->assertNotNull($bill->queue_ticket_uuid); $this->assertSame('waiting', $bill->queue_ticket_status); $this->assertStringStartsWith('B', (string) $bill->queue_ticket_number); $this->assertDatabaseHas('care_queue_tickets', [ 'uuid' => $bill->queue_ticket_uuid, 'ticket_number' => $bill->queue_ticket_number, 'status' => 'waiting', 'care_entity' => 'bill', 'care_entity_id' => $bill->id, ]); $this->assertDatabaseHas('care_service_queues', [ 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'context' => CareQueueContexts::BILLING, ]); $this->assertTrue(app(WorkflowQueueGate::class)->canRelease( $this->organization, $visit, CareQueueContexts::BILLING, )); $this->assertFalse(app(WorkflowQueueGate::class)->canRelease( $this->organization, $visit, CareQueueContexts::CONSULTATION, )); Http::assertNothingSent(); } public function test_queue_off_keeps_financial_gate_without_cashier_ticket(): void { $settings = $this->organization->settings; $settings['queue_integration_enabled'] = false; $this->organization->update(['settings' => $settings]); $visit = $this->checkIn(); $obligation = FinancialObligation::query() ->where('visit_id', $visit->id) ->where('stage_code', 'registration') ->firstOrFail(); $this->assertSame(FinancialObligation::STATUS_PENDING, $obligation->status); $this->assertNull($obligation->bill_id); $this->assertSame(0, Bill::query()->where('visit_id', $visit->id)->count()); $this->assertSame(VisitStageAdvance::STATUS_BLOCKED, app(WorkflowEngine::class)->currentAdvance($visit)->status); } public function test_clearing_gate_completes_cashier_ticket_and_releases_clinical_queue(): void { Http::fake(); $visit = $this->checkIn(); $consultation = $this->reachConsultation($visit); $consultation->refresh(); $this->assertNotNull($consultation->bill_id); $bill = $consultation->bill()->firstOrFail(); $this->assertNotNull($bill->queue_ticket_uuid); $ticketUuid = $bill->queue_ticket_uuid; $appointment = Appointment::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_id' => $this->patient->id, 'visit_id' => $visit->id, 'type' => Appointment::TYPE_WALK_IN, 'status' => Appointment::STATUS_WAITING, 'scheduled_at' => now(), 'checked_in_at' => now(), 'waiting_at' => now(), 'queue_position' => 1, ]); $this->actingAs($this->owner) ->post(route('care.obligations.clear', $consultation), [ 'method' => 'payment', 'payment_mode' => 'internal_cashier', 'payment_method' => 'cash', 'reference' => 'CASH-QUEUE-1', ]) ->assertRedirect() ->assertSessionHas('success'); $bill->refresh(); $this->assertSame('completed', $bill->queue_ticket_status); $this->assertDatabaseHas('care_queue_tickets', [ 'uuid' => $ticketUuid, 'status' => 'completed', ]); $this->assertSame(VisitStageAdvance::STATUS_ACTIVE, app(WorkflowEngine::class)->currentAdvance($visit)->status); $this->assertTrue(app(WorkflowQueueGate::class)->canRelease( $this->organization, $visit, CareQueueContexts::CONSULTATION, )); $appointment->refresh(); $this->assertNotNull($appointment->queue_ticket_uuid); $this->assertSame('waiting', $appointment->queue_ticket_status); $this->assertStringStartsWith('C', (string) $appointment->queue_ticket_number); } public function test_cashier_can_call_next_and_serve_billing_ticket(): void { Http::fake(); Member::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'user_ref' => 'cashier-queue-user', 'role' => 'cashier', 'branch_id' => $this->branch->id, ]); $cashier = User::create([ 'public_id' => 'cashier-queue-user', 'name' => 'Cashier', 'email' => 'cashier-queue@example.com', ]); $visit = $this->checkIn(); $obligation = FinancialObligation::query() ->where('visit_id', $visit->id) ->where('stage_code', 'registration') ->firstOrFail(); $bill = $obligation->bill()->firstOrFail(); $this->assertNotNull($bill->queue_ticket_uuid); $this->actingAs($cashier) ->post(route('care.bills.call-next'), ['branch_id' => $this->branch->id]) ->assertRedirect() ->assertSessionHas('success'); $bill->refresh(); $this->assertSame('called', $bill->queue_ticket_status); $this->actingAs($cashier) ->post(route('care.bills.serve', $bill)) ->assertRedirect() ->assertSessionHas('success'); $this->assertSame('serving', $bill->fresh()->queue_ticket_status); } }