Activate Care workflows across check-in and financial clearance.
Deploy Ladill Care / deploy (push) Failing after 1m9s

Enforce service-queue gates, cashier settlements, and clinical stage progression so patient journeys cannot bypass configured payment or authorization rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 21:13:06 +00:00
co-authored by Cursor
parent 86bfce1e17
commit 3ee59a0956
38 changed files with 1953 additions and 107 deletions
@@ -0,0 +1,514 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\Branch;
use App\Models\FinancialObligation;
use App\Models\InvestigationRequest;
use App\Models\InvestigationType;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Payment;
use App\Models\User;
use App\Models\Visit;
use App\Models\VisitStageAdvance;
use App\Services\Care\AppointmentService;
use App\Services\Care\CareFeatures;
use App\Services\Care\CareQueueBridge;
use App\Services\Care\CareQueueContexts;
use App\Services\Care\VisitService;
use App\Services\Care\Workflow\FinancialGateService;
use App\Services\Care\Workflow\WorkflowEngine;
use App\Services\Care\Workflow\WorkflowQueueGate;
use App\Services\Care\Workflow\WorkflowStageCompletionService;
use App\Services\Care\Workflow\WorkflowTemplateInstaller;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CareFinancialWorkflowRuntimeTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
config([
'care.queue.api_url' => 'https://queue.test/api/v1',
'care.queue.api_key' => 'workflow-test-key',
]);
$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,
]);
}
}