feat(npi): healthcare marketing pages, nav dropdown, NPI lookup API + free tool + companion data migration/loader
This commit is contained in:
parent
f349d519c6
commit
4b0155542e
9 changed files with 1176 additions and 2 deletions
69
api/migrations/088_npi_compliance_data.sql
Normal file
69
api/migrations/088_npi_compliance_data.sql
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────
|
||||
-- NPI / Healthcare provider compliance lookup data.
|
||||
--
|
||||
-- Powers /tools/npi-compliance-check. The live identity/status comes from
|
||||
-- the free public NPPES Registry API at query time; these tables hold the
|
||||
-- *dateable hooks* and exclusion data that the public NPI API does NOT
|
||||
-- expose, all keyed by NPI:
|
||||
--
|
||||
-- npi_revalidation_due — CMS Medicare revalidation due dates (the flagship
|
||||
-- dateable hook; ~218k providers are PAST DUE).
|
||||
-- npi_exclusions — OIG LEIE exclusion list (matched by NPI when present).
|
||||
-- npi_optout — Medicare opt-out affidavits (end dates).
|
||||
--
|
||||
-- Source files (free, public):
|
||||
-- data.cms.gov Revalidation Due List, Medicare Opt Out
|
||||
-- oig.hhs.gov LEIE downloadable database
|
||||
-- ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS npi_revalidation_due (
|
||||
id SERIAL PRIMARY KEY,
|
||||
npi TEXT NOT NULL,
|
||||
enrollment_id TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
organization_name TEXT,
|
||||
enrollment_state TEXT,
|
||||
enrollment_type TEXT, -- CMS enrollment type code
|
||||
provider_type TEXT,
|
||||
specialty TEXT,
|
||||
revalidation_due_date DATE, -- NULL = "TBD" in CMS data
|
||||
adjusted_due_date DATE,
|
||||
reassign_to TEXT,
|
||||
receiving_reassignment TEXT,
|
||||
loaded_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_npi_reval_npi ON npi_revalidation_due (npi);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS npi_exclusions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
npi TEXT, -- '0000000000' / blank when OIG has none
|
||||
last_name TEXT,
|
||||
first_name TEXT,
|
||||
middle_name TEXT,
|
||||
business_name TEXT,
|
||||
general_category TEXT,
|
||||
specialty TEXT,
|
||||
state TEXT,
|
||||
exclusion_type TEXT, -- e.g. 1128a1
|
||||
exclusion_date DATE,
|
||||
reinstatement_date DATE, -- NULL when still excluded
|
||||
loaded_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_npi_excl_npi ON npi_exclusions (npi) WHERE npi IS NOT NULL AND npi <> '0000000000';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS npi_optout (
|
||||
id SERIAL PRIMARY KEY,
|
||||
npi TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
specialty TEXT,
|
||||
optout_effective_date DATE,
|
||||
optout_end_date DATE,
|
||||
state TEXT,
|
||||
loaded_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_npi_optout_npi ON npi_optout (npi);
|
||||
|
||||
-- Reuse the existing free-tool logging table (compliance_check_log) for NPI
|
||||
-- checks; no schema change needed there.
|
||||
|
|
@ -47,6 +47,7 @@ import portalEsignGenericRouter from "./routes/portal-esign-generic.js";
|
|||
import pucRouter from "./routes/puc.js";
|
||||
import fccCarrierRegRouter from "./routes/fcc-carrier-registration.js";
|
||||
import dotLookupRouter from "./routes/dot-lookup.js";
|
||||
import npiLookupRouter from "./routes/npi-lookup.js";
|
||||
import surveyRouter from "./routes/survey.js";
|
||||
import orderTimelineRouter from "./routes/order-timeline.js";
|
||||
|
||||
|
|
@ -122,6 +123,7 @@ app.use(foreignQualRouter);
|
|||
app.use(pucRouter);
|
||||
app.use(fccCarrierRegRouter);
|
||||
app.use(dotLookupRouter);
|
||||
app.use(npiLookupRouter);
|
||||
app.use(surveyRouter);
|
||||
app.use(orderTimelineRouter);
|
||||
app.use(adminCryptoRouter);
|
||||
|
|
|
|||
361
api/src/routes/npi-lookup.ts
Normal file
361
api/src/routes/npi-lookup.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
/**
|
||||
* NPI / Healthcare provider compliance lookup.
|
||||
*
|
||||
* GET /api/v1/npi/lookup?npi=1234567893
|
||||
* GET /api/v1/npi/search?name=Acme+Clinic&state=CA
|
||||
*
|
||||
* Combines the free public NPPES Registry API (live identity + status) with
|
||||
* local CMS/OIG companion tables (revalidation due dates, exclusions, opt-out)
|
||||
* to produce a green/yellow/red compliance summary, mirroring dot-lookup.ts.
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { pool } from "../db.js";
|
||||
|
||||
const router = Router();
|
||||
const NPPES_BASE = "https://npiregistry.cms.hhs.gov/api/";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function nppesFetch(params: Record<string, string>): Promise<any> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 6000);
|
||||
try {
|
||||
const qs = new URLSearchParams({ version: "2.1", ...params }).toString();
|
||||
const resp = await fetch(`${NPPES_BASE}?${qs}`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type CheckStatus = "green" | "yellow" | "red" | "unknown";
|
||||
|
||||
interface ComplianceCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
status: CheckStatus;
|
||||
detail: string;
|
||||
action_url?: string | null;
|
||||
}
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.round((a.getTime() - b.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
function fmtDate(d: Date | null): string {
|
||||
if (!d) return "—";
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function nppesName(basic: any): string {
|
||||
if (!basic) return "Unknown provider";
|
||||
if (basic.organization_name) return basic.organization_name;
|
||||
const parts = [basic.first_name, basic.middle_name, basic.last_name].filter(Boolean);
|
||||
const name = parts.join(" ").trim();
|
||||
return name || basic.name || "Unknown provider";
|
||||
}
|
||||
|
||||
// ── Lookup by NPI ───────────────────────────────────────────────────
|
||||
|
||||
router.get("/api/v1/npi/lookup", async (req, res) => {
|
||||
const rawNpi = String(req.query.npi || "").replace(/\D/g, "");
|
||||
if (!/^\d{10}$/.test(rawNpi)) {
|
||||
res.status(400).json({ error: "Provide a valid 10-digit NPI." });
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
// 1) Live NPPES identity + status
|
||||
const nppes = await nppesFetch({ number: rawNpi });
|
||||
const result = nppes?.results?.[0] || null;
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({
|
||||
error: "NPI not found in NPPES.",
|
||||
npi: rawNpi,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const basic = result.basic || {};
|
||||
const name = nppesName(basic);
|
||||
const status = basic.status || null; // "A" active, "D"/null = deactivated
|
||||
const enumType = result.enumeration_type || null; // NPI-1 individual, NPI-2 org
|
||||
const lastUpdated = basic.last_updated ? new Date(basic.last_updated) : null;
|
||||
const enumerationDate = basic.enumeration_date ? new Date(basic.enumeration_date) : null;
|
||||
const deactivationDate = basic.deactivation_date ? new Date(basic.deactivation_date) : null;
|
||||
|
||||
const primaryTaxonomy = (result.taxonomies || []).find((t: any) => t.primary) || (result.taxonomies || [])[0] || null;
|
||||
const locationAddr = (result.addresses || []).find((a: any) => a.address_purpose === "LOCATION") || (result.addresses || [])[0] || null;
|
||||
const practiceState = locationAddr?.state || null;
|
||||
|
||||
// 2) Companion data joins (best-effort; tables may be empty pre-load)
|
||||
const [revalRes, exclRes, optoutRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT revalidation_due_date, adjusted_due_date, enrollment_type, specialty, enrollment_state
|
||||
FROM npi_revalidation_due WHERE npi = $1 ORDER BY id LIMIT 1`,
|
||||
[rawNpi]
|
||||
).catch(() => ({ rows: [] as any[] })),
|
||||
pool.query(
|
||||
`SELECT exclusion_type, exclusion_date, reinstatement_date, general_category
|
||||
FROM npi_exclusions
|
||||
WHERE npi = $1 AND npi <> '0000000000'
|
||||
ORDER BY exclusion_date DESC NULLS LAST LIMIT 1`,
|
||||
[rawNpi]
|
||||
).catch(() => ({ rows: [] as any[] })),
|
||||
pool.query(
|
||||
`SELECT optout_effective_date, optout_end_date, specialty
|
||||
FROM npi_optout WHERE npi = $1 ORDER BY optout_end_date DESC NULLS LAST LIMIT 1`,
|
||||
[rawNpi]
|
||||
).catch(() => ({ rows: [] as any[] })),
|
||||
]);
|
||||
|
||||
const reval = revalRes.rows[0] || null;
|
||||
const excl = exclRes.rows[0] || null;
|
||||
const optout = optoutRes.rows[0] || null;
|
||||
|
||||
const now = new Date();
|
||||
const checks: ComplianceCheck[] = [];
|
||||
|
||||
// ── Check 1: NPI active status ──────────────────────────────────
|
||||
if (status === "A" && !deactivationDate) {
|
||||
checks.push({
|
||||
id: "npi-active",
|
||||
label: "NPI registration",
|
||||
status: "green",
|
||||
detail: "Your NPI is active in NPPES.",
|
||||
action_url: null,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "npi-active",
|
||||
label: "NPI registration",
|
||||
status: "red",
|
||||
detail: deactivationDate
|
||||
? `Your NPI was deactivated on ${fmtDate(deactivationDate)}. You cannot bill until it is reactivated.`
|
||||
: "Your NPI is not active in NPPES. Reactivation is required before you can bill.",
|
||||
action_url: "/order/npi-reactivation",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 2: Medicare revalidation (the dateable hook) ──────────
|
||||
if (reval) {
|
||||
const due = reval.adjusted_due_date || reval.revalidation_due_date;
|
||||
if (!due) {
|
||||
checks.push({
|
||||
id: "revalidation",
|
||||
label: "Medicare revalidation",
|
||||
status: "yellow",
|
||||
detail: "CMS lists you as enrolled but has not yet set a revalidation due date (\"TBD\"). We can monitor it for you.",
|
||||
action_url: "/services/healthcare/npi-revalidation",
|
||||
});
|
||||
} else {
|
||||
const dueDate = new Date(due);
|
||||
const daysToD = daysBetween(dueDate, now);
|
||||
if (daysToD < 0) {
|
||||
checks.push({
|
||||
id: "revalidation",
|
||||
label: "Medicare revalidation",
|
||||
status: "red",
|
||||
detail: `Your Medicare revalidation was due ${fmtDate(dueDate)} — ${Math.abs(daysToD)} days ago. CMS may deactivate your billing privileges. File now.`,
|
||||
action_url: "/order/npi-revalidation",
|
||||
});
|
||||
} else if (daysToD <= 180) {
|
||||
checks.push({
|
||||
id: "revalidation",
|
||||
label: "Medicare revalidation",
|
||||
status: "yellow",
|
||||
detail: `Your Medicare revalidation is due ${fmtDate(dueDate)} — ${daysToD} days away. File before the deadline to avoid deactivation.`,
|
||||
action_url: "/order/npi-revalidation",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "revalidation",
|
||||
label: "Medicare revalidation",
|
||||
status: "green",
|
||||
detail: `Your next Medicare revalidation is due ${fmtDate(dueDate)}.`,
|
||||
action_url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
checks.push({
|
||||
id: "revalidation",
|
||||
label: "Medicare revalidation",
|
||||
status: "unknown",
|
||||
detail: "We did not find an active Medicare revalidation record for this NPI. If you bill Medicare, confirm your enrollment status in PECOS.",
|
||||
action_url: "/services/healthcare/medicare-enrollment",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 3: OIG / SAM exclusion ────────────────────────────────
|
||||
if (excl && !excl.reinstatement_date) {
|
||||
checks.push({
|
||||
id: "exclusion",
|
||||
label: "OIG exclusion screening",
|
||||
status: "red",
|
||||
detail: `This NPI matches an active OIG exclusion (${excl.exclusion_type || "type unknown"}, excluded ${fmtDate(excl.exclusion_date ? new Date(excl.exclusion_date) : null)}). Excluded providers cannot bill federal healthcare programs.`,
|
||||
action_url: "/order/oig-sam-screening",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "exclusion",
|
||||
label: "OIG exclusion screening",
|
||||
status: "green",
|
||||
detail: "No active OIG exclusion found for this NPI. Annual screening of your staff is still recommended.",
|
||||
action_url: "/order/oig-sam-screening",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 4: NPPES record freshness ─────────────────────────────
|
||||
if (lastUpdated) {
|
||||
const staleDays = daysBetween(now, lastUpdated);
|
||||
if (staleDays > 365 * 2) {
|
||||
checks.push({
|
||||
id: "nppes-fresh",
|
||||
label: "NPPES record currency",
|
||||
status: "yellow",
|
||||
detail: `Your NPPES record was last updated ${fmtDate(lastUpdated)} — over 2 years ago. CMS requires updates within 30 days of any change. Re-attest to keep it current.`,
|
||||
action_url: "/order/nppes-update",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: "nppes-fresh",
|
||||
label: "NPPES record currency",
|
||||
status: "green",
|
||||
detail: `Your NPPES record was last updated ${fmtDate(lastUpdated)}.`,
|
||||
action_url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 5: Medicare opt-out ───────────────────────────────────
|
||||
if (optout && optout.optout_end_date) {
|
||||
const endDate = new Date(optout.optout_end_date);
|
||||
const daysToEnd = daysBetween(endDate, now);
|
||||
if (daysToEnd >= 0 && daysToEnd <= 365) {
|
||||
checks.push({
|
||||
id: "optout",
|
||||
label: "Medicare opt-out",
|
||||
status: "yellow",
|
||||
detail: `Your Medicare opt-out affidavit ends ${fmtDate(endDate)}. Opt-out auto-renews every 2 years unless you act; if you want to re-enroll, plan ahead.`,
|
||||
action_url: "/services/healthcare/medicare-enrollment",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const redCount = checks.filter((c) => c.status === "red").length;
|
||||
const yellowCount = checks.filter((c) => c.status === "yellow").length;
|
||||
const greenCount = checks.filter((c) => c.status === "green").length;
|
||||
const severity = redCount > 0 ? "critical" : yellowCount > 0 ? "major" : "clean";
|
||||
|
||||
// Fire-and-forget logging (reuse compliance_check_log; NPI stored in frn col)
|
||||
pool.query(
|
||||
`INSERT INTO compliance_check_log
|
||||
(frn, entity_name, ip_address, user_agent, referrer, total_checks, issues_found, severity, check_slugs, response_ms)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[
|
||||
rawNpi,
|
||||
name,
|
||||
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || null,
|
||||
req.headers["user-agent"] || null,
|
||||
req.headers["referer"] || null,
|
||||
checks.length,
|
||||
redCount + yellowCount,
|
||||
severity,
|
||||
checks.filter((c) => c.status === "red" || c.status === "yellow").map((c) => c.id),
|
||||
Date.now() - startedAt,
|
||||
]
|
||||
).catch(() => {});
|
||||
|
||||
res.json({
|
||||
npi: rawNpi,
|
||||
name,
|
||||
enumeration_type: enumType,
|
||||
status,
|
||||
taxonomy: primaryTaxonomy
|
||||
? { code: primaryTaxonomy.code, desc: primaryTaxonomy.desc, state: primaryTaxonomy.state, license: primaryTaxonomy.license }
|
||||
: null,
|
||||
practice_state: practiceState,
|
||||
enumeration_date: enumerationDate ? fmtDate(enumerationDate) : null,
|
||||
last_updated: lastUpdated ? fmtDate(lastUpdated) : null,
|
||||
checks,
|
||||
summary: {
|
||||
red: redCount,
|
||||
yellow: yellowCount,
|
||||
green: greenCount,
|
||||
total: checks.length,
|
||||
},
|
||||
severity,
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[npi-lookup] Error:", err);
|
||||
if (!res.headersSent) res.status(500).json({ error: "NPI lookup failed." });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Name search ─────────────────────────────────────────────────────
|
||||
|
||||
router.get("/api/v1/npi/search", async (req, res) => {
|
||||
const name = String(req.query.name || "").trim();
|
||||
const state = String(req.query.state || "").trim().toUpperCase();
|
||||
if (name.length < 2) {
|
||||
res.status(400).json({ error: "Provide a name (at least 2 characters)." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// NPPES supports org name and last name search; try organization first,
|
||||
// then individual last name. Wildcard with trailing * for partials.
|
||||
const params: Record<string, string> = { limit: "15" };
|
||||
if (state && state.length === 2) params.state = state;
|
||||
|
||||
// Heuristic: single token -> last_name; otherwise org name.
|
||||
const tokens = name.split(/\s+/);
|
||||
if (tokens.length === 1) {
|
||||
params.last_name = name + "*";
|
||||
} else {
|
||||
params.organization_name = name + "*";
|
||||
}
|
||||
|
||||
let nppes = await nppesFetch(params);
|
||||
// Fallback: if org search empty, try last name
|
||||
if (!nppes?.results?.length && !params.last_name) {
|
||||
delete params.organization_name;
|
||||
params.last_name = tokens[tokens.length - 1] + "*";
|
||||
nppes = await nppesFetch(params);
|
||||
}
|
||||
|
||||
const results = (nppes?.results || []).map((r: any) => {
|
||||
const b = r.basic || {};
|
||||
const loc = (r.addresses || []).find((a: any) => a.address_purpose === "LOCATION") || (r.addresses || [])[0] || {};
|
||||
const tax = (r.taxonomies || []).find((t: any) => t.primary) || (r.taxonomies || [])[0] || {};
|
||||
return {
|
||||
npi: r.number,
|
||||
name: nppesName(b),
|
||||
enumeration_type: r.enumeration_type,
|
||||
status: b.status,
|
||||
taxonomy: tax.desc || null,
|
||||
city: loc.city || null,
|
||||
state: loc.state || null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ query: name, count: results.length, results });
|
||||
} catch (err) {
|
||||
console.error("[npi-search] Error:", err);
|
||||
if (!res.headersSent) res.status(500).json({ error: "NPI search failed." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
168
scripts/load_npi_companion_data.py
Normal file
168
scripts/load_npi_companion_data.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Load CMS/OIG NPI companion data into Postgres for the NPI compliance check.
|
||||
|
||||
Populates:
|
||||
npi_revalidation_due <- CMS Revalidation Due List
|
||||
npi_exclusions <- OIG LEIE
|
||||
npi_optout <- CMS Medicare Opt Out
|
||||
|
||||
Usage:
|
||||
DATABASE_URL=postgresql://... python3 scripts/load_npi_companion_data.py \
|
||||
--dir /tmp/npi_companion
|
||||
|
||||
Source CSVs (free/public):
|
||||
revalidation_due.csv data.cms.gov Medicare Revalidation Due List
|
||||
leie.csv oig.hhs.gov LEIE downloadable database
|
||||
optout.csv data.cms.gov Medicare Opt Out
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://pw:pw@localhost:5432/performancewest")
|
||||
|
||||
|
||||
def parse_date(s):
|
||||
if not s:
|
||||
return None
|
||||
s = s.strip()
|
||||
if not s or s in ("00000000", "TBD"):
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%Y%m%d", "%m/%d/%y"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def clean_npi(s):
|
||||
s = (s or "").strip()
|
||||
return s if s and s != "0000000000" and len(s) == 10 and s.isdigit() else (s or None)
|
||||
|
||||
|
||||
def load_revalidation(conn, path):
|
||||
rows = []
|
||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||
for r in csv.DictReader(f):
|
||||
npi = (r.get("National Provider Identifier") or "").strip()
|
||||
if not (npi.isdigit() and len(npi) == 10):
|
||||
continue
|
||||
rows.append((
|
||||
npi,
|
||||
(r.get("Enrollment ID") or "").strip() or None,
|
||||
(r.get("First Name") or "").strip() or None,
|
||||
(r.get("Last Name") or "").strip() or None,
|
||||
(r.get("Organization Name") or "").strip() or None,
|
||||
(r.get("Enrollment State Code") or "").strip() or None,
|
||||
(r.get("Enrollment Type") or "").strip() or None,
|
||||
(r.get("Provider Type Text") or "").strip() or None,
|
||||
(r.get("Enrollment Specialty") or "").strip() or None,
|
||||
parse_date(r.get("Revalidation Due Date")),
|
||||
parse_date(r.get("Adjusted Due Date")),
|
||||
(r.get("Individual Total Reassign To") or "").strip() or None,
|
||||
(r.get("Receiving Benefits Reassignment") or "").strip() or None,
|
||||
))
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("TRUNCATE npi_revalidation_due RESTART IDENTITY")
|
||||
execute_values(cur, """
|
||||
INSERT INTO npi_revalidation_due
|
||||
(npi, enrollment_id, first_name, last_name, organization_name,
|
||||
enrollment_state, enrollment_type, provider_type, specialty,
|
||||
revalidation_due_date, adjusted_due_date, reassign_to, receiving_reassignment)
|
||||
VALUES %s
|
||||
""", rows, page_size=5000)
|
||||
conn.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
def load_exclusions(conn, path):
|
||||
rows = []
|
||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||
for r in csv.DictReader(f):
|
||||
rows.append((
|
||||
clean_npi(r.get("NPI")),
|
||||
(r.get("LASTNAME") or "").strip() or None,
|
||||
(r.get("FIRSTNAME") or "").strip() or None,
|
||||
(r.get("MIDNAME") or "").strip() or None,
|
||||
(r.get("BUSNAME") or "").strip() or None,
|
||||
(r.get("GENERAL") or "").strip() or None,
|
||||
(r.get("SPECIALTY") or "").strip() or None,
|
||||
(r.get("STATE") or "").strip() or None,
|
||||
(r.get("EXCLTYPE") or "").strip() or None,
|
||||
parse_date(r.get("EXCLDATE")),
|
||||
parse_date(r.get("REINDATE")),
|
||||
))
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("TRUNCATE npi_exclusions RESTART IDENTITY")
|
||||
execute_values(cur, """
|
||||
INSERT INTO npi_exclusions
|
||||
(npi, last_name, first_name, middle_name, business_name,
|
||||
general_category, specialty, state, exclusion_type,
|
||||
exclusion_date, reinstatement_date)
|
||||
VALUES %s
|
||||
""", rows, page_size=5000)
|
||||
conn.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
def load_optout(conn, path):
|
||||
rows = []
|
||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||
for r in csv.DictReader(f):
|
||||
npi = (r.get("npi") or r.get("NPI") or "").strip()
|
||||
if not (npi.isdigit() and len(npi) == 10):
|
||||
continue
|
||||
rows.append((
|
||||
npi,
|
||||
(r.get("First Name") or "").strip() or None,
|
||||
(r.get("Last Name") or "").strip() or None,
|
||||
(r.get("Specialty") or "").strip() or None,
|
||||
parse_date(r.get("Optout Effective Date")),
|
||||
parse_date(r.get("Optout End Date")),
|
||||
(r.get("State Code") or "").strip() or None,
|
||||
))
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("TRUNCATE npi_optout RESTART IDENTITY")
|
||||
execute_values(cur, """
|
||||
INSERT INTO npi_optout
|
||||
(npi, first_name, last_name, specialty,
|
||||
optout_effective_date, optout_end_date, state)
|
||||
VALUES %s
|
||||
""", rows, page_size=5000)
|
||||
conn.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dir", default="/tmp/npi_companion")
|
||||
args = ap.parse_args()
|
||||
|
||||
conn = psycopg2.connect(DATABASE_URL)
|
||||
try:
|
||||
jobs = [
|
||||
("revalidation_due.csv", load_revalidation),
|
||||
("leie.csv", load_exclusions),
|
||||
("optout.csv", load_optout),
|
||||
]
|
||||
for fname, fn in jobs:
|
||||
path = os.path.join(args.dir, fname)
|
||||
if not os.path.exists(path):
|
||||
print(f" SKIP {fname} (not found at {path})")
|
||||
continue
|
||||
n = fn(conn, path)
|
||||
print(f" loaded {n:,} rows from {fname}")
|
||||
finally:
|
||||
conn.close()
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
94
site/src/pages/services/healthcare/index.astro
Normal file
94
site/src/pages/services/healthcare/index.astro
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
const title = "Healthcare Provider Compliance — NPI, NPPES & Medicare Filings";
|
||||
const description = "We handle the federal paperwork that keeps providers billable: Medicare revalidation (PECOS), NPI reactivation, NPPES updates, enrollment, and OIG/SAM exclusion screening. HIPAA not included.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-hero">
|
||||
<h1>Healthcare Provider Compliance</h1>
|
||||
<p style="font-size: 1.1rem; max-width: 46rem;">
|
||||
Your NPI is public. Your Medicare enrollment has a clock on it. We
|
||||
handle the federal filings that keep you billable so a missed
|
||||
deadline never deactivates your privileges.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>The obligations we cover</h2>
|
||||
<div class="pw-card-grid">
|
||||
<a class="pw-card" href="/services/healthcare/npi-revalidation">
|
||||
<h3>Medicare Revalidation (PECOS)</h3>
|
||||
<p class="pw-card-price">$399 per filing</p>
|
||||
<p>CMS requires every enrolled provider to revalidate every 5 years. Miss it and your billing privileges deactivate. We prepare and file it in PECOS.</p>
|
||||
</a>
|
||||
<a class="pw-card" href="/services/healthcare/medicare-enrollment">
|
||||
<h3>Medicare Enrollment (PECOS)</h3>
|
||||
<p class="pw-card-price">$499 per filing</p>
|
||||
<p>New CMS-855 enrollment: taxonomy, practice location, authorized official, EFT. We assemble the package and file it.</p>
|
||||
</a>
|
||||
<a class="pw-card" href="/order/npi-reactivation">
|
||||
<h3>NPI Reactivation</h3>
|
||||
<p class="pw-card-price">$249 per filing</p>
|
||||
<p>Reactivate a deactivated NPI in NPPES and re-certify the record so you can resume billing.</p>
|
||||
</a>
|
||||
<a class="pw-card" href="/order/nppes-update">
|
||||
<h3>NPPES Data Update</h3>
|
||||
<p class="pw-card-price">$149 per filing</p>
|
||||
<p>CMS requires NPPES updates within 30 days of any change. We update and re-attest your record.</p>
|
||||
</a>
|
||||
<a class="pw-card" href="/order/oig-sam-screening">
|
||||
<h3>OIG / SAM Exclusion Screening</h3>
|
||||
<p class="pw-card-price">$99 / year</p>
|
||||
<p>Annual OIG LEIE and SAM exclusion screening for you and your staff, with a compliance certificate.</p>
|
||||
</a>
|
||||
<a class="pw-card" href="/order/provider-compliance-bundle">
|
||||
<h3>Provider Compliance Bundle</h3>
|
||||
<p class="pw-card-price">$699 / year</p>
|
||||
<p>Revalidation monitoring, OIG/SAM screening, and NPPES upkeep in one annual package.</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Not sure where you stand?</h2>
|
||||
<p>
|
||||
Run our free NPI compliance check. Enter your NPI and we'll tell you
|
||||
whether your record is active, whether your Medicare revalidation is due
|
||||
or past due, and whether anything in the public registries needs attention.
|
||||
</p>
|
||||
<p>
|
||||
<a class="pw-cta-inline" href="/tools/npi-compliance-check">Run the free NPI compliance check →</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="pw-note">
|
||||
<p>
|
||||
<strong>Note:</strong> We handle federal registry and enrollment
|
||||
compliance. We do <em>not</em> provide HIPAA privacy/security program
|
||||
services. That's a separate specialty.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="pw-cta-section">
|
||||
<a class="pw-cta" href="/services/healthcare/npi-revalidation">Check your revalidation status →</a>
|
||||
</section>
|
||||
</main>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-hero { padding: 2.5rem 1.5rem; background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%); color: #fff; border-radius: 14px; margin-bottom: 2rem; }
|
||||
.pw-hero h1 { color: #fff; margin: 0 0 0.5rem; }
|
||||
.pw-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin: 1rem 0; }
|
||||
.pw-card { display: block; padding: 1.25rem; border: 1px solid #e2e8f0; border-radius: 10px; text-decoration: none; color: inherit; transition: box-shadow 0.15s; }
|
||||
.pw-card:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); border-color: #14b8a6; }
|
||||
.pw-card h3 { margin: 0 0 0.3rem; color: #134e4a; font-size: 1.05rem; }
|
||||
.pw-card-price { font-weight: 700; color: #0f766e; margin: 0 0 0.5rem; font-size: 1rem; }
|
||||
.pw-note { border-left: 4px solid #14b8a6; background: #f0fdfa; padding: 0.9rem 1.1rem; border-radius: 0 6px 6px 0; margin: 1.5rem 0; }
|
||||
.pw-cta-inline { color: #0f766e; font-weight: 600; }
|
||||
.pw-cta-section { margin: 3rem 0; text-align: center; }
|
||||
.pw-cta { display: inline-block; padding: 1rem 2rem; background: #0f766e; color: #fff; text-decoration: none; font-weight: 600; border-radius: 8px; font-size: 1.05rem; }
|
||||
.pw-cta:hover { background: #115e59; }
|
||||
</style>
|
||||
69
site/src/pages/services/healthcare/medicare-enrollment.astro
Normal file
69
site/src/pages/services/healthcare/medicare-enrollment.astro
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
const title = "Medicare Enrollment (PECOS) — Get Enrolled and Billable";
|
||||
const description = "New to Medicare or adding a practice location? We assemble and file your CMS-855 enrollment in PECOS: taxonomy, practice location, authorized official, EFT, and reassignments.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-hero">
|
||||
<h1>Medicare Enrollment (PECOS)</h1>
|
||||
<p style="font-size: 1.1rem; max-width: 44rem;">
|
||||
Before you can bill Medicare, you have to be enrolled in PECOS with a
|
||||
clean CMS-855. We build the package, file it, and shepherd it through
|
||||
your MAC to approval.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Who needs to enroll</h2>
|
||||
<ul>
|
||||
<li><strong>New providers</strong> just out of training or newly licensed and ready to bill Medicare.</li>
|
||||
<li><strong>Providers joining a group</strong> who need a reassignment (855R) to the group's billing.</li>
|
||||
<li><strong>Groups and suppliers</strong> establishing a new billing entity (855B).</li>
|
||||
<li><strong>Providers adding a practice location</strong> or changing how they bill.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What we file</h2>
|
||||
<ul>
|
||||
<li><strong>CMS-855I</strong> — individual physician/non-physician practitioner enrollment.</li>
|
||||
<li><strong>CMS-855B</strong> — group practice / supplier enrollment.</li>
|
||||
<li><strong>CMS-855R</strong> — reassignment of benefits to a group.</li>
|
||||
<li><strong>CMS-588 (EFT)</strong> — electronic funds transfer setup so you get paid.</li>
|
||||
<li><strong>CMS-460</strong> — participation agreement, if you're enrolling as participating.</li>
|
||||
</ul>
|
||||
<div class="pw-callout">
|
||||
We confirm your <strong>taxonomy code</strong>, <strong>practice
|
||||
location</strong>, and <strong>authorized official</strong> match
|
||||
across NPPES and PECOS before we file, because a mismatch is the #1
|
||||
cause of enrollment rejections.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>How it works</h2>
|
||||
<ol>
|
||||
<li>You place the order and give us your NPI and basic practice details.</li>
|
||||
<li>We pull your NPPES record and identify exactly which 855 forms apply.</li>
|
||||
<li>We assemble the package and send it to you for signature/attestation.</li>
|
||||
<li>We submit in PECOS and track it through your MAC, resolving any development requests.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="pw-cta-section">
|
||||
<a class="pw-cta" href="/order/medicare-enrollment">Start my Medicare enrollment — $499 →</a>
|
||||
</section>
|
||||
</main>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-hero { padding: 2.5rem 1.5rem; background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%); color: #fff; border-radius: 14px; margin-bottom: 2rem; }
|
||||
.pw-hero h1 { color: #fff; margin: 0 0 0.5rem; }
|
||||
.pw-callout { border-left: 4px solid #14b8a6; background: #f0fdfa; padding: 0.9rem 1.1rem; border-radius: 0 6px 6px 0; margin: 1rem 0; }
|
||||
.pw-cta-section { margin: 3rem 0; text-align: center; }
|
||||
.pw-cta { display: inline-block; padding: 1rem 2rem; background: #0f766e; color: #fff; text-decoration: none; font-weight: 600; border-radius: 8px; font-size: 1.05rem; }
|
||||
.pw-cta:hover { background: #115e59; }
|
||||
</style>
|
||||
84
site/src/pages/services/healthcare/npi-revalidation.astro
Normal file
84
site/src/pages/services/healthcare/npi-revalidation.astro
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
const title = "Medicare Revalidation Filing (PECOS) — Don't Let Your Billing Privileges Lapse";
|
||||
const description = "CMS requires every enrolled provider and supplier to revalidate their Medicare enrollment every 5 years. Miss your deadline and your billing privileges are deactivated. We prepare and file your revalidation in PECOS.";
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<main>
|
||||
<section class="pw-hero">
|
||||
<h1>Medicare Revalidation Filing</h1>
|
||||
<p style="font-size: 1.1rem; max-width: 44rem;">
|
||||
Every Medicare-enrolled provider has to revalidate every 5 years. If
|
||||
you miss your due date, CMS deactivates your billing privileges and
|
||||
every claim after that bounces. We file your revalidation for you.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What revalidation is</h2>
|
||||
<p>
|
||||
Under <strong>42 CFR § 424.515</strong>, CMS re-verifies the
|
||||
information of every enrolled provider and supplier on a recurring
|
||||
cycle (every 5 years for most providers, every 3 years for DMEPOS
|
||||
suppliers). You revalidate your entire enrollment record in
|
||||
<strong>PECOS</strong> (the Provider Enrollment, Chain, and Ownership
|
||||
System) using the CMS-855 family of forms.
|
||||
</p>
|
||||
<div class="pw-callout">
|
||||
<strong>The deadline is real.</strong> CMS publishes a revalidation due
|
||||
date for every provider. Submit late or not at all and your Medicare
|
||||
billing privileges are <em>deactivated</em>. Reactivating means a gap
|
||||
in payments and a fresh round of paperwork.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>How do I know if I'm due?</h2>
|
||||
<p>
|
||||
CMS posts a due date for every enrolled provider. Many providers are
|
||||
already <strong>past due</strong> and don't know it because the
|
||||
notification letter went to an old address. Run our free check and
|
||||
we'll look up your status:
|
||||
</p>
|
||||
<p>
|
||||
<a class="pw-cta-inline" href="/tools/npi-compliance-check">Check my revalidation status (free) →</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What we do</h2>
|
||||
<ul>
|
||||
<li>Pull your current PECOS enrollment record and confirm what CMS has on file.</li>
|
||||
<li>Reconcile your practice locations, reassignments, ownership, and authorized officials.</li>
|
||||
<li>Complete the correct CMS-855 (855I for individuals, 855B for groups/suppliers, 855R for reassignments).</li>
|
||||
<li>Submit the revalidation in PECOS and track it through to approval.</li>
|
||||
<li>Flag and resolve any development requests from your MAC before they cause a deactivation.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Why not do it yourself</h2>
|
||||
<ul>
|
||||
<li><strong>PECOS is unforgiving.</strong> One wrong reassignment or a stale practice location triggers a development request, and the clock keeps ticking.</li>
|
||||
<li><strong>The forms branch.</strong> Whether you file 855I, 855B, 855R, or a combination depends on how you bill. Pick wrong and you start over.</li>
|
||||
<li><strong>Deactivation is retroactive.</strong> If you miss the date, the gap isn't forgiven; claims in the gap stay denied.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="pw-cta-section">
|
||||
<a class="pw-cta" href="/order/npi-revalidation">File my Medicare revalidation — $399 →</a>
|
||||
</section>
|
||||
</main>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.pw-hero { padding: 2.5rem 1.5rem; background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%); color: #fff; border-radius: 14px; margin-bottom: 2rem; }
|
||||
.pw-hero h1 { color: #fff; margin: 0 0 0.5rem; }
|
||||
.pw-callout { border-left: 4px solid #14b8a6; background: #f0fdfa; padding: 0.9rem 1.1rem; border-radius: 0 6px 6px 0; margin: 1rem 0; }
|
||||
.pw-cta-inline { color: #0f766e; font-weight: 600; }
|
||||
.pw-cta-section { margin: 3rem 0; text-align: center; }
|
||||
.pw-cta { display: inline-block; padding: 1rem 2rem; background: #0f766e; color: #fff; text-decoration: none; font-weight: 600; border-radius: 8px; font-size: 1.05rem; }
|
||||
.pw-cta:hover { background: #115e59; }
|
||||
</style>
|
||||
327
site/src/pages/tools/npi-compliance-check.astro
Normal file
327
site/src/pages/tools/npi-compliance-check.astro
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
---
|
||||
import Base from "../../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base
|
||||
title="NPI Compliance Check — Free Medicare Revalidation & NPPES Status"
|
||||
description="Enter your 10-digit NPI to instantly check your NPPES status, Medicare revalidation due date, OIG exclusion, and record currency. Free tool from Performance West."
|
||||
>
|
||||
<main class="max-w-3xl mx-auto px-4 py-10">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="text-sm text-gray-500 mb-6" aria-label="Breadcrumb">
|
||||
<a href="/" class="hover:text-teal-600">Home</a>
|
||||
<span class="mx-1">/</span>
|
||||
<a href="/tools" class="hover:text-teal-600">Free Tools</a>
|
||||
<span class="mx-1">/</span>
|
||||
<span class="text-gray-800 font-medium">NPI Compliance Check</span>
|
||||
</nav>
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">NPI Compliance Check</h1>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Enter your provider name or 10-digit NPI to instantly check your NPPES status,
|
||||
Medicare revalidation due date, OIG exclusion screening, and record currency.
|
||||
</p>
|
||||
|
||||
<div class="bg-gray-100 border border-gray-200 rounded-lg p-4 text-sm text-gray-600 mb-8">
|
||||
This tool queries publicly available NPPES, CMS, and OIG data for informational
|
||||
purposes only. Always confirm critical compliance matters directly with CMS.
|
||||
This is not legal advice.
|
||||
</div>
|
||||
|
||||
<!-- Search box -->
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm mb-8">
|
||||
<!-- Name search -->
|
||||
<label for="name-input" class="block text-sm font-medium text-gray-700 mb-1">Provider or Organization Name</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input type="text" id="name-input" placeholder="e.g. Smith, or Acme Medical Group"
|
||||
class="flex-1 border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-400" />
|
||||
<input type="text" id="state-input" placeholder="State" maxlength="2"
|
||||
class="w-20 border border-gray-300 rounded-lg px-3 py-2 text-sm uppercase focus:outline-none focus:ring-2 focus:ring-teal-400" />
|
||||
<button id="name-search-btn"
|
||||
class="bg-teal-600 hover:bg-teal-700 text-white font-semibold px-5 py-2 rounded-lg text-sm transition">Search</button>
|
||||
</div>
|
||||
<div id="name-results" class="hidden mt-3"></div>
|
||||
|
||||
<div class="flex items-center gap-3 my-5">
|
||||
<div class="flex-1 border-t border-gray-200"></div>
|
||||
<span class="text-sm text-gray-400">or enter NPI directly</span>
|
||||
<div class="flex-1 border-t border-gray-200"></div>
|
||||
</div>
|
||||
|
||||
<!-- NPI search -->
|
||||
<label for="npi-input" class="block text-sm font-medium text-gray-700 mb-1">National Provider Identifier (NPI)</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input type="text" id="npi-input" placeholder="1234567893" maxlength="10" inputmode="numeric"
|
||||
class="flex-1 border border-gray-300 rounded-lg px-4 py-2 font-mono text-lg focus:outline-none focus:ring-2 focus:ring-teal-400" />
|
||||
<button id="npi-check-btn"
|
||||
class="bg-gray-700 hover:bg-gray-800 text-white font-semibold px-5 py-2 rounded-lg text-sm transition">Check Compliance</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
Don't know your NPI?
|
||||
<a href="https://npiregistry.cms.hhs.gov/search" target="_blank" rel="noopener" class="text-teal-600 hover:underline">Look it up in NPPES</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="loading" class="hidden text-center py-10">
|
||||
<svg class="animate-spin h-8 w-8 text-teal-600 mx-auto mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-700 text-sm font-medium" id="loading-status">Checking NPPES & CMS databases…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div id="error-box" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm mb-6">
|
||||
<p id="error-message"></p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="results" class="hidden space-y-6">
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<h2 id="provider-name" class="text-xl font-bold text-gray-900"></h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
NPI: <span id="provider-npi" class="font-mono font-semibold text-gray-800"></span>
|
||||
</p>
|
||||
<div id="provider-details" class="mt-2 space-y-1 text-sm text-gray-600"></div>
|
||||
</div>
|
||||
|
||||
<div id="checks-container" class="space-y-4"></div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div id="cta-section" class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<div id="cta-content"></div>
|
||||
</div>
|
||||
|
||||
<p id="checked-at" class="text-xs text-gray-400 text-center"></p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" is:inline>
|
||||
const API = window.__PW_API || "https://api.performancewest.net";
|
||||
|
||||
const nameInput = document.getElementById("name-input");
|
||||
const stateInput = document.getElementById("state-input");
|
||||
const nameSearchBtn = document.getElementById("name-search-btn");
|
||||
const nameResults = document.getElementById("name-results");
|
||||
const npiInput = document.getElementById("npi-input");
|
||||
const npiCheckBtn = document.getElementById("npi-check-btn");
|
||||
const loadingEl = document.getElementById("loading");
|
||||
const errorBox = document.getElementById("error-box");
|
||||
const errorMessage = document.getElementById("error-message");
|
||||
const resultsEl = document.getElementById("results");
|
||||
|
||||
let currentController = null;
|
||||
|
||||
// Service map: each check id -> the order page + price for the CTA.
|
||||
const SERVICE_MAP = {
|
||||
"npi-active": { name: "NPI Reactivation", price: 249, url: "/order/npi-reactivation" },
|
||||
"revalidation": { name: "Medicare Revalidation Filing", price: 399, url: "/order/npi-revalidation" },
|
||||
"exclusion": { name: "OIG/SAM Exclusion Screening", price: 99, url: "/order/oig-sam-screening" },
|
||||
"nppes-fresh": { name: "NPPES Data Update", price: 149, url: "/order/nppes-update" },
|
||||
"optout": { name: "Medicare Enrollment (PECOS)", price: 499, url: "/order/medicare-enrollment" },
|
||||
};
|
||||
|
||||
// --- Name search ---
|
||||
nameSearchBtn?.addEventListener("click", runNameSearch);
|
||||
nameInput?.addEventListener("keydown", (e) => { if (e.key === "Enter") runNameSearch(); });
|
||||
|
||||
async function runNameSearch() {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) return;
|
||||
const state = stateInput.value.trim().toUpperCase();
|
||||
nameResults.classList.remove("hidden");
|
||||
nameResults.innerHTML = '<p class="text-sm text-gray-500">Searching…</p>';
|
||||
|
||||
try {
|
||||
const qs = `name=${encodeURIComponent(name)}${state ? "&state=" + encodeURIComponent(state) : ""}`;
|
||||
const res = await fetch(`${API}/api/v1/npi/search?${qs}`);
|
||||
if (!res.ok) throw new Error("Search failed");
|
||||
const data = await res.json();
|
||||
const items = data.results || [];
|
||||
if (items.length === 0) {
|
||||
nameResults.innerHTML = '<p class="text-sm text-gray-500">No results found.</p>';
|
||||
return;
|
||||
}
|
||||
let html = `<p class="text-xs text-gray-400 mb-2">${items.length} result${items.length !== 1 ? "s" : ""} — click to check compliance</p>`;
|
||||
html += '<div class="space-y-2 max-h-72 overflow-y-auto border border-gray-200 rounded-lg divide-y divide-gray-100">';
|
||||
for (const item of items) {
|
||||
html += `<button data-npi="${item.npi}" class="name-result-btn w-full text-left px-4 py-3 flex items-center justify-between gap-3 hover:bg-teal-50 transition cursor-pointer" style="display:flex;">
|
||||
<div style="min-width:0;flex:1;">
|
||||
<p style="font-weight:600;font-size:14px;color:#111827;margin:0;">${item.name || "Unknown"}</p>
|
||||
<p style="font-size:12px;color:#6b7280;margin:2px 0 0;">${item.taxonomy || ""}${item.city ? " — " + item.city + ", " + (item.state || "") : ""}</p>
|
||||
</div>
|
||||
<div style="text-align:right;flex-shrink:0;">
|
||||
<p style="font-family:monospace;font-size:12px;color:#0f766e;font-weight:600;margin:0;">${item.npi}</p>
|
||||
<p style="font-size:11px;color:${item.status === "A" ? "#059669" : "#dc2626"};margin:2px 0 0;">${item.status === "A" ? "Active" : "Inactive"}</p>
|
||||
</div>
|
||||
</button>`;
|
||||
}
|
||||
html += "</div>";
|
||||
nameResults.innerHTML = html;
|
||||
nameResults.querySelectorAll(".name-result-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
npiInput.value = btn.getAttribute("data-npi");
|
||||
runCheck();
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
nameResults.innerHTML = `<p class="text-sm text-red-600">Search error: ${err.message || "Unknown error"}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- NPI compliance check ---
|
||||
npiCheckBtn.addEventListener("click", runCheck);
|
||||
npiInput.addEventListener("keydown", (e) => { if (e.key === "Enter") runCheck(); });
|
||||
|
||||
async function runCheck() {
|
||||
const raw = npiInput.value.trim().replace(/\D/g, "");
|
||||
if (raw.length !== 10) {
|
||||
showError("NPI must be exactly 10 digits (e.g. 1234567893). You entered " + raw.length + " digits.");
|
||||
return;
|
||||
}
|
||||
npiInput.value = raw;
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set("npi", raw);
|
||||
history.replaceState(null, "", url);
|
||||
|
||||
resultsEl.classList.add("hidden");
|
||||
errorBox.classList.add("hidden");
|
||||
loadingEl.classList.remove("hidden");
|
||||
|
||||
if (currentController) currentController.abort();
|
||||
const controller = new AbortController();
|
||||
currentController = controller;
|
||||
|
||||
try {
|
||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||
const res = await fetch(`${API}/api/v1/npi/lookup?npi=${raw}`, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
if (currentController !== controller) return;
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (res.status === 404) {
|
||||
showError(`NPI ${raw} was not found in NPPES. Verify your number at <a href="https://npiregistry.cms.hhs.gov/search" target="_blank" style="color:#0f766e;text-decoration:underline;">NPPES Registry</a>.`, true);
|
||||
return;
|
||||
}
|
||||
throw new Error(body.error || "NPI lookup failed. Please try again.");
|
||||
}
|
||||
renderResults(await res.json());
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") {
|
||||
showError("The CMS databases are taking too long to respond. Please try again in a moment.");
|
||||
} else {
|
||||
showError(err.message || "Could not reach the CMS databases right now. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
loadingEl.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg, allowHtml) {
|
||||
if (allowHtml) errorMessage.innerHTML = msg;
|
||||
else errorMessage.textContent = msg;
|
||||
errorBox.classList.remove("hidden");
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
green: { bg: "bg-green-50", border: "border-green-200", iconColor: "text-green-600", textColor: "text-green-800" },
|
||||
yellow: { bg: "bg-amber-50", border: "border-amber-200", iconColor: "text-amber-600", textColor: "text-amber-800" },
|
||||
red: { bg: "bg-red-50", border: "border-red-200", iconColor: "text-red-600", textColor: "text-red-800" },
|
||||
unknown: { bg: "bg-gray-50", border: "border-gray-200", iconColor: "text-gray-500", textColor: "text-gray-700" },
|
||||
};
|
||||
const icons = {
|
||||
green: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>`,
|
||||
yellow: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M10.29 3.86l-8.8 15.32A1 1 0 002.36 21h19.28a1 1 0 00.87-1.5l-9.64-16.64a1 1 0 00-1.74 0z"/></svg>`,
|
||||
red: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>`,
|
||||
unknown: `<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"/></svg>`,
|
||||
};
|
||||
|
||||
function renderResults(data) {
|
||||
errorBox.classList.add("hidden");
|
||||
document.getElementById("provider-name").textContent = data.name || "Unknown Provider";
|
||||
document.getElementById("provider-npi").textContent = data.npi;
|
||||
|
||||
const details = document.getElementById("provider-details");
|
||||
const bits = [];
|
||||
if (data.enumeration_type) bits.push(`<span class="font-medium text-gray-700">Type:</span> ${data.enumeration_type === "NPI-2" ? "Organization" : "Individual"}`);
|
||||
if (data.taxonomy?.desc) bits.push(`<span class="font-medium text-gray-700">Taxonomy:</span> ${data.taxonomy.desc}`);
|
||||
if (data.practice_state) bits.push(`<span class="font-medium text-gray-700">Practice State:</span> ${data.practice_state}`);
|
||||
if (data.enumeration_date) bits.push(`<span class="font-medium text-gray-700">Enumerated:</span> ${data.enumeration_date}`);
|
||||
details.innerHTML = bits.map((b) => `<p>${b}</p>`).join("");
|
||||
|
||||
const container = document.getElementById("checks-container");
|
||||
container.innerHTML = "";
|
||||
for (const check of (data.checks || [])) {
|
||||
const status = check.status || "unknown";
|
||||
const c = colorMap[status] || colorMap.unknown;
|
||||
const card = document.createElement("div");
|
||||
card.className = `${c.bg} ${c.border} border rounded-xl p-4 flex items-start gap-3`;
|
||||
card.innerHTML = `<div class="${c.iconColor} mt-0.5 flex-shrink-0">${icons[status] || icons.unknown}</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold ${c.textColor}">${check.label || check.id}</p>
|
||||
${check.detail ? `<p class="text-sm ${c.textColor} mt-1">${check.detail}</p>` : ""}
|
||||
</div>`;
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
renderCta(data);
|
||||
document.getElementById("checked-at").textContent = "Checked at " + new Date().toLocaleString();
|
||||
resultsEl.classList.remove("hidden");
|
||||
document.getElementById("results").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function renderCta(data) {
|
||||
const checks = data.checks || [];
|
||||
const npi = data.npi;
|
||||
const services = [];
|
||||
const seen = new Set();
|
||||
for (const check of checks) {
|
||||
if (check.status === "green") continue;
|
||||
const svc = SERVICE_MAP[check.id];
|
||||
if (svc && !seen.has(check.id)) { services.push({ ...svc, id: check.id }); seen.add(check.id); }
|
||||
}
|
||||
|
||||
const ctaContent = document.getElementById("cta-content");
|
||||
if (services.length > 0) {
|
||||
let html = `<div style="background:#fef2f2;border:2px solid #fca5a5;border-radius:10px;padding:16px;margin-bottom:16px;">
|
||||
<p style="font-size:14px;color:#991b1b;font-weight:700;margin:0 0 6px;">⚠ ${services.length} item${services.length > 1 ? "s need" : " needs"} attention</p>
|
||||
<p style="font-size:13px;color:#7f1d1d;margin:0;">We can prepare and file these with CMS on your behalf so your billing privileges stay intact.</p>
|
||||
</div>`;
|
||||
html += `<h3 class="text-lg font-bold text-gray-900 mb-1">Recommended filings</h3>`;
|
||||
html += `<p class="text-sm text-gray-600 mb-4">Based on your NPPES, CMS, and OIG records.</p>`;
|
||||
html += `<div class="space-y-2">`;
|
||||
for (const svc of services) {
|
||||
html += `<a href="${svc.url}?npi=${npi}" class="flex items-center justify-between gap-3 p-3 rounded-lg border border-gray-200 hover:bg-teal-50 hover:border-teal-300 transition cursor-pointer" style="text-decoration:none;color:inherit;">
|
||||
<span class="font-medium text-gray-900">${svc.name}</span>
|
||||
<span class="font-semibold text-teal-700 whitespace-nowrap">$${svc.price} →</span>
|
||||
</a>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `<div class="mt-5 text-center">
|
||||
<a href="/order/provider-compliance-bundle?npi=${npi}" style="background:#0f766e;color:#fff;font-weight:700;padding:12px 28px;border-radius:8px;display:inline-block;font-size:15px;text-decoration:none;">
|
||||
Get the Provider Compliance Bundle ($699/yr) →
|
||||
</a>
|
||||
<p style="font-size:11px;color:#6b7280;margin-top:8px;">Revalidation monitoring, screening, and NPPES upkeep in one package.</p>
|
||||
</div>`;
|
||||
ctaContent.innerHTML = html;
|
||||
} else {
|
||||
ctaContent.innerHTML = `<h3 class="text-lg font-bold text-green-800 mb-2">Looking good — all checks passed</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Your NPI and Medicare records appear current. No action is needed right now.</p>
|
||||
<a href="/order/oig-sam-screening?npi=${data.npi}" class="inline-block bg-teal-600 hover:bg-teal-700 text-white font-semibold px-5 py-2 rounded-lg text-sm transition" style="text-decoration:none;">
|
||||
Add annual OIG/SAM screening — $99/yr
|
||||
</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auto-fill from URL ---
|
||||
(function init() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const npi = params.get("npi");
|
||||
const name = params.get("name");
|
||||
if (npi) { npiInput.value = npi; runCheck(); }
|
||||
else if (name) { nameInput.value = name; runNameSearch(); }
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue