Skip to content

feat(billing): pre-submission rules engine#8363

Draft
alexwillingham wants to merge 11 commits into
developfrom
claude/pensive-hamilton-a2w5hh
Draft

feat(billing): pre-submission rules engine#8363
alexwillingham wants to merge 11 commits into
developfrom
claude/pensive-hamilton-a2w5hh

Conversation

@alexwillingham

Copy link
Copy Markdown
Contributor

Summary

Adds the billing pre-submission rules engine: an ordered set of rules that run against a working-copy Claim before it is submitted. Each rule is an if / else-if / else conditional 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 Basic resources contained in a single ordered List (entry order = run order; rules never stand alone). The engine is driven by a FHIR Task (the established async pattern): a Task is created when a working-copy claim is created, a Subscription invokes the engine zambda, and the Task ends completed (submitted) or failed (with the name of the rule that applied Hold, or the error).

Important

Gated by the presubmissionRulesEngineEnabled feature 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

  • Shared contract (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-Basic List; constants + feature flag.
  • Engine zambdasub-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 calls submitClaim().
  • Kickoff wiring — both create-billing-claim (manual) and create-billing-claim-from-encounter enqueue the engine Task after creating the draft claim (flag-gated).
  • CRUD zambdasget-billing-rules / save-billing-rules (the whole ordered list is saved atomically with optimistic locking; seeds the Hold system tag), plus the BillingAdmin role grant.
  • Billing UI — a Rules screen: drag-to-reorder list with enable/disable, duplicate, delete and a terminal "when all rules pass, the claim is submitted" card; a recursive rule editor for conditions (all / claim property / AND-OR group) and outcomes (actions / nested branch / do nothing).

Submission backend

Per decision, submitClaim() is an isolated no-op seam with a TODO — 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 --noEmit clean across utils, zambdas, and apps/billing.
  • ESLint clean (pre-commit lint-staged on every commit).
  • Tests: utils 553/553 (incl. evaluator/serialization/payer-id round-trip), zambdas billing unit 169/169 (incl. the modified claim-creation handler), apps/billing component tests for the list + builder.
  • Not run here (no live backend in the dev container): zambda integration tests against a real FHIR store, and visual QA of the app.

Notes for reviewers

  • "Set payer id" edits the claim's own working-copy Coverage (payor) and the working-copy Claim's insurer via the pure getPayerUrl helper — the same fields update-billing-claim sets — so it behaves like every other working-copy edit (no shared-resource mutation, no RCM lookup).
  • Drag-to-reorder uses native HTML5 DnD (no new dependency); could swap in @dnd-kit later for nicer UX.

🤖 Generated with Claude Code

https://claude.ai/code/session_018FrFrhGhKjN8QfmS3Xk9gd


Generated by Claude Code

claude added 11 commits June 23, 2026 23:37
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants