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:
@@ -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).
|
||||
Reference in New Issue
Block a user