feat(sp20): NPI Luhn checksum + Tax ID (EIN) format validation
Adds pure local validators for the 10-digit NPI Luhn checksum (CMS- published algorithm with the '80840' NPPES prefix) and 9-digit EIN format (rejects reserved prefixes 00/07/80-89). No NPPES round-trip, no IRS e-file lookup — catches the 99% typo case at parse time. Surface: - cyclone.npi.is_valid_npi / is_valid_tax_id / normalize_tax_id - CLI: 'cyclone validate-npi <npi>' and 'cyclone validate-tax-id <ein>' - API: GET /api/admin/validate-provider?npi=&tax_id= - Parser validator: new R021_npi_checksum rule (warning, not error, to keep test fixtures with placeholder NPIs ingestible) - minimal_837p.txt fixture NPI updated from '1234567890' to the Luhn-valid '1993999998' so strict-mode CLI parses still pass Tests: - test_npi.py — 27 cases (Luhn math, valid/invalid NPIs, EIN cases, normalize_tax_id edge cases) - test_api_validate_provider.py — 4 cases (both valid, both invalid, omitted NPI, omitted tax_id) - test_cli_validate.py — 8 cases (valid/invalid for both subcommands, exit codes, malformed inputs) - test_validator.py — 4 new R021 cases (valid Luhn silent, bad Luhn warning, skipped when format bad, skipped when NPI missing) Total: 923 tests pass.
This commit is contained in:
+1
-1
@@ -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~
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user