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:
Tyler
2026-06-21 10:46:10 -06:00
parent f26bbe061a
commit 1942a22629
13 changed files with 838 additions and 3 deletions
+47
View File
@@ -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()