# mbiannaccone/candid

## Candid Health Integration

Canvas plugin that replaces the built-in `candid_integration/` module. Handles the full claim lifecycle with Candid Health: submission, adjudication sync, patient payment reporting and syncing, and claim status banners.

## ID Glossary

Three IDs flow through this plugin and can be confused if you're not careful:

| ID | Source | Format | Where it lives |
| --- | --- | --- | --- |
| **Canvas claim ID** | Canvas | UUID (`00000000-...`) | `claim.id`, used for `ClaimEffect`, metadata lookups, and as `external_id` sent to Candid (`canvas:{claim_id}`) |
| **Candid encounter ID** | Candid returns it from `POST /encounters/v4` | UUID | Stored in claim metadata as `candid_encounters[].candid_encounter_id`. Used to `GET /encounters/v4/{id}` |
| **Candid claim ID** | Nested inside the encounter response at `claims[].claim_id` | Candid-generated | **Not stored.** Discovered at sync time, used ephemerally to query `GET /patient-payments/v4?claim_id={id}` |

In code, variables are prefixed to avoid ambiguity:

- `canvas_claim_id` — Canvas's internal UUID
- `candid_encounter_id` — Candid's encounter-level ID
- `candid_claim_id` — Candid's claim-level ID (nested inside an encounter)

The Candid encounter response has this hierarchy:

```
Encounter (encounter_id)            <- we create via POST, fetch via GET
  +-- claims[]
        +-- claim_id               <- Candid creates internally
        +-- status                 <- e.g. "era_received", "finalized_paid"
        +-- eras[]                 <- ERA IDs for dedup
        |     +-- era_id
        |     +-- check_number
        |     +-- check_date
        +-- service_lines[]        <- payment/adjustment amounts per line
              +-- procedure_code
              +-- charge_amount_cents
              +-- primary_paid_amount_cents
              +-- allowed_amount_cents
              +-- deductible_cents, coinsurance_cents, copay_cents
              +-- service_line_era_data.service_line_adjustments[]
              +-- service_line_manual_adjustments[]
```

## Features

### Claim Submission

When a claim is moved to the **QueuedForSubmission** queue, the plugin schedules a submission to Candid's `/api/encounters/v4` endpoint via the plugin's own `/submit` SimpleAPI route after a **60-second grace period** (`GRACE_PERIOD_SECONDS`). When the submit handler fires, it re-checks the claim's queue and skips the submission if the user has moved the claim out of `QueuedForSubmission` during the grace window.

- Builds the Candid encounter payload from Canvas claim data (patient, providers, diagnoses, service lines, subscribers)
- **Splits claims with >12 diagnosis codes** into a primary encounter (real service lines) and supplemental encounters (99499 CPT code at $0.01) per CMS-1500 limits
- Diagnoses are split in rank order (first 12, next 12, etc.) without reordering -- the Canvas UI's Claim Review Records (CRR) workflow lets users review and assign diagnoses across splits before submission
- Payload errors are caught per-section (patient, billing provider, etc.) and surfaced as claim comments rather than crashing the handler
- Stores Candid encounter IDs in claim metadata for traceability
- **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**

### Adjudication Sync (Pull-Based)

Candid has no webhooks, so the plugin pulls adjudication data via `GET /api/encounters/v4/{encounter_id}` and patient payments via `GET /api/patient-payments/v4?claim_id={candid_claim_id}`.

**Triggers:**

- **Event-driven:** When a claim enters the **Patient Balance** queue, the plugin asynchronously POSTs to its own `/sync-patient-payments` SimpleAPI route to pull just the patient payments for that claim (full ERA/adjudication sync is left to the nightly cron)
- **Manual:** The "Sync Now" button on the claim timeline application POSTs to `/claim-detail`, which runs a full adjudication sync inline
- **Nightly cron (2 AM):** Queries Canvas for all claims in **FiledAwaitingResponse**, **AdjudicatedOpenBalance**, and **PatientBalance** queues that have Candid encounter metadata, then runs full adjudication sync on each

A separate one-time **midnight cron** migrates legacy `SyncLog` rows into `candid_sync_history` metadata.

**Insurance adjudication (ERA data):**

- Fetches each encounter stored in claim metadata via `GET /encounters/v4/{candid_encounter_id}`
- Iterates `claims[].eras[]` to find new ERA IDs not yet synced
- **Delta-based posting:** Service line amounts (`primary_paid_amount_cents`, etc.) are cumulative across all ERAs. The plugin stores previously synced amounts in `candid_synced_amounts` metadata and only posts the delta for each payer tier. This correctly handles primary and secondary ERAs arriving at different times without double-posting.
- Posts payments via `ClaimEffect.post_payment()` for each payer tier where new amounts are detected:
  - **Primary insurance** – charged, allowed, payment, contractual CO-45 write-off (charged minus allowed), ERA + manual adjustments
  - **Secondary/Tertiary insurance** – separate `post_payment` per payer, only when that tier's amounts increase
- **Contractual adjustment:** The difference between `charge_amount_cents` and `allowed_amount_cents` is posted as a CO-45 write-off on the insurance posting
- **Patient responsibility:** Deductible (PR-1), coinsurance (PR-2), and copay (PR-3) amounts from the service lines are posted as transfer adjustments on the insurance posting (not as separate patient postings)
- **Balance transfers:** Based on the encounter's `next_responsible_party`, remaining balance is transferred to the appropriate party:
  - `
