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:
@@ -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 <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 <ein>`, 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
|
||||
|
||||
Reference in New Issue
Block a user