Add workflow-centric patient journey with financial gates.
Deploy Ladill Care / deploy (push) Failing after 45s

Introduces a facility workflow engine as Care's primary configuration
object: onboarding now leads with a facility category (which modules to
enable) and a workflow template (how patients move + where money is
collected), including standard herbal hospital/clinic templates.

Templates materialize into care_facility_workflows/care_workflow_stages.
The engine tracks a visit's position via care_visit_stage_advances and,
with FinancialGateService, gates a stage's queue behind a cleared
FinancialObligation (paid/authorized/waived/deferred) for "before"
payment timing while leaving "after" (pay-at-exit) flows unblocked.
Gated behavior is opt-in behind the workflow_engine/financial_gates
rollout flags; legacy orgs keep current behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 20:47:33 +00:00
co-authored by Cursor
parent c319692b33
commit 86bfce1e17
16 changed files with 1564 additions and 27 deletions
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\Workflow\WorkflowTemplateRegistry;
use App\Support\OrganizationBranding;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -16,6 +17,7 @@ class OnboardingController extends Controller
public function __construct(
protected OrganizationResolver $organizations,
protected WorkflowTemplateRegistry $workflows,
) {}
public function show(Request $request): View|RedirectResponse
@@ -27,12 +29,9 @@ class OnboardingController extends Controller
return view('care.onboarding.show', [
'user' => $request->user(),
'timezones' => timezone_identifiers_list(),
'facilityTypes' => [
'clinic' => 'Clinic',
'hospital' => 'Hospital',
'diagnostic' => 'Diagnostic laboratory',
'specialist' => 'Specialist practice',
],
'categories' => $this->workflows->categories(),
'templates' => $this->workflows->templates(),
'defaultTemplate' => $this->workflows->defaultTemplateKey(),
]);
}
@@ -42,9 +41,13 @@ class OnboardingController extends Controller
return redirect()->route('care.dashboard');
}
$categoryKeys = array_keys($this->workflows->categories());
$templateKeys = array_keys($this->workflows->templates());
$validated = $request->validate([
'organization_name' => ['required', 'string', 'max:255'],
'facility_type' => ['required', 'string', 'in:clinic,hospital,diagnostic,specialist'],
'facility_category' => ['required', 'string', 'in:'.implode(',', $categoryKeys)],
'workflow_template' => ['required', 'string', 'in:'.implode(',', $templateKeys)],
'branch_name' => ['required', 'string', 'max:255'],
'branch_address' => ['nullable', 'string', 'max:500'],
'branch_phone' => ['nullable', 'string', 'max:50'],
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class FacilityWorkflow extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_facility_workflows';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'name',
'template_key', 'category', 'is_active', 'settings',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'settings' => 'array',
];
}
protected static function booted(): void
{
static::creating(function (FacilityWorkflow $workflow) {
if (! $workflow->uuid) {
$workflow->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function stages(): HasMany
{
return $this->hasMany(WorkflowStage::class, 'workflow_id')->orderBy('sort_order');
}
public function firstStage(): ?WorkflowStage
{
return $this->stages()->first();
}
public function stage(string $code): ?WorkflowStage
{
return $this->stages()->where('code', $code)->first();
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class FinancialObligation extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_PENDING = 'pending';
public const STATUS_AUTHORIZED = 'authorized';
public const STATUS_PAID = 'paid';
public const STATUS_WAIVED = 'waived';
public const STATUS_DEFERRED = 'deferred';
public const STATUS_VOID = 'void';
/** Statuses that satisfy a financial gate. */
public const CLEARED_STATUSES = [
self::STATUS_AUTHORIZED,
self::STATUS_PAID,
self::STATUS_WAIVED,
self::STATUS_DEFERRED,
];
protected $table = 'care_financial_obligations';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'visit_id', 'patient_id',
'workflow_stage_id', 'bill_id', 'stage_code', 'charge_code', 'label',
'amount_minor', 'status', 'clearance_method', 'payment_mode',
'external_reference', 'cleared_by', 'cleared_at', 'meta',
];
protected function casts(): array
{
return [
'amount_minor' => 'integer',
'cleared_at' => 'datetime',
'meta' => 'array',
];
}
protected static function booted(): void
{
static::creating(function (FinancialObligation $obligation) {
if (! $obligation->uuid) {
$obligation->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function stage(): BelongsTo
{
return $this->belongsTo(WorkflowStage::class, 'workflow_stage_id');
}
public function bill(): BelongsTo
{
return $this->belongsTo(Bill::class, 'bill_id');
}
public function isCleared(): bool
{
return in_array($this->status, self::CLEARED_STATUSES, true);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VisitStageAdvance extends Model
{
use BelongsToOwner;
public const STATUS_ACTIVE = 'active';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_COMPLETED = 'completed';
public const STATUS_SKIPPED = 'skipped';
protected $table = 'care_visit_stage_advances';
protected $fillable = [
'owner_ref', 'visit_id', 'workflow_id', 'workflow_stage_id', 'stage_code',
'status', 'blocked_reason', 'entered_at', 'cleared_at', 'completed_at', 'meta',
];
protected function casts(): array
{
return [
'entered_at' => 'datetime',
'cleared_at' => 'datetime',
'completed_at' => 'datetime',
'meta' => 'array',
];
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function workflow(): BelongsTo
{
return $this->belongsTo(FacilityWorkflow::class, 'workflow_id');
}
public function stage(): BelongsTo
{
return $this->belongsTo(WorkflowStage::class, 'workflow_stage_id');
}
public function isBlocked(): bool
{
return $this->status === self::STATUS_BLOCKED;
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class WorkflowStage extends Model
{
use BelongsToOwner;
public const TIMING_BEFORE = 'before';
public const TIMING_AFTER = 'after';
public const TIMING_NONE = 'none';
protected $table = 'care_workflow_stages';
protected $fillable = [
'uuid', 'owner_ref', 'workflow_id', 'code', 'name', 'type',
'queue_context', 'department_type', 'sort_order',
'requires_payment', 'payment_timing', 'payment_modes',
'insurance_eligible', 'credit_allowed', 'allow_override',
'charge_code', 'charge_label', 'default_amount_minor',
'default_next', 'can_return', 'can_skip', 'optional', 'creates_child', 'meta',
];
protected function casts(): array
{
return [
'sort_order' => 'integer',
'requires_payment' => 'boolean',
'payment_modes' => 'array',
'insurance_eligible' => 'boolean',
'credit_allowed' => 'boolean',
'allow_override' => 'boolean',
'default_amount_minor' => 'integer',
'can_return' => 'boolean',
'can_skip' => 'boolean',
'optional' => 'boolean',
'creates_child' => 'boolean',
'meta' => 'array',
];
}
protected static function booted(): void
{
static::creating(function (WorkflowStage $stage) {
if (! $stage->uuid) {
$stage->uuid = (string) Str::uuid();
}
});
}
public function workflow(): BelongsTo
{
return $this->belongsTo(FacilityWorkflow::class, 'workflow_id');
}
/** Whether entering this stage's service queue is gated on a cleared obligation. */
public function gatesBeforeService(): bool
{
return $this->requires_payment && $this->payment_timing === self::TIMING_BEFORE;
}
public function collectsAfterService(): bool
{
return $this->requires_payment && $this->payment_timing === self::TIMING_AFTER;
}
}
+6
View File
@@ -21,6 +21,12 @@ class CareFeatures
public const ASSESSMENT_REQUIRED_ON_COMPLETE = 'assessment_required_on_complete';
/** Workflow-centric patient journey + financial gates (opt-in). */
public const WORKFLOW_ENGINE = 'workflow_engine';
/** Enforce financial gates (block gated stages until obligations clear). */
public const FINANCIAL_GATES = 'financial_gates';
/**
* Flags that default ON when unset (opt-out). Others default OFF (opt-in).
*
+31 -1
View File
@@ -7,6 +7,8 @@ use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Services\Care\Workflow\WorkflowTemplateInstaller;
use App\Services\Care\Workflow\WorkflowTemplateRegistry;
use Illuminate\Support\Str;
class OrganizationResolver
@@ -73,6 +75,15 @@ class OrganizationResolver
{
$ref = $user->ownerRef();
$registry = app(WorkflowTemplateRegistry::class);
// New onboarding leads with Facility Category (modules) + Workflow
// Template (patient flow); legacy callers may still pass facility_type.
$category = $data['facility_category'] ?? $this->categoryFromLegacyType($data['facility_type'] ?? null);
$templateKey = $data['workflow_template']
?? $registry->defaultTemplateForCategory($category);
$modules = $registry->modulesForCategory($category);
$organization = Organization::create([
'owner_ref' => $ref,
'name' => $data['organization_name'],
@@ -80,7 +91,10 @@ class OrganizationResolver
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
'settings' => [
'onboarded' => true,
'facility_type' => $data['facility_type'] ?? 'clinic',
'facility_category' => $category,
'facility_type' => $data['facility_type'] ?? $category,
'modules' => $modules,
'workflow_template' => $templateKey,
],
]);
@@ -103,12 +117,28 @@ class OrganizationResolver
'is_active' => true,
]);
if ($registry->hasTemplate($templateKey)) {
app(WorkflowTemplateInstaller::class)->install($organization, $branch, $templateKey, [
'category' => $category,
]);
}
AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id);
AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id);
return $organization;
}
protected function categoryFromLegacyType(?string $type): string
{
return match ($type) {
'hospital' => 'hospital',
'diagnostic' => 'diagnostic',
'specialist' => 'specialist',
default => 'clinic',
};
}
/** Branch ID the member may access; null = all branches. */
public function branchScope(?Member $member): ?int
{
@@ -0,0 +1,128 @@
<?php
namespace App\Services\Care\Workflow;
use App\Models\FinancialObligation;
use App\Models\Visit;
use App\Models\WorkflowStage;
/**
* Owns FinancialObligation lifecycle and answers whether a stage's financial
* gate is satisfied for a given visit.
*
* A "before" gate blocks entry to the stage's service queue until the stage's
* obligation is cleared (paid / authorized / waived / deferred). An "after"
* gate never blocks entry but is created so the balance is collected at exit.
*/
class FinancialGateService
{
/**
* Create (or return existing) obligation for a visit + stage. Only stages
* that require payment produce an obligation.
*
* @param array<string, mixed> $overrides
*/
public function obligationFor(Visit $visit, WorkflowStage $stage, array $overrides = []): ?FinancialObligation
{
if (! $stage->requires_payment) {
return null;
}
$existing = FinancialObligation::query()
->where('visit_id', $visit->id)
->where('workflow_stage_id', $stage->id)
->whereNull('deleted_at')
->first();
if ($existing !== null) {
return $existing;
}
return FinancialObligation::create(array_merge([
'owner_ref' => $visit->owner_ref,
'organization_id' => $visit->organization_id,
'branch_id' => $visit->branch_id,
'visit_id' => $visit->id,
'patient_id' => $visit->patient_id,
'workflow_stage_id' => $stage->id,
'stage_code' => $stage->code,
'charge_code' => $stage->charge_code,
'label' => $stage->charge_label ?? $stage->name,
'amount_minor' => $stage->default_amount_minor ?? $this->defaultAmountFor($stage),
'status' => FinancialObligation::STATUS_PENDING,
], $overrides));
}
/**
* Whether the visit may enter the stage. Stages with a "before" payment
* gate require a cleared obligation; everything else is allowed.
*/
public function isSatisfied(Visit $visit, WorkflowStage $stage): bool
{
if (! $stage->gatesBeforeService()) {
return true;
}
$obligation = FinancialObligation::query()
->where('visit_id', $visit->id)
->where('workflow_stage_id', $stage->id)
->whereNull('deleted_at')
->first();
return $obligation !== null && $obligation->isCleared();
}
public function blockedReason(Visit $visit, WorkflowStage $stage): ?string
{
if ($this->isSatisfied($visit, $stage)) {
return null;
}
return 'awaiting_payment';
}
/**
* Clear an obligation (paid / insurance authorized / waived / credit).
*/
public function clear(
FinancialObligation $obligation,
string $method,
?string $actorRef = null,
?string $paymentMode = null,
?string $externalReference = null,
): FinancialObligation {
$status = match ($method) {
'insurance' => FinancialObligation::STATUS_AUTHORIZED,
'waiver' => FinancialObligation::STATUS_WAIVED,
'credit' => FinancialObligation::STATUS_DEFERRED,
default => FinancialObligation::STATUS_PAID,
};
$obligation->forceFill([
'status' => $status,
'clearance_method' => $method,
'payment_mode' => $paymentMode,
'external_reference' => $externalReference,
'cleared_by' => $actorRef,
'cleared_at' => now(),
])->save();
return $obligation;
}
public function void(FinancialObligation $obligation): FinancialObligation
{
$obligation->forceFill(['status' => FinancialObligation::STATUS_VOID])->save();
return $obligation;
}
protected function defaultAmountFor(WorkflowStage $stage): int
{
if ($stage->charge_code === 'consultation') {
return (int) config('care.billing.consultation_fee_minor', 0);
}
return 0;
}
}
@@ -0,0 +1,189 @@
<?php
namespace App\Services\Care\Workflow;
use App\Models\FacilityWorkflow;
use App\Models\Visit;
use App\Models\VisitStageAdvance;
use App\Models\WorkflowStage;
use Illuminate\Support\Facades\DB;
/**
* Drives a visit through its facility workflow. Responsible for placing a visit
* on its first stage, advancing between stages, and together with
* FinancialGateService deciding whether a stage may be entered or whether the
* visit is blocked awaiting payment / authorization.
*/
class WorkflowEngine
{
public function __construct(
protected FinancialGateService $gate,
) {}
/** The active workflow for a visit's org + branch (branch-specific wins). */
public function workflowFor(Visit $visit): ?FacilityWorkflow
{
return FacilityWorkflow::query()
->where('organization_id', $visit->organization_id)
->where('is_active', true)
->where(function ($q) use ($visit) {
$q->where('branch_id', $visit->branch_id)->orWhereNull('branch_id');
})
->orderByRaw('branch_id is null')
->with('stages')
->first();
}
/**
* Place the visit on the workflow's first stage. Idempotent returns the
* existing active advance if the visit is already started.
*/
public function start(Visit $visit, ?FacilityWorkflow $workflow = null): ?VisitStageAdvance
{
$workflow ??= $this->workflowFor($visit);
if ($workflow === null) {
return null;
}
$existing = $this->currentAdvance($visit);
if ($existing !== null) {
return $existing;
}
$first = $workflow->firstStage();
if ($first === null) {
return null;
}
return $this->enterStage($visit, $workflow, $first);
}
public function currentAdvance(Visit $visit): ?VisitStageAdvance
{
return VisitStageAdvance::query()
->where('visit_id', $visit->id)
->whereIn('status', [VisitStageAdvance::STATUS_ACTIVE, VisitStageAdvance::STATUS_BLOCKED])
->orderByDesc('id')
->first();
}
public function currentStage(Visit $visit): ?WorkflowStage
{
return $this->currentAdvance($visit)?->stage;
}
/**
* Whether the visit may advance to $toCode (or the current stage's
* default_next when omitted). Blocked when the target stage has an unmet
* "before" financial gate.
*/
public function canAdvance(Visit $visit, ?string $toCode = null): bool
{
$target = $this->resolveTarget($visit, $toCode);
return $target !== null && $this->gate->isSatisfied($visit, $target);
}
/**
* Advance the visit to the next stage. Completes the current advance and
* enters the target stage; if the target is gated and unpaid the new
* advance is created in the "blocked" state (visit is not queued until the
* obligation clears).
*/
public function advance(Visit $visit, ?string $toCode = null): ?VisitStageAdvance
{
return DB::transaction(function () use ($visit, $toCode) {
$workflow = $this->workflowFor($visit);
if ($workflow === null) {
return null;
}
$target = $this->resolveTarget($visit, $toCode, $workflow);
if ($target === null) {
return null;
}
$current = $this->currentAdvance($visit);
if ($current !== null) {
$current->forceFill([
'status' => VisitStageAdvance::STATUS_COMPLETED,
'completed_at' => now(),
])->save();
}
return $this->enterStage($visit, $workflow, $target);
});
}
/**
* Whether the current stage may release the patient into its Queue service
* point. False while a "before" gate is unmet.
*/
public function isQueueReleasable(Visit $visit): bool
{
$advance = $this->currentAdvance($visit);
if ($advance === null || $advance->stage === null) {
return true;
}
return $this->gate->isSatisfied($visit, $advance->stage);
}
/**
* Re-evaluate the current advance after an obligation changes. Moves a
* blocked advance to active once its gate clears.
*/
public function refreshGate(Visit $visit): ?VisitStageAdvance
{
$advance = $this->currentAdvance($visit);
if ($advance === null || $advance->stage === null) {
return $advance;
}
if ($advance->isBlocked() && $this->gate->isSatisfied($visit, $advance->stage)) {
$advance->forceFill([
'status' => VisitStageAdvance::STATUS_ACTIVE,
'blocked_reason' => null,
'cleared_at' => now(),
])->save();
}
return $advance->fresh();
}
protected function enterStage(Visit $visit, FacilityWorkflow $workflow, WorkflowStage $stage): VisitStageAdvance
{
$this->gate->obligationFor($visit, $stage);
$satisfied = $this->gate->isSatisfied($visit, $stage);
return VisitStageAdvance::create([
'owner_ref' => $visit->owner_ref,
'visit_id' => $visit->id,
'workflow_id' => $workflow->id,
'workflow_stage_id' => $stage->id,
'stage_code' => $stage->code,
'status' => $satisfied ? VisitStageAdvance::STATUS_ACTIVE : VisitStageAdvance::STATUS_BLOCKED,
'blocked_reason' => $satisfied ? null : $this->gate->blockedReason($visit, $stage),
'entered_at' => now(),
'cleared_at' => $satisfied ? now() : null,
]);
}
protected function resolveTarget(Visit $visit, ?string $toCode, ?FacilityWorkflow $workflow = null): ?WorkflowStage
{
$workflow ??= $this->workflowFor($visit);
if ($workflow === null) {
return null;
}
if ($toCode !== null && $toCode !== '') {
return $workflow->stage($toCode);
}
$current = $this->currentAdvance($visit)?->stage;
$nextCode = $current?->default_next;
return $nextCode ? $workflow->stage($nextCode) : null;
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Services\Care\Workflow;
use App\Models\Branch;
use App\Models\FacilityWorkflow;
use App\Models\Organization;
use App\Models\WorkflowStage;
use Illuminate\Support\Facades\DB;
/**
* Materializes a workflow template from config into a FacilityWorkflow +
* WorkflowStage graph for an organization. Idempotent per (org, branch):
* re-installing replaces the workflow's stages with the template definition.
*/
class WorkflowTemplateInstaller
{
public function __construct(
protected WorkflowTemplateRegistry $registry,
) {}
/**
* @param array{name?: string, category?: string, activate?: bool, replace?: bool} $options
*/
public function install(Organization $organization, ?Branch $branch, string $templateKey, array $options = []): FacilityWorkflow
{
$template = $this->registry->template($templateKey);
if ($template === null) {
throw new \InvalidArgumentException("Unknown workflow template [{$templateKey}].");
}
$ownerRef = (string) $organization->owner_ref;
$activate = (bool) ($options['activate'] ?? true);
$replace = (bool) ($options['replace'] ?? true);
$name = (string) ($options['name'] ?? ($template['label'] ?? $templateKey));
$category = $options['category'] ?? data_get($organization->settings, 'facility_category');
return DB::transaction(function () use ($organization, $branch, $template, $templateKey, $ownerRef, $activate, $replace, $name, $category) {
$query = FacilityWorkflow::query()
->where('organization_id', $organization->id)
->where('branch_id', $branch?->id);
$workflow = $query->first();
if ($workflow === null) {
$workflow = FacilityWorkflow::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'branch_id' => $branch?->id,
'name' => $name,
'template_key' => $templateKey,
'category' => $category,
'is_active' => $activate,
'settings' => ['description' => $template['description'] ?? null],
]);
} else {
$workflow->forceFill([
'name' => $name,
'template_key' => $templateKey,
'category' => $category,
'is_active' => $activate ? true : $workflow->is_active,
'settings' => array_merge($workflow->settings ?? [], [
'description' => $template['description'] ?? null,
]),
])->save();
if ($replace) {
WorkflowStage::query()->where('workflow_id', $workflow->id)->delete();
}
}
if ($replace || $workflow->stages()->count() === 0) {
$this->syncStages($workflow, $ownerRef, (array) ($template['stages'] ?? []));
}
return $workflow->fresh(['stages']) ?? $workflow;
});
}
/**
* @param list<array<string, mixed>> $stages
*/
protected function syncStages(FacilityWorkflow $workflow, string $ownerRef, array $stages): void
{
$order = 0;
foreach ($stages as $stage) {
$order += 10;
WorkflowStage::create([
'owner_ref' => $ownerRef,
'workflow_id' => $workflow->id,
'code' => (string) $stage['code'],
'name' => (string) ($stage['name'] ?? $stage['code']),
'type' => (string) ($stage['type'] ?? 'custom'),
'queue_context' => $stage['queue_context'] ?? null,
'department_type' => $stage['department_type'] ?? null,
'sort_order' => $stage['sort_order'] ?? $order,
'requires_payment' => (bool) ($stage['requires_payment'] ?? false),
'payment_timing' => (string) ($stage['payment_timing'] ?? 'none'),
'payment_modes' => $stage['payment_modes'] ?? ['internal_cashier', 'digital'],
'insurance_eligible' => (bool) ($stage['insurance_eligible'] ?? false),
'credit_allowed' => (bool) ($stage['credit_allowed'] ?? false),
'allow_override' => (bool) ($stage['allow_override'] ?? true),
'charge_code' => $stage['charge_code'] ?? null,
'charge_label' => $stage['charge_label'] ?? null,
'default_amount_minor' => $stage['default_amount_minor'] ?? null,
'default_next' => $stage['default_next'] ?? null,
'can_return' => (bool) ($stage['can_return'] ?? false),
'can_skip' => (bool) ($stage['can_skip'] ?? false),
'optional' => (bool) ($stage['optional'] ?? false),
'creates_child' => (bool) ($stage['creates_child'] ?? false),
]);
}
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Services\Care\Workflow;
/**
* Read-only access to config/care_workflows.php facility categories and
* workflow templates. Templates are materialized into care_facility_workflows
* by WorkflowTemplateInstaller.
*/
class WorkflowTemplateRegistry
{
/** @return array<string, mixed> */
public function categories(): array
{
return (array) config('care_workflows.categories', []);
}
/** @return array<string, mixed> */
public function category(?string $key): ?array
{
if ($key === null || $key === '') {
return null;
}
$category = config("care_workflows.categories.{$key}");
return is_array($category) ? $category : null;
}
/** @return array<string, string> key => label */
public function categoryOptions(): array
{
return array_map(
fn (array $category) => (string) ($category['label'] ?? ''),
$this->categories(),
);
}
/** @return array<string, mixed> */
public function templates(): array
{
return (array) config('care_workflows.templates', []);
}
/** @return array<string, mixed>|null */
public function template(?string $key): ?array
{
if ($key === null || $key === '') {
return null;
}
$template = config("care_workflows.templates.{$key}");
return is_array($template) ? $template : null;
}
public function hasTemplate(?string $key): bool
{
return $this->template($key) !== null;
}
/** @return array<string, string> key => label */
public function templateOptions(): array
{
return array_map(
fn (array $template) => (string) ($template['label'] ?? ''),
$this->templates(),
);
}
public function defaultTemplateKey(): string
{
return (string) config('care_workflows.default_template', 'legacy_clinic');
}
/** Preferred template for a facility category, falling back to the global default. */
public function defaultTemplateForCategory(?string $categoryKey): string
{
$category = $this->category($categoryKey);
$preferred = $category['default_template'] ?? null;
if (is_string($preferred) && $this->hasTemplate($preferred)) {
return $preferred;
}
return $this->defaultTemplateKey();
}
/** @return list<string> */
public function modulesForCategory(?string $categoryKey): array
{
$category = $this->category($categoryKey);
return array_values((array) ($category['modules'] ?? []));
}
}