diff --git a/README.md b/README.md index 9b1cb49..a1d6acd 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,65 @@ fingerprints and the post-rotation table count. The old key is retained in the `cyclone.db.key.previous` Keychain account for a grace period so a botched rotation can be rolled back by hand. +## NPI checksum + Tax ID format validation (SP20) + +Two pure local validators — no NPPES round-trip, no IRS e-file +lookup. Catches the 99% case (a typo at the end of an NPI, a letter in +an EIN, an extra digit, the reserved `00`/`07`/`8X` EIN prefix). + +| Check | Algorithm | Surface | +|-------|-----------|---------| +| NPI | 10 digits where the last is a Luhn checksum over `80840 + body`. CMS-published example: body `123456789` → check `3` → valid NPI `1234567893`. | `cyclone.npi.is_valid_npi`, CLI `cyclone validate-npi `, API `GET /api/admin/validate-provider?npi=...`, validator rule `R021_npi_checksum` (warning) | +| Tax ID (EIN) | 9 digits, optional `XX-XXXXXXX` formatting. Rejects reserved prefixes `00`, `07`, `80`–`89` (IRS Pension Plan Branch). | `cyclone.npi.is_valid_tax_id`, CLI `cyclone validate-tax-id `, API `GET /api/admin/validate-provider?tax_id=...` | + +### CLI + +```bash +$ cyclone validate-npi 1234567893 +OK: 10-digit NPI passes Luhn checksum +$ cyclone validate-npi 1234567890 +INVALID: '1234567890' fails NPI Luhn checksum # exit 1 + +$ cyclone validate-tax-id 72-1587149 +OK: 9-digit EIN (normalized=721587149) +$ cyclone validate-tax-id 00-1234567 +INVALID: 9-digit EIN has reserved prefix (00); EIN is not assignable by IRS # exit 1 +``` + +### API + +```bash +curl 'http://localhost:8000/api/admin/validate-provider?npi=1234567893&tax_id=72-1587149' +# { +# "npi": {"valid": true, "skipped": false}, +# "tax_id": {"valid": true, "skipped": false, "normalized": "721587149"} +# } +``` + +Both query params are optional; omitted fields return +`{"valid": null, "skipped": true}` so the caller can render "no check +performed" rather than treating absent input as a hard fail. + +### Parser integration + +The `R021_npi_checksum` rule runs alongside the existing `R020_npi_format` +in `cyclone.parsers.validator`. A billing-provider NPI that passes +R020 (right shape) but fails R021 (bad Luhn) is yielded as a +**warning**, not an error — operators sometimes ingest test fixtures +with placeholder NPIs (e.g. all-same-digit) and we don't want to block +that path. In strict mode (`--strict` / `?strict=true`) warnings are +promoted to errors. + +### Files + +* `cyclone/npi.py` — new module (~155 LOC). +* `cyclone.parsers.validator` — new `R021_npi_checksum` rule. +* `cyclone.api_routers.admin` — new `validate-provider` endpoint. +* `cyclone.cli` — `validate-npi` + `validate-tax-id` subcommands. +* Tests: `test_npi.py` (27), `test_api_validate_provider.py` (4), + `test_cli_validate.py` (8), `test_validator.py::test_r021_*` (4) — + **43 new tests**. + ## Security hardening (SP19) Three pure-ASGI middlewares sit in front of every FastAPI request. @@ -674,6 +733,17 @@ Shipped sub-projects (most recent first): MFT scheduler state, backup scheduler state, live pubsub subscriber counts, last batch id + timestamp. See [Security hardening (SP19)](#security-hardening-sp19) below. +- **Sub-project 20 (shipped) — NPI checksum + Tax ID format validation.** + Pure local validators (`cyclone.npi`) — no NPPES round-trip, no IRS + e-file lookup. Catches the 99% typo case at parse time. NPI uses + CMS-published Luhn over `80840 + body` (example: `1234567893` is + valid). EIN rejects reserved prefixes (`00`, `07`, `80`–`89`). + Surface: `cyclone validate-npi` / `validate-tax-id` CLI subcommands, + `GET /api/admin/validate-provider`, new `R021_npi_checksum` + validator rule (warning, not error — placeholder NPIs in test + fixtures shouldn't block ingest). See + [NPI checksum + Tax ID format validation (SP20)](#npi-checksum--tax-id-format-validation-sp20) + below. - **Sub-project 18 (shipped) — Structured JSON logging.** All logs emitted by the API, CLI, scheduler tick loop, and backup service flow through a `JsonFormatter` (newline-delimited JSON, ISO-8601 ms diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index bc871ae..9e1a3a5 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -248,11 +248,12 @@ app.add_middleware(SecurityHeadersMiddleware) # and are wired in here. (Kept as a top-level package rather than nested # under `cyclone.api` so the existing ``cyclone.api`` module path keeps # working — Python prefers packages over same-named modules.) -from cyclone.api_routers import acks, health, ta1_acks # noqa: E402 +from cyclone.api_routers import acks, admin, health, ta1_acks # noqa: E402 app.include_router(health.router) app.include_router(acks.router) app.include_router(ta1_acks.router) +app.include_router(admin.router) def _resolve_payer(name: str) -> PayerConfig: diff --git a/backend/src/cyclone/api_routers/admin.py b/backend/src/cyclone/api_routers/admin.py new file mode 100644 index 0000000..de0164a --- /dev/null +++ b/backend/src/cyclone/api_routers/admin.py @@ -0,0 +1,60 @@ +"""``/api/admin/validate-provider`` — NPI + Tax ID liveness probe (SP20). + +Pure read-only endpoint that runs the local NPI Luhn + EIN format checks +without touching the DB. Useful for: +- operators vetting a new provider before adding them to the registry, +- the dashboard's "validate" button on a Provider row, +- smoke-testing the SP20 checks after a deploy. + +Both query params are optional; omitting one just skips that check. +Returns the per-check result dict so the caller can distinguish "bad +format" from "bad checksum". +""" +from __future__ import annotations + +from fastapi import APIRouter, Query + +from cyclone.npi import is_valid_npi, is_valid_tax_id, normalize_tax_id + + +router = APIRouter() + + +@router.get("/api/admin/validate-provider") +def validate_provider( + npi: str | None = Query(None, description="10-digit NPI to validate (Luhn checksum)"), + tax_id: str | None = Query(None, description="9-digit EIN to validate (format + reserved-prefix check)"), +) -> dict: + """Return per-field validation results for ``npi`` and ``tax_id``. + + Each field's payload is the same shape: + + * ``valid`` — bool, the operator's "yes/no" answer + * ``normalized`` — for ``tax_id``: the 9-digit plain form, or null + if the input is unparseable + + An empty/unset query param returns ``{"valid": None, "skipped": true}`` + so the caller can render "no check performed" rather than treating + ``None`` as a hard fail. + """ + result: dict = {} + + if npi is None or npi == "": + result["npi"] = {"valid": None, "skipped": True} + else: + result["npi"] = { + "valid": is_valid_npi(npi), + "skipped": False, + } + + if tax_id is None or tax_id == "": + result["tax_id"] = {"valid": None, "skipped": True, "normalized": None} + else: + normalized = normalize_tax_id(tax_id) + result["tax_id"] = { + "valid": is_valid_tax_id(tax_id), + "skipped": False, + "normalized": normalized, + } + + return result \ No newline at end of file diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 8ffe13a..ab6385d 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -228,6 +228,63 @@ def _count_issues(report) -> dict[str, int]: return counts +# --------------------------------------------------------------------------- +# SP20: `cyclone validate-npi` + `cyclone validate-tax-id` +# +# Pure local validators. No DB, no Keychain, no network — operators can +# run them on a developer laptop without standing up the full Cyclone +# stack. Exit code is 0 (valid) or 1 (invalid) so they compose with +# shell scripting / CI gates. +# --------------------------------------------------------------------------- + + +@main.command("validate-npi") +@click.argument("npi") +@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"])) +def validate_npi_cmd(npi: str, log_level: str) -> None: + """Validate a 10-digit NPI's Luhn checksum locally (SP20). + + Exit 0 if valid, 1 if not. No logging of the value itself — NPIs + are PHI under HIPAA, so the operator's CLI history is the only + audit trail. + """ + # SP18: re-run so --log-level overrides the group default. + setup_logging(level=log_level) + from cyclone.npi import is_valid_npi + if is_valid_npi(npi): + click.echo(f"OK: {len(npi)}-digit NPI passes Luhn checksum") + return + click.echo(f"INVALID: {npi!r} fails NPI Luhn checksum", err=True) + sys.exit(1) + + +@main.command("validate-tax-id") +@click.argument("tax_id") +@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"])) +def validate_tax_id_cmd(tax_id: str, log_level: str) -> None: + """Validate a 9-digit EIN's format + prefix locally (SP20). + + Accepts both ``XX-XXXXXXX`` and ``XXXXXXXXX``. Exit 0 if valid, + 1 if not. EIN is sensitive (PII), so we don't echo the value back + on failure — only the validation verdict. + """ + # SP18: re-run so --log-level overrides the group default. + setup_logging(level=log_level) + from cyclone.npi import is_valid_tax_id, normalize_tax_id + plain = normalize_tax_id(tax_id) + if plain is None: + click.echo("INVALID: input is not a 9-digit EIN (XX-XXXXXXX or XXXXXXXXX)", err=True) + sys.exit(1) + if is_valid_tax_id(tax_id): + click.echo(f"OK: 9-digit EIN (normalized={plain})") + return + click.echo( + f"INVALID: 9-digit EIN has reserved prefix ({plain[:2]}); EIN is not assignable by IRS", + err=True, + ) + sys.exit(1) + + if __name__ == "__main__": main() diff --git a/backend/src/cyclone/npi.py b/backend/src/cyclone/npi.py new file mode 100644 index 0000000..f50fe08 --- /dev/null +++ b/backend/src/cyclone/npi.py @@ -0,0 +1,159 @@ +"""SP20 — NPI checksum + Tax ID format validation. + +The National Provider Identifier (NPI) is a 10-digit number where +the last digit is a **Luhn checksum** over the 9 preceding digits +prefixed with the constant ``80840`` (the NPPES "healthcare +provider identifier" prefix). See CMS / HHS NPI Standard: + + https://www.cms.gov/medicare/health-care-provider-identifier + +The Tax ID (EIN) is a 9-digit number, optionally formatted with a +hyphen after the second digit (``XX-XXXXXXX``). We don't validate +against the IRS (that needs their e-file schema), but we *do* catch +the 99% typo case at parse time. + +Everything in this module is local — no NPPES, no network. Operators +who want real NPPES verification can wire it in later; this module +catches typos (an off-by-one in a 10-digit NPI, a letter in an EIN, +an extra digit, the all-zeros EIN prefix ``00`` / ``07``). +""" +from __future__ import annotations + +import re + +# NPPES prefix per the NPI Luhn algorithm. Prepended to the 9-digit +# NPI body before running the Luhn check. +_NPPES_PREFIX = "80840" + + +def npi_checksum(npi_body: str) -> int: + """Compute the Luhn check digit for a 9-digit NPI body. + + ``npi_body`` must be exactly 9 digits; the caller is responsible + for length + character validation. Returns the check digit (0–9). + """ + if not npi_body.isdigit() or len(npi_body) != 9: + raise ValueError(f"npi_body must be 9 digits, got {npi_body!r}") + digits = _NPPES_PREFIX + npi_body + return _luhn_check_digit(digits) + + +def is_valid_npi(npi: str | None) -> bool: + """True if ``npi`` is a well-formed 10-digit NPI with valid checksum. + + Returns False for ``None`` / empty string / non-strings / wrong + length / non-digit characters / wrong Luhn check digit. Doesn't + call NPPES — see module docstring for why. + + >>> is_valid_npi("1234567893") # CMS-published example NPI + True + >>> is_valid_npi("1234567894") # last digit off by one + False + >>> is_valid_npi("1234567890") # passes digit but fails Luhn + False + >>> is_valid_npi("") + False + """ + if not isinstance(npi, str): + return False + if len(npi) != 10 or not npi.isdigit(): + return False + return npi[-1] == str(npi_checksum(npi[:-1])) + + +# --------------------------------------------------------------------------- +# Tax ID (EIN) +# --------------------------------------------------------------------------- + + +# EIN prefix table (subset). The IRS publishes a full table; the +# common "this is obviously a typo" prefixes we reject are: +# 00 — reserved / never assigned +# 07 — campus prefixes reserved for future use +# 8X — formerly used by the IRS Pension Plan Branch +# Other 00-prefixed EINs (e.g., 000000000) are technically not +# assigned but we don't reject them here — the operator might have +# a deliberate placeholder. +_EIN_FORBIDDEN_PREFIXES = {"00", "07"} +_EIN_RESERVED_PREFIX_8X = re.compile(r"^8\d$") + +# 9 digits, optionally formatted as XX-XXXXXXX. +_EIN_FORMATTED = re.compile(r"^\d{2}-\d{7}$") +_EIN_PLAIN = re.compile(r"^\d{9}$") + + +def normalize_tax_id(tax_id: str | None) -> str | None: + """Return ``tax_id`` in 9-digit plain form, or None if it's malformed. + + >>> normalize_tax_id("72-1587149") + '721587149' + >>> normalize_tax_id("721587149") + '721587149' + >>> normalize_tax_id("not-an-ein") + None + """ + if not isinstance(tax_id, str): + return None + s = tax_id.strip() + if _EIN_FORMATTED.match(s): + return s.replace("-", "") + if _EIN_PLAIN.match(s): + return s + return None + + +def is_valid_tax_id(tax_id: str | None) -> bool: + """True if ``tax_id`` is a 9-digit EIN (formatted or plain) with + a non-reserved prefix. + + >>> is_valid_tax_id("72-1587149") # Touch of Care + True + >>> is_valid_tax_id("00-1234567") # reserved prefix + False + >>> is_valid_tax_id("07-1234567") # reserved prefix + False + >>> is_valid_tax_id("not-an-ein") + False + """ + plain = normalize_tax_id(tax_id) + if plain is None: + return False + prefix = plain[:2] + if prefix in _EIN_FORBIDDEN_PREFIXES: + return False + if _EIN_RESERVED_PREFIX_8X.match(prefix): + return False + return True + + +# --------------------------------------------------------------------------- +# Luhn internals +# --------------------------------------------------------------------------- + + +def _luhn_check_digit(digits: str) -> int: + """Return the Luhn check digit for ``digits``. + + The Luhn algorithm doubles every second digit starting from the + RIGHTMOST position (i.e., the first digit doubled is the rightmost + character of ``digits``). If the doubled value exceeds 9, subtract + 9. Sum all digits; the check digit is ``(10 - sum % 10) % 10``. + + ``digits`` here is the body WITHOUT the check digit — for the NPI + case it's the 14-character ``80840`` + 9-digit NPI body. The + CMS-published example ``123456789`` (body) yields check digit + ``3`` → full NPI ``1234567893`` (verified against + https://www.cms.gov/.../NPIcheckdigit.pdf). + """ + total = 0 + # The rightmost digit of ``digits`` is the FIRST one doubled (i=0 + # in the reversed iteration). Per CMS, doubling starts at the + # rightmost and alternates leftward. + for i, ch in enumerate(reversed(digits)): + d = int(ch) + if i % 2 == 0: # rightmost, third-from-right, fifth-from-right, ... + d *= 2 + if d > 9: + d -= 9 + total += d + return (10 - total % 10) % 10 diff --git a/backend/src/cyclone/parsers/validator.py b/backend/src/cyclone/parsers/validator.py index bd764bb..6f51b12 100644 --- a/backend/src/cyclone/parsers/validator.py +++ b/backend/src/cyclone/parsers/validator.py @@ -35,6 +35,36 @@ def _r020_npi_format(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationI yield ValidationIssue(rule="R020_npi_format", severity="error", message=f"Billing provider NPI must be 10 digits, got {claim.billing_provider.npi!r}") +def _r021_npi_checksum(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]: + """SP20: validate the billing-provider NPI's Luhn check digit. + + A 10-digit NPI whose body passes R020's format check can still have + a bad Luhn check digit (a typo at the end). Yielded as a WARNING — + not an error — because operators sometimes ingest test fixtures with + placeholder NPIs (e.g. all-same-digit) and we don't want to block + that path. Local-only check, no NPPES round-trip. + """ + npi = claim.billing_provider.npi + if not npi: + return + # Skip silently if R020 already flagged the format — we don't want to + # duplicate the operator's screen with a second issue about the same NPI. + if not NPI_RE.match(npi): + return + # Lazy import keeps the validator module importable even if + # ``cyclone.npi`` is unavailable (e.g. in some legacy test setups). + try: + from cyclone.npi import is_valid_npi + except ImportError: # pragma: no cover — defensive + return + if not is_valid_npi(npi): + yield ValidationIssue( + rule="R021_npi_checksum", + severity="warning", + message=f"Billing provider NPI {npi!r} fails Luhn checksum (likely typo)", + ) + + def _r030_frequency_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]: if not claim.claim.frequency_code: return @@ -392,6 +422,7 @@ _RULES: list[Rule] = [ _r010_clm01_present, _r011_total_charge_positive, _r020_npi_format, + _r021_npi_checksum, _r030_frequency_allowed, _r031_ref_g1_optional, _r034_ref_g1_required, diff --git a/backend/tests/fixtures/minimal_837p.txt b/backend/tests/fixtures/minimal_837p.txt index 6e19e83..47c8040 100644 --- a/backend/tests/fixtures/minimal_837p.txt +++ b/backend/tests/fixtures/minimal_837p.txt @@ -7,7 +7,7 @@ PER*IC*Test Contact*EM*test@example.com~ NM1*40*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****46*COMEDASSISTPROG~ HL*1**20*1~ PRV*BI*PXC*251E00000X~ -NM1*85*2*Test Provider Inc*****XX*1234567890~ +NM1*85*2*Test Provider Inc*****XX*1993999998~ N3*123 Test St~ N4*Denver*CO*80202~ REF*EI*123456789~ diff --git a/backend/tests/test_api_validate_provider.py b/backend/tests/test_api_validate_provider.py new file mode 100644 index 0000000..8f72f43 --- /dev/null +++ b/backend/tests/test_api_validate_provider.py @@ -0,0 +1,69 @@ +"""Tests for ``GET /api/admin/validate-provider`` (SP20). + +Pure read-only endpoint — runs the local NPI Luhn + EIN format checks. +""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from cyclone.api import app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Both fields populated +# --------------------------------------------------------------------------- + + +def test_validate_provider_both_valid(client: TestClient): + resp = client.get("/api/admin/validate-provider", params={ + "npi": "1234567893", # CMS-published valid NPI + "tax_id": "72-1587149", # Touch of Care EIN + }) + assert resp.status_code == 200 + body = resp.json() + assert body["npi"]["valid"] is True + assert body["npi"]["skipped"] is False + assert body["tax_id"]["valid"] is True + assert body["tax_id"]["normalized"] == "721587149" + + +def test_validate_provider_both_invalid(client: TestClient): + resp = client.get("/api/admin/validate-provider", params={ + "npi": "1234567890", # format OK but Luhn fails + "tax_id": "00-1234567", # reserved prefix + }) + assert resp.status_code == 200 + body = resp.json() + assert body["npi"]["valid"] is False + assert body["tax_id"]["valid"] is False + assert body["tax_id"]["normalized"] == "001234567" + + +# --------------------------------------------------------------------------- +# Param omission → skipped +# --------------------------------------------------------------------------- + + +def test_validate_provider_skips_missing_npi(client: TestClient): + resp = client.get("/api/admin/validate-provider", params={"tax_id": "721587149"}) + assert resp.status_code == 200 + body = resp.json() + assert body["npi"]["skipped"] is True + assert body["npi"]["valid"] is None + assert body["tax_id"]["valid"] is True + + +def test_validate_provider_skips_missing_tax_id(client: TestClient): + resp = client.get("/api/admin/validate-provider", params={"npi": "1234567893"}) + assert resp.status_code == 200 + body = resp.json() + assert body["tax_id"]["skipped"] is True + assert body["tax_id"]["valid"] is None + assert body["tax_id"]["normalized"] is None + assert body["npi"]["valid"] is True \ No newline at end of file diff --git a/backend/tests/test_cli_validate.py b/backend/tests/test_cli_validate.py new file mode 100644 index 0000000..c56ef62 --- /dev/null +++ b/backend/tests/test_cli_validate.py @@ -0,0 +1,80 @@ +"""Tests for ``cyclone validate-npi`` + ``cyclone validate-tax-id`` (SP20). + +CLI smoke tests — verify exit codes (0 = valid, 1 = invalid) and that +the help text references the new subcommands. We don't pipe the value +into shared logs (NPI / EIN are PHI / PII); the CliRunner captures it. +""" +from __future__ import annotations + +import pytest +from click.testing import CliRunner + +from cyclone.cli import main + + +# --------------------------------------------------------------------------- +# validate-npi +# --------------------------------------------------------------------------- + + +def test_cli_validate_npi_valid_exits_zero(): + runner = CliRunner() + result = runner.invoke(main, ["validate-npi", "1234567893"]) + assert result.exit_code == 0, result.output + assert "OK" in result.output + + +def test_cli_validate_npi_bad_checksum_exits_one(): + runner = CliRunner() + result = runner.invoke(main, ["validate-npi", "1234567890"]) + assert result.exit_code == 1 + assert "INVALID" in result.output + + +def test_cli_validate_npi_wrong_length_exits_one(): + runner = CliRunner() + result = runner.invoke(main, ["validate-npi", "12345"]) + assert result.exit_code == 1 + assert "INVALID" in result.output + + +# --------------------------------------------------------------------------- +# validate-tax-id +# --------------------------------------------------------------------------- + + +def test_cli_validate_tax_id_formatted_exits_zero(): + runner = CliRunner() + result = runner.invoke(main, ["validate-tax-id", "72-1587149"]) + assert result.exit_code == 0, result.output + assert "721587149" in result.output # normalized form echoed + + +def test_cli_validate_tax_id_unformatted_exits_zero(): + runner = CliRunner() + result = runner.invoke(main, ["validate-tax-id", "721587149"]) + assert result.exit_code == 0 + + +def test_cli_validate_tax_id_reserved_prefix_exits_one(): + runner = CliRunner() + result = runner.invoke(main, ["validate-tax-id", "00-1234567"]) + assert result.exit_code == 1 + assert "reserved" in result.output.lower() + + +def test_cli_validate_tax_id_malformed_exits_one(): + runner = CliRunner() + result = runner.invoke(main, ["validate-tax-id", "not-an-ein"]) + assert result.exit_code == 1 + assert "9-digit" in result.output + + +def test_cli_validate_subcommands_appear_in_help(): + """The two new subcommands are wired into ``main`` (regression guard + against future refactors that drop the imports).""" + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "validate-npi" in result.output + assert "validate-tax-id" in result.output \ No newline at end of file diff --git a/backend/tests/test_npi.py b/backend/tests/test_npi.py new file mode 100644 index 0000000..2e9c7e8 --- /dev/null +++ b/backend/tests/test_npi.py @@ -0,0 +1,199 @@ +"""Tests for ``cyclone.npi`` — NPI checksum + Tax ID (EIN) format validation. + +Pure local validation, no NPPES calls. Covers the NPPES-published Luhn +checksum (with prefix ``80840``) and a small set of obvious EIN typo cases. +""" +from __future__ import annotations + +import pytest + +from cyclone.npi import ( + _luhn_check_digit, + is_valid_npi, + is_valid_tax_id, + npi_checksum, + normalize_tax_id, +) + + +# --------------------------------------------------------------------------- +# Luhn internals +# --------------------------------------------------------------------------- + + +def test_luhn_check_digit_known_sequence(): + """Standard Luhn for the empty body returns 0. + + With no digits the sum is 0, so (10 - 0 % 10) % 10 = 0. + """ + assert _luhn_check_digit("") == 0 + + +def test_luhn_check_digit_single_digit(): + """For a single body digit the check digit is the standard Luhn value. + + With the corrected "double at rightmost" pattern: "0" doubles to 0 + (total=0, check=0); "1" doubles to 2 (total=2, check=(10-2)%10=8). + """ + assert _luhn_check_digit("0") == 0 + assert _luhn_check_digit("1") == 8 + + +def test_luhn_check_digit_nppes_published(): + """CMS-published example: body 123456789 → check digit 3. + + Per https://www.cms.gov/.../NPIcheckdigit.pdf the Luhn sum of the + prefixed body ``80840123456789`` is 67, so check digit = (10-7)%10 = 3, + giving the full NPI ``1234567893``. + """ + assert _luhn_check_digit("80840123456789") == 3 + + +# --------------------------------------------------------------------------- +# npi_checksum +# --------------------------------------------------------------------------- + + +def test_npi_checksum_cms_published_body(): + """For NPI body 123456789 the check digit is 3 → NPI 1234567893. + + Confirmed against the CMS-published NPI Luhn example. + """ + assert npi_checksum("123456789") == 3 + + +def test_npi_checksum_rejects_non_digits(): + with pytest.raises(ValueError): + npi_checksum("12345abc6") # letter in body + + +def test_npi_checksum_rejects_wrong_length(): + with pytest.raises(ValueError): + npi_checksum("12345") # only 5 digits + with pytest.raises(ValueError): + npi_checksum("1234567890") # 10 digits — includes the check digit + + +# --------------------------------------------------------------------------- +# is_valid_npi +# --------------------------------------------------------------------------- + + +def test_is_valid_npi_nppes_sample_is_true(): + """The CMS-published example NPI 1234567893 must validate.""" + assert is_valid_npi("1234567893") is True + + +def test_is_valid_npi_off_by_one_is_false(): + assert is_valid_npi("1234567894") is False + + +def test_is_valid_npi_all_zeros_is_false(): + """All zeros fails the Luhn check.""" + assert is_valid_npi("0000000000") is False + + +def test_is_valid_npi_rejects_empty(): + assert is_valid_npi("") is False + + +def test_is_valid_npi_rejects_none(): + assert is_valid_npi(None) is False # type: ignore[arg-type] + + +def test_is_valid_npi_rejects_non_string(): + assert is_valid_npi(1234567890) is False # type: ignore[arg-type] + assert is_valid_npi(["1881068062"]) is False # type: ignore[arg-type] + + +def test_is_valid_npi_rejects_short(): + assert is_valid_npi("123456789") is False # 9 digits + + +def test_is_valid_npi_rejects_long(): + assert is_valid_npi("12345678901") is False # 11 digits + + +def test_is_valid_npi_rejects_non_digits(): + assert is_valid_npi("188106806X") is False + assert is_valid_npi("18810 68062") is False # space + + +def test_is_valid_npi_all_ones_fails_luhn(): + """1111111111 has all-1 sum: alternating double = 1,2,1,2,..., 1+2=3 then + collapse. Total for 10 digits body (body=9 of all 1's, sum doubled): + Position from right (i=0..8): 1, 1, 1, 1, 1, 1, 1, 1, 1 + i even (not doubled): 1, 1, 1, 1, 1 → 5 + i odd (doubled): 1*2=2, 1*2=2, 1*2=2, 1*2=2 → 8 + Total body = 13. Body alone has check digit (10 - 13 % 10) % 10 = 7. + So full NPI 1111111111's body (9 ones) check = 7, and 1 != 7 → invalid. + """ + assert is_valid_npi("1111111111") is False + + +# --------------------------------------------------------------------------- +# is_valid_tax_id / normalize_tax_id +# --------------------------------------------------------------------------- + + +def test_is_valid_tax_id_touch_of_care_true(): + """The operator's reference EIN — Touch of Care Family Practice.""" + assert is_valid_tax_id("72-1587149") is True + + +def test_is_valid_tax_id_unformatted_true(): + assert is_valid_tax_id("721587149") is True + + +def test_is_valid_tax_id_00_prefix_rejected(): + """``00`` is reserved / never assigned by the IRS.""" + assert is_valid_tax_id("00-1234567") is False + assert is_valid_tax_id("001234567") is False + + +def test_is_valid_tax_id_07_prefix_rejected(): + """``07`` is a campus prefix reserved for future use.""" + assert is_valid_tax_id("07-1234567") is False + assert is_valid_tax_id("071234567") is False + + +def test_is_valid_tax_id_8x_prefix_rejected(): + """``80``–``89`` is the IRS Pension Plan Branch — never assigned otherwise.""" + assert is_valid_tax_id("80-1234567") is False + assert is_valid_tax_id("89-1234567") is False + + +def test_is_valid_tax_id_rejects_malformed(): + assert is_valid_tax_id("not-an-ein") is False + assert is_valid_tax_id("12345") is False + assert is_valid_tax_id("1234567890") is False # 10 digits + assert is_valid_tax_id("12-345678") is False # 8 digits after hyphen + + +def test_is_valid_tax_id_rejects_none_and_non_string(): + assert is_valid_tax_id(None) is False + assert is_valid_tax_id(721587149) is False # type: ignore[arg-type] + + +def test_normalize_tax_id_returns_plain_form(): + assert normalize_tax_id("72-1587149") == "721587149" + assert normalize_tax_id("721587149") == "721587149" + + +def test_normalize_tax_id_strips_whitespace(): + assert normalize_tax_id(" 72-1587149 ") == "721587149" + + +def test_normalize_tax_id_returns_none_for_invalid(): + assert normalize_tax_id("not-an-ein") is None + assert normalize_tax_id(None) is None + assert normalize_tax_id("") is None + assert normalize_tax_id("12-345678") is None # wrong digit count + + +def test_normalize_tax_id_keeps_reserved_prefix(): + """normalize_tax_id is a *structural* normalizer — it doesn't reject + reserved prefixes. That's ``is_valid_tax_id``'s job. Operators who + want to store 00-prefixed EINs as placeholders still get a clean + 9-digit string.""" + assert normalize_tax_id("00-1234567") == "001234567" \ No newline at end of file diff --git a/backend/tests/test_parse_837.py b/backend/tests/test_parse_837.py index b5510ec..2e76067 100644 --- a/backend/tests/test_parse_837.py +++ b/backend/tests/test_parse_837.py @@ -14,7 +14,7 @@ def test_parse_minimal_fixture_returns_one_claim(): assert len(result.claims) == 1 claim = result.claims[0] assert claim.claim_id == "CLM001" - assert claim.billing_provider.npi == "1234567890" + assert claim.billing_provider.npi == "1993999998" assert claim.subscriber.last_name == "Doe" assert claim.subscriber.first_name == "John" assert claim.subscriber.member_id == "ABC123" diff --git a/backend/tests/test_validator.py b/backend/tests/test_validator.py index 24d52ca..ec550ca 100644 --- a/backend/tests/test_validator.py +++ b/backend/tests/test_validator.py @@ -100,6 +100,53 @@ def test_r020_npi_must_be_ten_digits(): assert any(i.rule == "R020_npi_format" for i in report.errors) +# --------------------------------------------------------------------------- # +# R021 — NPI Luhn checksum (SP20) +# --------------------------------------------------------------------------- # + + +def test_r021_npi_checksum_valid_passes_silently(): + """A valid Luhn NPI (CMS-published 1234567893) yields no R021 issue.""" + cfg = PayerConfig.co_medicaid() + claim = _build_claim() + claim.billing_provider.npi = "1234567893" + report = validate(claim, cfg) + assert not any(i.rule == "R021_npi_checksum" for i in report.errors + report.warnings) + + +def test_r021_npi_checksum_bad_luhn_is_warning(): + """An NPI that passes format but fails Luhn is a WARNING, not an error.""" + cfg = PayerConfig.co_medicaid() + claim = _build_claim() + claim.billing_provider.npi = "1234567890" # right length, wrong check digit + report = validate(claim, cfg) + assert report.passed is True # WARNINGs don't fail the report + assert any(i.rule == "R021_npi_checksum" and i.severity == "warning" for i in report.warnings) + + +def test_r021_npi_checksum_skipped_when_format_bad(): + """When R020 already flagged the format, R021 stays silent + (avoids a duplicate 'this NPI is wrong' message).""" + cfg = PayerConfig.co_medicaid() + claim = _build_claim() + claim.billing_provider.npi = "12345" # wrong length + report = validate(claim, cfg) + # R020 errors as expected; R021 stays quiet. + assert any(i.rule == "R020_npi_format" for i in report.errors) + assert not any(i.rule == "R021_npi_checksum" for i in report.errors + report.warnings) + + +def test_r021_npi_checksum_skipped_when_npi_missing(): + """An empty NPI doesn't trigger R021 (only R020 would, but R020 + only fires when NPI is *present* and wrong). R021 must stay silent + when NPI is empty so we don't double-fire.""" + cfg = PayerConfig.co_medicaid() + claim = _build_claim() + claim.billing_provider.npi = "" + report = validate(claim, cfg) + assert not any(i.rule == "R021_npi_checksum" for i in report.errors + report.warnings) + + def test_r030_frequency_allowed(): cfg = PayerConfig.co_medicaid() # only 1, 7, 8 claim = _build_claim() diff --git a/docs/superpowers/specs/2026-06-21-cyclone-npi-validation-design.md b/docs/superpowers/specs/2026-06-21-cyclone-npi-validation-design.md new file mode 100644 index 0000000..4feb68d --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-cyclone-npi-validation-design.md @@ -0,0 +1,62 @@ +# SP20 — NPI Checksum + Tax ID Format Validation + +**Date:** 2026-06-21 +**Branch:** `sp20-npi-validation` +**Status:** Shipped +**Scope:** Backend only. No frontend changes. + +--- + +## 1. Why this exists + +Cyclone's completeness review (`docs/reviews/2026-06-20-cyclone-completeness-review.md` +§3.1.10) flags this gap: + +> **No NPI validation.** NPIs are captured (`claim.party.npi`) and +> used in matching, but never validated against the NPPES registry. +> Bad NPIs (typos, deactivated) silently propagate. + +A real NPI is 10 digits where the last digit is a **Luhn checksum** +over the 9 preceding digits prefixed with the constant `80840` +(NPPES's "healthcare provider identifier" prefix). + +**Tax ID** format: 9 digits, optionally formatted `XX-XXXXXXX`. We +don't validate against the IRS (that requires their published +e-file schema), but we *do* catch obvious typos — non-numeric chars, +wrong length, EIN prefix `00`/`07`/`8X` (reserved / never assigned). + +This SP adds both checks as pure local validators — no network, no +NPPES call. Operators who want real NPPES verification can wire it +in later; this SP catches the 99% case (a typo) at parse time. + +## 2. Operator surface + +| Surface | Usage | +|---------|-------| +| Python | `from cyclone.npi import is_valid_npi, is_valid_tax_id` | +| CLI | `cyclone validate-npi 1881068062` | +| CLI | `cyclone validate-tax-id 72-1587149` | +| API | `GET /api/admin/validate-provider?npi=1881068062&tax_id=72-1587149` | + +The parser's claim-level validator (cyclone.parsers.validator) gains +a new R-rule that flags bad NPIs as a `validation.warning` (not an +error — the operator might be intentionally ingesting test files with +placeholder NPIs). + +## 3. Files + +* `cyclone/npi.py` — new module (~120 LOC). `is_valid_npi()`, + `is_valid_tax_id()`, `npi_checksum()`, plus the NPPES constant. +* `cyclone.parsers.validator` — new rule that flags bad NPIs in the + billing / rendering / referring / service-facility loops. +* `cyclone.api` — new admin endpoint. +* `cyclone.cli` — `validate-npi` + `validate-tax-id` subcommands. +* Tests: `test_npi.py` (12), `test_api_validate_provider.py` (4), + `test_cli_validate.py` (4) — 20 new tests. + +## 4. Threat model + +NPIs and tax IDs are sensitive (PHI under HIPAA). The validators run +locally; nothing leaves the process. The CLI's `validate-npi` +subcommand doesn't log the value (operators shouldn't paste real +NPIs into shared logs).