feat(billing): pre-submission rules engine#8363
Draft
alexwillingham wants to merge 11 commits into
Draft
Conversation
…se 1) Add the shared contract for the billing pre-submission rules engine in packages/utils, consumed by both the engine zambda and the billing app: - Zod schemas/types for rules: recursive if/else-if Conditional, Condition (all/field/group), Action (setField/applyTag/noop), Outcome. - Field catalog mapping logical claim fields (payer id, patient demographics, service facility, rendering provider, tags) to FHIR readers/writers over a RulesEngineClaimModel; also the source of truth for the UI's field pickers. - Pure evaluator: executeRule/evaluateCondition/applyAction with first-match branch semantics and Hold-tag termination. - Serialization between rules and an ordered FHIR List of contained Basic resources (entry order = run order; rules never stand alone). - Constants: rules-engine tag systems, kickoff Task code, Hold tag, extensions. - presubmissionRulesEngineEnabled feature flag (off by default; opt-in). Move CLAIM_TAG_SYSTEM into utils as the single source of truth and re-export it from billing/shared.ts. Unit-tested (8 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
…se 2) Add sub-presubmission-rules-engine, a Task-triggered subscription zambda (timeout 870s = 14m30s) that: - resolves the working-copy Claim from Task.focus, - loads the ordered rules List and the claim's related working-copy resources (patient, coverages, rendering provider, service facility) into a model, - runs each rule in order via the shared evaluator, halting if a rule applies the Hold tag, - persists all mutations in one FHIR transaction, then - calls submitClaim() and marks the Task completed — or failed with the holding rule's name (wrapTaskHandler also fails the Task on any thrown error). submitClaim() is an isolated no-op seam (per "abstract now, decide later"); the real backend (Oystehr claim service / Candid) wires in later without touching the engine. Register the zambda + its Subscription in config/oystehr-core. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
…phase 3) After a draft working-copy Claim is created, enqueue the rules-engine kickoff Task (status requested, focus = Claim) so the Subscription runs the engine. Wired into both creation paths — create-billing-claim (manual) and create-billing-claim-from-encounter — and gated by the presubmissionRulesEngine feature flag, so the default behavior is unchanged. Adds the shared buildRulesEngineKickoffTask helper in utils. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
Add get-billing-rules and save-billing-rules. Because all rules live in one ordered List, the save endpoint takes the full ordered array — create, edit, reorder, and delete are all "save this list" — and writes it atomically with optimistic locking (expectedVersionId). get returns the rules plus the List versionId. save also best-effort seeds the Hold system tag so it appears in the Tags screen. Adds SaveBillingRulesInputSchema (+ duplicate-id guard) and the BillingRulesResponse type in utils; registers both zambdas (http_auth) and grants them to the BillingAdmin role. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
Add a Rules screen to the billing app (gated by the feature flag): - Rules list: ordered rows with native drag-to-reorder, enable/disable, duplicate, delete — each change persists the full ordered list — plus a terminal "when all rules pass, the claim is submitted" card. - Rule editor: name/description/enabled and a recursive if / else-if / else builder (ConditionalEditor) for conditions (all / claim property / AND-OR group) and outcomes (actions / nested branch / do nothing); actions are set-property / apply-tag / do-nothing, with the Hold tag called out as the engine-stopping action. - API client functions get/saveBillingRules; routes + sidebar nav entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
Expand utils unit tests (kickoff Task builder, save-input duplicate-id guard) and add billing component tests (Rules list renders rules + the terminal submission card and empty state; ConditionalEditor renders IF/THEN). The submission step remains the isolated submitClaim() no-op seam per the "abstract now, decide later" decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
The payerId setField action now re-points the working-copy Coverage's payor and the working-copy Claim's insurer (via getPayerUrl) — the same fields update-billing-claim sets — instead of writing a stale display value on the coverage plan class. This treats Coverage as the claim's editable working copy, consistent with patient/rendering-provider, and needs no RCM lookup or submission-time resolution (getPayerUrl/extractPayerIdFromUrl are pure). The reader now sources payer id from the payor reference (with plan-class fallback), keeping read/write consistent. Removes the inaccurate submit-claim TODO. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
…ns + actions) Replace the payer-id free-text box in the rule builder with a PayerSelect backed by the Oystehr payer list (search-billing-payers): debounced server-side search, shows payer name + clearinghouse payer id, and stores the RCM payer id (the token coverage.payor/Claim.insurer round-trip on). Supports single select (eq/neq) and multi-select (is one of / is not one of) for conditions, and single for set-payer-id actions. Introduce a field-aware FieldValueInput dispatcher in the builder so each field type can have the right input (payer picker today; gender dropdown / date picker slot in the same way). Reset a condition/action value when its target field changes so stale values can't carry across field types. Component test asserts the payer picker renders for payerId in both the condition and the action. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
…ngine Add a flag-gated "Submit claim" button to the claim detail view that kicks off the pre-submission rules engine for that claim. It calls a new run-billing-rules-engine zambda which validates the claim exists and enqueues the engine Task (the same trigger create-billing-claim uses); a Subscription then runs sub-presubmission-rules-engine asynchronously. The UI reports it as started and offers a Refresh action to see applied changes/tags/Hold. Adds RunBillingRulesEngineInputSchema/Response in utils, the zambda + its http_auth registration and BillingAdmin role grant, and the runBillingRulesEngine api client function. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
The first production run exited with no handler logs because wrapTaskHandler routes handler errors to Sentry (misconfigured in that env) and the Task statusReason, not CloudWatch — so a thrown error was invisible. Two fixes: - Log each stage (start, rules/model loaded, per-rule actions applied, hold, persist, submit, completion) and console.error the actual error before rethrowing, so failures show up in CloudWatch. - Read/write through createBillingClient (the billing workspace client every other billing zambda uses) instead of the core subscription client, so the claim, working copies, and rules List resolve correctly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
Correctness: - Encounter-created claims now reference their per-claim working copies for rendering provider, service facility, and billing provider (buildClaim was passed the shared billing originals while the copies sat orphaned), so claim edits and rules-engine writes can no longer mutate resources shared across claims. The engine additionally refuses to write any non-working-copy resource other than the Claim itself, protecting legacy claims. - persistModel now writes only resources a rule actually changed (snapshot diff) and guards every PUT with ifMatch, so no-op runs write nothing and concurrent edits fail the run instead of being clobbered. - One unparseable stored rule no longer breaks the whole list (Rules screen 500 + every engine Task failing): it deserializes as a disabled placeholder and survives round-trips instead of being silently deleted on the next save. - applyTag values are trimmed and Hold case-variants canonicalized at the schema boundary, so "hold"/"HOLD "/etc. actually hold the claim. - Kickoff-Task failures after the claim is durably created are logged instead of failing the request (a retry would have created a duplicate claim). - ensureHoldTag pages with _count=200 (duplicate un-deletable system tags) and runs only when the rules List is first created, not on every save. Reuse/simplification: - Shared findPresubmissionRulesList replaces three copies of the List lookup; setNpi moved to utils (re-exported from billing/shared) and the field catalog now uses utils getNPI/setNpi/resourceHasTag instead of re-implementations. - PayerSelect: one shared props object instead of two near-identical Autocomplete blocks; debounce via the app's useDebounce hook (adds the missing unmount cleanup). - FieldValueInput dispatches on the catalog's valueType (new 'payer' type) instead of a hardcoded field id; dead fallbacks and redundant coalescing removed; RuleDetail/Rules drop the initial-load ref guard (stale-on-param- change hazard) in favor of the ClaimDetail effect pattern. - Trimmed history-narrating/cross-module comment blocks and corrected the engine catch comment (topLevelCatch does log to CloudWatch; the catch adds claim context). Loading a claim's coverages now prefers the focal insurance entry. Tests: unparseable-rule placeholder, Hold canonicalization; from-encounter unit test updated to the corrected working-copy references; component-test client mock made stable like the real hook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the billing pre-submission rules engine: an ordered set of rules that run against a working-copy
Claimbefore it is submitted. Each rule is anif / else-if / elseconditional whose branches terminate in actions (change a claim property, apply a tag, or do nothing). The engine runs the rules in order and submits the claim at the end — unless a rule applies the Hold tag, which stops the engine and holds the claim.Rules are stored as FHIR
Basicresources contained in a single orderedList(entry order = run order; rules never stand alone). The engine is driven by a FHIRTask(the established async pattern): aTaskis created when a working-copy claim is created, a Subscription invokes the engine zambda, and the Task endscompleted(submitted) orfailed(with the name of the rule that applied Hold, or the error).Important
Gated by the
presubmissionRulesEngineEnabledfeature flag, off by default. With it off, nothing changes. Turning it on makes working-copy claim creation kick off the engine and (when all rules pass) submit the claim — a deliberate, opt-in behavior change.What's included
packages/utils) — Zod schema for the recursive rule tree; a field catalog mapping logical claim fields (payer id, patient demographics, service facility, rendering provider, tags) to FHIR readers/writers over the claim's working-copy resources; pure evaluator; serialization between rules and the contained-BasicList; constants + feature flag.sub-presubmission-rules-engine(Subscription-triggered, timeout 870s = 14m30s): loads the rules + the claim's working-copy resources, runs them, persists mutations in one transaction, halts on Hold, else callssubmitClaim().create-billing-claim(manual) andcreate-billing-claim-from-encounterenqueue the engine Task after creating the draft claim (flag-gated).get-billing-rules/save-billing-rules(the whole ordered list is saved atomically with optimistic locking; seeds the Hold system tag), plus theBillingAdminrole grant.Submission backend
Per decision,
submitClaim()is an isolated no-op seam with aTODO— the real backend (the Oystehr claim service or the existing Candid path) wires into that one function later without touching the engine or UI. Until then the Task completes with a clear "ready to submit (backend not yet wired)" reason.Verification
tsc --noEmitclean acrossutils,zambdas, andapps/billing.utils553/553 (incl. evaluator/serialization/payer-id round-trip),zambdasbilling unit 169/169 (incl. the modified claim-creation handler),apps/billingcomponent tests for the list + builder.Notes for reviewers
payor) and the working-copy Claim'sinsurervia the puregetPayerUrlhelper — the same fieldsupdate-billing-claimsets — so it behaves like every other working-copy edit (no shared-resource mutation, no RCM lookup).@dnd-kitlater for nicer UX.🤖 Generated with Claude Code
https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd
Generated by Claude Code