Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions extensions/candid/candid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
13 changes: 13 additions & 0 deletions extensions/candid/candid/api/submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
check_internal_auth,
handle_submit_failure,
handle_submit_success,
resubmission_code_comment,
)

SUBMISSION_QUEUE = ClaimQueues.QUEUED_FOR_SUBMISSION
Expand Down Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions extensions/candid/candid/effect_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("-")
Expand Down
91 changes: 91 additions & 0 deletions extensions/candid/tests/test_effect_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
BANNER_KEY,
DENIED_STATUSES,
active_coverages_ordered,
resubmission_code_comment,
schedule_async_post,
sync_banner,
)
Expand Down Expand Up @@ -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"]
55 changes: 55 additions & 0 deletions extensions/candid/tests/test_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down