diff --git a/extensions/candid/candid/README.md b/extensions/candid/candid/README.md index 12dcb3057..be2bc58bc 100644 --- a/extensions/candid/candid/README.md +++ b/extensions/candid/candid/README.md @@ -52,6 +52,7 @@ When a claim is moved to the **QueuedForSubmission** queue, the plugin schedules - **Resubmission handling:** If the POST fails with `EncounterExternalIdUniquenessError` (the encounter already exists in Candid), the plugin looks up the existing encounter by `external_id` and PATCHes it instead. The PATCH payload excludes `diagnoses` and `service_lines` (Candid's update schema uses `diagnosis_ids` instead and does not accept service lines) - On success: adds a claim comment with the submission date and Candid encounter IDs, adds a status banner, and moves claim to **FiledAwaitingResponse** - On failure: adds an error comment, writes a `candid_submission_error` metadata entry, and moves claim to **NeedsCodingReview** +- **Corrected claims cannot be submitted through this integration.** Canvas exposes a CMS-1500 box 22 resubmission code (6 corrected, 7 replacement, 8 void) on each claim coverage, but `POST /encounters/v4` has no claim-frequency or original-reference field to carry it — the only `claim_frequency_code` in Candid's schema sits under `external_claim_submission`, which reports claims filed outside Candid. Candid confirmed directly (Aug 2026) that there is **no API path for corrections at all**: voids, corrections, and fresh claims all go through the same Candid UI workflow, where Candid derives the frequency code from the submission type the biller selects, and the biller must wait for the original claim to adjudicate before submitting the correction or it returns as a duplicate denial. Setting the code in Canvas therefore does nothing. Rather than let that pass silently, the plugin adds a claim comment on every submission where a code is set, spelling out that the claim filed as an original and describing the Candid UI workflow. The payload is unchanged ### Adjudication Sync (Pull-Based) diff --git a/extensions/candid/candid/api/submit.py b/extensions/candid/candid/api/submit.py index b1cb81384..6f4e3f13b 100644 --- a/extensions/candid/candid/api/submit.py +++ b/extensions/candid/candid/api/submit.py @@ -15,6 +15,7 @@ check_internal_auth, handle_submit_failure, handle_submit_success, + resubmission_code_comment, ) SUBMISSION_QUEUE = ClaimQueues.QUEUED_FOR_SUBMISSION @@ -124,6 +125,18 @@ def post(self) -> list[Response | Effect]: return [] effects = self._submit(claim) + + # Warn on every attempt, success or failure. A biller who set a + # resubmission code is expecting a correction, and Candid can't carry + # one, so the claim needs to say that whatever else happened. + warning = resubmission_code_comment(claim, ClaimEffect(claim_id=claim.id)) + if warning: + log.info( + f"Candid: claim {claim.id} carries a resubmission code that " + "Candid's submission API cannot accept; warned on the claim" + ) + effects.append(warning) + effects.append(notify_claim_updated(str(claim.id))) return effects diff --git a/extensions/candid/candid/effect_helpers.py b/extensions/candid/candid/effect_helpers.py index ec7b1bd9d..494dabf7a 100644 --- a/extensions/candid/candid/effect_helpers.py +++ b/extensions/candid/candid/effect_helpers.py @@ -145,6 +145,53 @@ def active_coverages_ordered(claim: Claim) -> list: ) +# Canvas stores a CMS-1500 box 22 resubmission code on each claim coverage. +# Candid's submission path has nowhere to put it: POST /encounters/v4 exposes no +# claim-frequency or original-reference field, and the only claim_frequency_code +# in the schema hangs off external_claim_submission, which reports claims filed +# outside Candid. +# +# Candid confirmed this directly (Aug 2026): corrections cannot be submitted via +# the API at all. Voids, corrections, and fresh claims all go through the same +# Candid UI workflow, where Candid derives the frequency code from the submission +# type the biller picks, and they must wait for the original claim to adjudicate +# first or the correction comes back as a duplicate denial. +# +# So the code on the coverage can never do anything here. Say that on the claim +# rather than letting it sit there looking like it worked. +RESUBMISSION_CODE_LABELS = { + "6": "6 (corrected claim)", + "7": "7 (replacement of prior claim)", + "8": "8 (void/cancel prior claim)", +} + + +def resubmission_code_comment(claim: Claim, claim_effect: ClaimEffect) -> Effect | None: + """Warn on the claim when a resubmission code is set but was not sent to Candid. + + Returns None when no active coverage carries one, so callers can fold the + result into an effect list without branching on the claim's contents. + """ + codes = [ + RESUBMISSION_CODE_LABELS.get(coverage.resubmission_code, coverage.resubmission_code) + for coverage in active_coverages_ordered(claim) + if coverage.resubmission_code + ] + if not codes: + return None + + return claim_effect.add_comment( + comment=( + f"Resubmission code {', '.join(codes)} is set on this claim. Candid has no " + "API field for it, so it was not sent and this claim was filed as an " + "original. Corrections and voids are submitted in the Candid UI, where " + "Candid sets the frequency code from the submission type you choose. Wait " + "for the original claim to adjudicate before submitting the correction, " + "otherwise it can come back as a duplicate denial." + ) + ) + + def format_date_display(date_str: str) -> str: """Convert an ISO date string (YYYY-MM-DD...) to MM-DD-YYYY display format.""" y, m, d = date_str[:10].split("-") diff --git a/extensions/candid/tests/test_effect_helpers.py b/extensions/candid/tests/test_effect_helpers.py index f2b926d16..8ac9c4216 100644 --- a/extensions/candid/tests/test_effect_helpers.py +++ b/extensions/candid/tests/test_effect_helpers.py @@ -8,6 +8,7 @@ BANNER_KEY, DENIED_STATUSES, active_coverages_ordered, + resubmission_code_comment, schedule_async_post, sync_banner, ) @@ -146,3 +147,93 @@ def test_schedule_async_post_passes_comma_free_secret_through() -> None: auth = MockEffect.call_args.kwargs["headers"]["Authorization"] assert auth == "no-commas-here" + + +# --------------------------------------------------------------------------- +# resubmission_code_comment +# --------------------------------------------------------------------------- + + +def _coverage_with_code(payer_order: str, resubmission_code: str) -> MagicMock: + """An active coverage carrying an explicit resubmission code. + + The code has to be set explicitly — a bare MagicMock attribute is truthy + and would make every coverage look like a correction. + """ + cov = MagicMock() + cov.payer_order = payer_order + cov.resubmission_code = resubmission_code + return cov + + +def _claim_with_coverages(*coverages: MagicMock) -> MagicMock: + claim = MagicMock() + claim.coverages.active.return_value = list(coverages) + return claim + + +def test_resubmission_code_comment_returns_none_when_no_code_set() -> None: + """The common case: no correction requested, so nothing is added to the claim.""" + claim = _claim_with_coverages( + _coverage_with_code("Primary", ""), + _coverage_with_code("Secondary", ""), + ) + assert resubmission_code_comment(claim, MagicMock()) is None + + +def test_resubmission_code_comment_warns_that_code_was_not_sent() -> None: + """Code 7 set: the biller must learn the claim filed as an original anyway.""" + claim = _claim_with_coverages(_coverage_with_code("Primary", "7")) + claim_effect = MagicMock() + + result = resubmission_code_comment(claim, claim_effect) + + assert result is claim_effect.add_comment.return_value + comment = claim_effect.add_comment.call_args.kwargs["comment"] + assert "7 (replacement of prior claim)" in comment + assert "was not sent" in comment + assert "filed as an original" in comment + assert "Candid UI" in comment + # Candid's guidance: correcting before adjudication draws a duplicate denial, + # so the comment has to carry the ordering, not just the destination. + assert "adjudicate" in comment + assert "duplicate denial" in comment + + +def test_resubmission_code_comment_lists_every_coverage_with_a_code() -> None: + """Codes from multiple payers are reported together, primary first.""" + claim = _claim_with_coverages( + _coverage_with_code("Secondary", "8"), + _coverage_with_code("Primary", "6"), + ) + claim_effect = MagicMock() + + resubmission_code_comment(claim, claim_effect) + + comment = claim_effect.add_comment.call_args.kwargs["comment"] + assert "6 (corrected claim), 8 (void/cancel prior claim)" in comment + + +def test_resubmission_code_comment_ignores_coverages_without_a_code() -> None: + """A single corrected coverage alongside ordinary ones reports only itself.""" + claim = _claim_with_coverages( + _coverage_with_code("Primary", ""), + _coverage_with_code("Secondary", "7"), + ) + claim_effect = MagicMock() + + resubmission_code_comment(claim, claim_effect) + + comment = claim_effect.add_comment.call_args.kwargs["comment"] + assert "7 (replacement of prior claim)" in comment + assert "corrected claim" not in comment + + +def test_resubmission_code_comment_falls_back_to_the_raw_unmapped_code() -> None: + """An unexpected code still surfaces rather than vanishing from the comment.""" + claim = _claim_with_coverages(_coverage_with_code("Primary", "1")) + claim_effect = MagicMock() + + resubmission_code_comment(claim, claim_effect) + + assert "1" in claim_effect.add_comment.call_args.kwargs["comment"] diff --git a/extensions/candid/tests/test_submit.py b/extensions/candid/tests/test_submit.py index b7a84a7fd..e2340f4ac 100644 --- a/extensions/candid/tests/test_submit.py +++ b/extensions/candid/tests/test_submit.py @@ -150,6 +150,61 @@ def test_submit_success_for_single_payload() -> None: mock_failure.assert_not_called() +def test_submit_warns_when_a_resubmission_code_is_set() -> None: + """Candid can't carry a box-22 code, so the claim has to say so after filing.""" + claim = _claim_in_submission_queue() + coverage = MagicMock() + coverage.payer_order = "Primary" + coverage.resubmission_code = "7" + claim.coverages.active.return_value = [coverage] + + with ( + patch("candid.api.submit.Claim") as MockClaim, + patch("candid.api.submit.CandidClient") as MC, + patch("candid.api.submit.build_split_payloads") as mock_build, + patch("candid.api.submit.handle_submit_success") as mock_success, + patch("candid.api.submit.ClaimEffect") as MockClaimEffect, + patch("candid.api.submit.notify_claim_updated"), + ): + MockClaim.objects.filter.return_value.first.return_value = claim + mock_build.return_value = [({"external_id": "canvas:claim-1"}, [])] + MC.from_secrets.return_value.submit_claim.return_value = (True, "encounter-1") + mock_success.return_value = ["success-effect"] + + result = _build_handler(claim).post() + + assert MockClaimEffect.return_value.add_comment.return_value in result + comment = MockClaimEffect.return_value.add_comment.call_args.kwargs["comment"] + assert "7 (replacement of prior claim)" in comment + # The payload itself is untouched — this change only adds a comment. + MC.from_secrets.return_value.submit_claim.assert_called_once_with( + {"external_id": "canvas:claim-1"} + ) + + +def test_submit_adds_no_resubmission_warning_for_an_ordinary_claim() -> None: + """No code set → no extra comment, so ordinary submissions read as before.""" + claim = _claim_in_submission_queue() + claim.coverages.active.return_value = [] + + with ( + patch("candid.api.submit.Claim") as MockClaim, + patch("candid.api.submit.CandidClient") as MC, + patch("candid.api.submit.build_split_payloads") as mock_build, + patch("candid.api.submit.handle_submit_success") as mock_success, + patch("candid.api.submit.ClaimEffect") as MockClaimEffect, + patch("candid.api.submit.notify_claim_updated"), + ): + MockClaim.objects.filter.return_value.first.return_value = claim + mock_build.return_value = [({"external_id": "canvas:claim-1"}, [])] + MC.from_secrets.return_value.submit_claim.return_value = (True, "encounter-1") + mock_success.return_value = ["success-effect"] + + _build_handler(claim).post() + + MockClaimEffect.return_value.add_comment.assert_not_called() + + def test_submit_success_for_multiple_splits() -> None: """All splits succeed → handle_submit_success with N encounter records.""" claim = _claim_in_submission_queue()