From 3b884579bd52e715ebb96a66b8ceba3118d1618e Mon Sep 17 00:00:00 2001 From: Rebecca Portela Date: Mon, 10 Aug 2026 14:50:17 -0400 Subject: [PATCH 1/2] fix(candid): warn when a claim's resubmission code can't be sent Canvas stores a CMS-1500 box 22 resubmission code (6 corrected, 7 replacement, 8 void) on each claim coverage, but Candid's submission path has no field to carry it. POST /encounters/v4 exposes no claim-frequency or original-reference field, and the only claim_frequency_code in the schema sits under external_claim_submission, which reports claims filed outside Candid. The result was silent. A biller could set code 7, save, re-queue, and watch the claim file with the payer as an ordinary original claim with no error, warning, or comment anywhere. Add resubmission_code_comment() and call it from CandidSubmitAPI.post() so a claim carrying a code gets a comment explaining that it filed as an original and the correction has to be made in Candid. Fires on success and on failure, since a biller expecting a correction needs to know either way. The payload is unchanged, so this is additive only and cannot regress an existing submission. Warning rather than blocking is deliberate: instances already carry inert codes on coverages, and failing their next re-queue would be a worse outcome than an extra comment. Co-Authored-By: Claude Opus 5 (1M context) --- extensions/candid/candid/README.md | 1 + extensions/candid/candid/api/submit.py | 13 +++ extensions/candid/candid/effect_helpers.py | 37 ++++++++ .../candid/tests/test_effect_helpers.py | 87 +++++++++++++++++++ extensions/candid/tests/test_submit.py | 55 ++++++++++++ 5 files changed, 193 insertions(+) diff --git a/extensions/candid/candid/README.md b/extensions/candid/candid/README.md index 12dcb3057..45733f672 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 are not supported on this path.** 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. Setting the code therefore does nothing to what Canvas sends. Rather than let that pass silently, the plugin adds a claim comment on every submission where a code is set, saying the claim filed as an original and the correction has to be made in Candid. 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..60b49dd99 100644 --- a/extensions/candid/candid/effect_helpers.py +++ b/extensions/candid/candid/effect_helpers.py @@ -145,6 +145,43 @@ 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. Corrections are made in Candid directly, so say so on the claim +# rather than letting the code sit on the coverage looking like it did something. +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, but Candid's " + "submission API has no field to carry it, so it was not sent and this claim " + "was filed as an original. To file a correction, make the edit in Candid and " + "resubmit from there." + ) + ) + + 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..b35b618fc 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,89 @@ 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 "resubmit from there" 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() From 623955de79454fde8b491eb6c71b58f1412acb85 Mon Sep 17 00:00:00 2001 From: Rebecca Portela Date: Mon, 10 Aug 2026 15:20:49 -0400 Subject: [PATCH 2/2] fix(candid): tell billers the actual correction workflow Candid confirmed there is no API path for corrected claims at all. Voids, corrections, and fresh claims all go through the same Candid UI workflow, Candid derives the frequency code from the submission type the biller selects, and the original claim has to adjudicate first or the correction returns as a duplicate denial. The previous comment said "make the edit in Candid and resubmit from there," which was directionally right but left out the ordering. A biller following it literally could submit the correction before adjudication and draw the exact denial the workflow exists to avoid. Spell out the whole workflow in the comment and the README, and assert on the adjudication ordering in the test so the guidance can't quietly drop out of the string later. Co-Authored-By: Claude Opus 5 (1M context) --- extensions/candid/candid/README.md | 2 +- extensions/candid/candid/effect_helpers.py | 22 ++++++++++++++----- .../candid/tests/test_effect_helpers.py | 6 ++++- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/extensions/candid/candid/README.md b/extensions/candid/candid/README.md index 45733f672..be2bc58bc 100644 --- a/extensions/candid/candid/README.md +++ b/extensions/candid/candid/README.md @@ -52,7 +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 are not supported on this path.** 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. Setting the code therefore does nothing to what Canvas sends. Rather than let that pass silently, the plugin adds a claim comment on every submission where a code is set, saying the claim filed as an original and the correction has to be made in Candid. The payload is unchanged +- **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/effect_helpers.py b/extensions/candid/candid/effect_helpers.py index 60b49dd99..494dabf7a 100644 --- a/extensions/candid/candid/effect_helpers.py +++ b/extensions/candid/candid/effect_helpers.py @@ -149,8 +149,16 @@ def active_coverages_ordered(claim: Claim) -> list: # 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. Corrections are made in Candid directly, so say so on the claim -# rather than letting the code sit on the coverage looking like it did something. +# 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)", @@ -174,10 +182,12 @@ def resubmission_code_comment(claim: Claim, claim_effect: ClaimEffect) -> Effect return claim_effect.add_comment( comment=( - f"Resubmission code {', '.join(codes)} is set on this claim, but Candid's " - "submission API has no field to carry it, so it was not sent and this claim " - "was filed as an original. To file a correction, make the edit in Candid and " - "resubmit from there." + 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." ) ) diff --git a/extensions/candid/tests/test_effect_helpers.py b/extensions/candid/tests/test_effect_helpers.py index b35b618fc..8ac9c4216 100644 --- a/extensions/candid/tests/test_effect_helpers.py +++ b/extensions/candid/tests/test_effect_helpers.py @@ -193,7 +193,11 @@ def test_resubmission_code_comment_warns_that_code_was_not_sent() -> None: assert "7 (replacement of prior claim)" in comment assert "was not sent" in comment assert "filed as an original" in comment - assert "resubmit from there" 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: