feat: Delta Indiv eligibility on coversme portal and ADA paper claim/preauth
Eligibility: rebuilt the Delta Indiv worker for my.deltadentalcoversme.com — Dentist tab -> Universal Delta Sign In -> username/Next -> password -> SIGN IN, confirmation-code (MFA) page detected by URL so the app's OTP popup shows, then Search for Patient -> Last Name + DOB -> Submit. On the "Benefits for" details page it reads the patient name, Subscriber # and Eligible through (active while that date is today or later) and saves the PDF downloaded by the Print link. Session persists: login is skipped while Search for Patient is reachable, and Delta Indiv cookies are no longer wiped on agent startup. The button needs Last Name + DOB (member ID optional; backend falls back to the scraped Subscriber #). Chatbot gains a Delta Indiv option and aliases. Claims: Delta Indiv uses the same 2024 ADA form as BCBS with the payer address Delta Dental, P.O. Box 103, Stevens Point, WI 54481-0103. Adds Delta Indiv Claim/PreAuth (ADA Form) buttons, Claim All and AI queue routing, Print ADA Form in the claim viewer, a Delta Indiv fee schedule (copied from BCBS), and Delta Indiv as its own insurance type so it is no longer treated as Deltains. Also renames eligibility buttons: Delta Indiv (Last name, DOB or ID), Metlife Dental (ID or SSN), Altus (ID, First name, DOB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ export function siteKeyToAutoCheck(siteKey: string, dob?: string | Date | null):
|
||||
case "CCA": return "cca";
|
||||
case "DDMA": return "ddma";
|
||||
case "DELTA_INS": return "delta-ins";
|
||||
case "DELTA_INDIV": return "delta-indiv";
|
||||
case "TUFTS_SCO": return "tufts-sco";
|
||||
case "UNITED_SCO": return "united-sco";
|
||||
case "UNITED_UCARD": return "united-ucard";
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
{ "keyword": "delta dental ma", "siteKey": "DDMA" },
|
||||
{ "keyword": "ddma", "siteKey": "DDMA" },
|
||||
|
||||
{ "keyword": "delta dental indiv", "siteKey": "DELTA_INDIV" },
|
||||
{ "keyword": "delta dental individual", "siteKey": "DELTA_INDIV" },
|
||||
{ "keyword": "delta indiv", "siteKey": "DELTA_INDIV" },
|
||||
{ "keyword": "deltaindiv", "siteKey": "DELTA_INDIV" },
|
||||
{ "keyword": "delta individual", "siteKey": "DELTA_INDIV" },
|
||||
{ "keyword": "coversme", "siteKey": "DELTA_INDIV" },
|
||||
|
||||
{ "keyword": "delta dental ins", "siteKey": "DELTA_INS" },
|
||||
{ "keyword": "delta ins", "siteKey": "DELTA_INS" },
|
||||
{ "keyword": "deltains", "siteKey": "DELTA_INS" },
|
||||
|
||||
@@ -83,6 +83,12 @@ async function processDeltaIndivResult(
|
||||
const output: DeltaIndivEligibilityProcessorResult = {};
|
||||
let createdPdfFileId: number | string | null = null;
|
||||
|
||||
// Delta Indiv searches by last name + DOB, so the form may have no member ID —
|
||||
// fall back to the one scraped from the portal.
|
||||
if (!insuranceId.trim() && typeof seleniumResult?.memberId === "string") {
|
||||
insuranceId = seleniumResult.memberId.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) Resolve patient name (prefer selenium extraction → form data)
|
||||
const rawName =
|
||||
|
||||
@@ -12,6 +12,8 @@ const SCHEDULE_FILES: Record<string, string> = {
|
||||
BCBS: "procedureCodesBCBS.json",
|
||||
BCBS_MA: "procedureCodesBCBS.json",
|
||||
ALTUS: "procedureCodesAltus.json",
|
||||
DELTAINDIV: "procedureCodesDeltaIndiv.json",
|
||||
DELTA_INDIV: "procedureCodesDeltaIndiv.json",
|
||||
METLIFE: "procedureCodesMetLife.json",
|
||||
METLIFEDENTAL: "procedureCodesMetLife.json",
|
||||
TUFTSSCO: "procedureCodesTuftsSCO.json",
|
||||
|
||||
@@ -33,11 +33,19 @@ export const ADA_PAYERS: Record<string, AdaPayer> = {
|
||||
address1: "P.O. Box 1557",
|
||||
cityStateZip: "Providence, RI 02901-1557",
|
||||
},
|
||||
DELTA_INDIV: {
|
||||
name: "Delta Dental",
|
||||
address1: "P.O. Box 103",
|
||||
cityStateZip: "Stevens Point, WI 54481-0103",
|
||||
},
|
||||
};
|
||||
|
||||
/** ADA payer key for a claim's insurance provider text (defaults to BCBS MA). */
|
||||
export function adaPayerKeyForProvider(provider?: string | null): string {
|
||||
return /altus/i.test(provider ?? "") ? "ALTUS" : "BCBS_MA";
|
||||
const p = provider ?? "";
|
||||
if (/altus/i.test(p)) return "ALTUS";
|
||||
if (/delta\s*(dental\s*)?indiv/i.test(p)) return "DELTA_INDIV";
|
||||
return "BCBS_MA";
|
||||
}
|
||||
|
||||
export type AdaClaimInput = {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
[
|
||||
{ "Procedure Code": "D0120", "Description": "Periodic oral evaluation - established patient", "Price": 105 },
|
||||
{ "Procedure Code": "D0140", "Description": "Limited oral evaluation - problem focused", "Price": 90 },
|
||||
{ "Procedure Code": "D0145", "Description": "Oral evaluation for a patient under three years of age and counseling with primary caregiver", "Price": "NC" },
|
||||
{ "Procedure Code": "D0150", "Description": "Comprehensive oral evaluation - new or established patient", "Price": 120 },
|
||||
{ "Procedure Code": "D0180", "Description": "Comprehensive periodontal evaluation - new or established patient", "Price": 37 },
|
||||
{ "Procedure Code": "D0190", "Description": "Screening of a patient (PHDH only)", "Price": 20 },
|
||||
{ "Procedure Code": "D0191", "Description": "Assessment of a patient (PHDH only)", "Price": 20 },
|
||||
{ "Procedure Code": "D0210", "Description": "Intraoral - complete series of radiographic images", "Price": 120 },
|
||||
{ "Procedure Code": "D0220", "Description": "Intraoral - periapical, first radiographic image", "Price": 60 },
|
||||
{ "Procedure Code": "D0230", "Description": "Intraoral - periapical, each additional radiographic image", "Price": 50 },
|
||||
{ "Procedure Code": "D0240", "Description": "Intraoral - occlusal radiographic image", "Price": "NC" },
|
||||
{ "Procedure Code": "D0270", "Description": "Bitewing - single radiographic image", "Price": 14 },
|
||||
{ "Procedure Code": "D0272", "Description": "Bitewings - two radiographic images", "Price": 80 },
|
||||
{ "Procedure Code": "D0273", "Description": "Bitewings - three radiographic images", "Price": 27 },
|
||||
{ "Procedure Code": "D0274", "Description": "Bitewings - four radiographic images", "Price": 160 },
|
||||
{ "Procedure Code": "D0330", "Description": "Panoramic radiographic image", "Price": 150 },
|
||||
{ "Procedure Code": "D0340", "Description": "Cephalometric radiograph image (Oral surgeon only)", "Price": 74 },
|
||||
{ "Procedure Code": "D0364", "Description": "CBCT - less than one jaw (interpret)", "Price": 350 },
|
||||
{ "Procedure Code": "D0365", "Description": "CBCT - mandible (interpret)", "Price": 350 },
|
||||
{ "Procedure Code": "D0366", "Description": "CBCT - maxilla (interpret)", "Price": 350 },
|
||||
{ "Procedure Code": "D0367", "Description": "CBCT - both jaws (interpret)", "Price": 400 },
|
||||
{ "Procedure Code": "D0368", "Description": "CBCT - include TMJ (interpret)", "Price": 375 },
|
||||
{ "Procedure Code": "D0380", "Description": "CBCT - less than one jaw (capture only)", "Price": 300 },
|
||||
{ "Procedure Code": "D0381", "Description": "CBCT - mandible (capture only)", "Price": 300 },
|
||||
{ "Procedure Code": "D0382", "Description": "CBCT - maxilla (capture only)", "Price": 300 },
|
||||
{ "Procedure Code": "D0383", "Description": "CBCT - both jaws / include TMJ (capture only)", "Price": 350 },
|
||||
{ "Procedure Code": "D1110", "Description": "Prophylaxis - adult, 14 yo or older", "Price": 150 },
|
||||
{ "Procedure Code": "D1120", "Description": "Prophylaxis - child, 0-13 yo", "Price": 120 },
|
||||
{ "Procedure Code": "D1206", "Description": "Topical application of fluoride varnish", "Price": 26 },
|
||||
{ "Procedure Code": "D1208", "Description": "Topical application of fluoride - excluding varnish", "Price": 90 },
|
||||
{ "Procedure Code": "D1351", "Description": "Sealant - per tooth", "Price": 80 },
|
||||
{ "Procedure Code": "D1354", "Description": "Application of caries arresting medicament - per tooth", "Price": 15 },
|
||||
{ "Procedure Code": "D1510", "Description": "Space maintainer - fixed, unilateral - per quadrant", "Price": "NC" },
|
||||
{ "Procedure Code": "D1516", "Description": "Space maintainer - fixed, bilateral, maxillary", "Price": "NC" },
|
||||
{ "Procedure Code": "D1517", "Description": "Space maintainer - fixed, bilateral, mandibular", "Price": "NC" },
|
||||
{ "Procedure Code": "D1520", "Description": "Space maintainer - removable, unilateral, per quadrant", "Price": "NC" },
|
||||
{ "Procedure Code": "D1526", "Description": "Space maintainer - removable, bilateral, maxillary", "Price": "NC" },
|
||||
{ "Procedure Code": "D1527", "Description": "Space maintainer - removable, bilateral, mandibular", "Price": "NC" },
|
||||
{ "Procedure Code": "D1575", "Description": "Distal shoe space maintainer - fixed, unilateral, per quadrant", "Price": "NC" },
|
||||
{ "Procedure Code": "D1701", "Description": "Pfizer-BioNTech Covid-19 vaccine - first dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1702", "Description": "Pfizer-BioNTech Covid-19 vaccine - second dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1707", "Description": "Janssen Covid-19 vaccine - single dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1708", "Description": "Pfizer-BioNTech Covid-19 vaccine - third dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1709", "Description": "Pfizer-BioNTech Covid-19 vaccine - booster dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1712", "Description": "Janssen Covid-19 vaccine - booster dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1713", "Description": "Pfizer-BioNTech Covid-19 vaccine pediatric - first dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1714", "Description": "Pfizer-BioNTech Covid-19 vaccine pediatric - second dose", "Price": 45.87 },
|
||||
{ "Procedure Code": "D1999", "Description": "Unspecified preventive procedure, by report", "Price": 50 },
|
||||
{ "Procedure Code": "D2140", "Description": "Amalgam - one surface, primary or permanent", "Price": 150 },
|
||||
{ "Procedure Code": "D2150", "Description": "Amalgam - two surfaces, primary or permanent", "Price": 200 },
|
||||
{ "Procedure Code": "D2160", "Description": "Amalgam - three surfaces, primary or permanent", "Price": 92 },
|
||||
{ "Procedure Code": "D2161", "Description": "Amalgam - four or more surfaces, primary or permanent", "Price": 116 },
|
||||
{ "Procedure Code": "D2330", "Description": "Resin-based composite - one surface, anterior", "Price": 180 },
|
||||
{ "Procedure Code": "D2331", "Description": "Resin-based composite - two surfaces, anterior", "Price": 220 },
|
||||
{ "Procedure Code": "D2332", "Description": "Resin-based composite - three surfaces, anterior", "Price": 280 },
|
||||
{ "Procedure Code": "D2335", "Description": "Resin-based composite - four or more surfaces or involving incisal angle (anterior)", "Price": 350 },
|
||||
{ "Procedure Code": "D2390", "Description": "Resin-based composite crown, anterior", "Price": "NC" },
|
||||
{ "Procedure Code": "D2391", "Description": "Resin-based composite - one surface, posterior", "Price": 200 },
|
||||
{ "Procedure Code": "D2392", "Description": "Resin-based composite - two surfaces, posterior", "Price": 250 },
|
||||
{ "Procedure Code": "D2393", "Description": "Resin-based composite - three surfaces, posterior", "Price": 280 },
|
||||
{ "Procedure Code": "D2394", "Description": "Resin-based composite - four or more surfaces, posterior", "Price": 320 },
|
||||
{ "Procedure Code": "D2710", "Description": "Crown - resin-based composite (indirect)", "Price": "NC" },
|
||||
{ "Procedure Code": "D2740", "Description": "Crown - porcelain/ceramic", "Price": 1300 },
|
||||
{ "Procedure Code": "D2750", "Description": "Crown - porcelain fused to high noble metal", "Price": 1300 },
|
||||
{ "Procedure Code": "D2751", "Description": "Crown - porcelain fused to predominantly base metal", "Price": 1200 },
|
||||
{ "Procedure Code": "D2752", "Description": "Crown - porcelain fused to noble metal", "Price": 1200 },
|
||||
{ "Procedure Code": "D2790", "Description": "Crown - full cast high noble metal", "Price": "NC" },
|
||||
{ "Procedure Code": "D2910", "Description": "Re-cement or re-bond inlay, onlay or partial coverage restoration", "Price": 57 },
|
||||
{ "Procedure Code": "D2920", "Description": "Re-cement or re-bond crown", "Price": 120 },
|
||||
{ "Procedure Code": "D2929", "Description": "Prefabricated porcelain/ceramic crown - primary tooth", "Price": "NC" },
|
||||
{ "Procedure Code": "D2930", "Description": "Prefabricated stainless steel crown - primary tooth", "Price": "NC" },
|
||||
{ "Procedure Code": "D2931", "Description": "Prefabricated stainless steel crown - permanent tooth", "Price": 171 },
|
||||
{ "Procedure Code": "D2932", "Description": "Prefabricated resin crown", "Price": "NC" },
|
||||
{ "Procedure Code": "D2934", "Description": "Prefabricated esthetic coated stainless steel crown - primary tooth", "Price": "NC" },
|
||||
{ "Procedure Code": "D2950", "Description": "Core buildup, including any pins when required", "Price": 350 },
|
||||
{ "Procedure Code": "D2951", "Description": "Pin retention - per tooth, in addition to restoration", "Price": 27 },
|
||||
{ "Procedure Code": "D2954", "Description": "Prefabricated post and core in addition to crown", "Price": 450 },
|
||||
{ "Procedure Code": "D2955", "Description": "Post removal", "Price": 350 },
|
||||
{ "Procedure Code": "D2980", "Description": "Crown repair necessitated by restorative material failure", "Price": 115 },
|
||||
{ "Procedure Code": "D2999", "Description": "Unspecified restorative procedure, by report", "Price": "IC" },
|
||||
{ "Procedure Code": "D3120", "Description": "Pulp cap - indirect (excluding final restoration)", "Price": 34 },
|
||||
{ "Procedure Code": "D3220", "Description": "Therapeutic pulpotomy (excluding final restoration)", "Price": "NC" },
|
||||
{ "Procedure Code": "D3230", "Description": "Pulpal therapy (resorbable filling) - anterior, primary tooth", "Price": 280 },
|
||||
{ "Procedure Code": "D3240", "Description": "Pulpal therapy (resorbable filling) - posterior, primary tooth", "Price": 490 },
|
||||
{ "Procedure Code": "D3310", "Description": "Endodontic therapy, anterior tooth (excluding final restoration)", "Price": 900 },
|
||||
{ "Procedure Code": "D3320", "Description": "Endodontic therapy, premolar tooth (excluding final restoration)", "Price": 1050 },
|
||||
{ "Procedure Code": "D3330", "Description": "Endodontic therapy, molar tooth (excluding final restoration)", "Price": 1250 },
|
||||
{ "Procedure Code": "D3346", "Description": "Retreatment of previous root canal therapy - anterior", "Price": 950 },
|
||||
{ "Procedure Code": "D3347", "Description": "Retreatment of previous root canal therapy - premolar", "Price": 1100 },
|
||||
{ "Procedure Code": "D3348", "Description": "Retreatment of previous root canal therapy - molar", "Price": 1500 },
|
||||
{ "Procedure Code": "D3410", "Description": "Apicoectomy - anterior", "Price": 407 },
|
||||
{ "Procedure Code": "D3421", "Description": "Apicoectomy - premolar (first root)", "Price": 460 },
|
||||
{ "Procedure Code": "D3425", "Description": "Apicoectomy - molar (first root)", "Price": 598 },
|
||||
{ "Procedure Code": "D3426", "Description": "Apicoectomy (each additional root)", "Price": 230 },
|
||||
{ "Procedure Code": "D4210", "Description": "Gingivectomy or gingivoplasty - four or more contiguous teeth per quadrant", "Price": 307 },
|
||||
{ "Procedure Code": "D4211", "Description": "Gingivectomy or gingivoplasty - one to three contiguous teeth per quadrant", "Price": 111 },
|
||||
{ "Procedure Code": "D4341", "Description": "Periodontal scaling and root planing - four or more teeth per quadrant", "Price": 250 },
|
||||
{ "Procedure Code": "D4342", "Description": "Periodontal scaling and root planing - one to three teeth, per quadrant", "Price": 90 },
|
||||
{ "Procedure Code": "D4346", "Description": "Scaling in presence of generalized moderate or severe gingival inflammation - full mouth", "Price": 60 },
|
||||
{ "Procedure Code": "D4910", "Description": "Periodontal maintenance", "Price": 250 },
|
||||
{ "Procedure Code": "D5110", "Description": "Complete denture - maxillary", "Price": 1700 },
|
||||
{ "Procedure Code": "D5120", "Description": "Complete denture - mandibular", "Price": 1700 },
|
||||
{ "Procedure Code": "D5130", "Description": "Immediate denture - maxillary", "Price": "NC" },
|
||||
{ "Procedure Code": "D5140", "Description": "Immediate denture - mandibular", "Price": "NC" },
|
||||
{ "Procedure Code": "D5211", "Description": "Maxillary partial denture - resin base", "Price": 1300 },
|
||||
{ "Procedure Code": "D5212", "Description": "Mandibular partial denture - resin base", "Price": 1300 },
|
||||
{ "Procedure Code": "D5213", "Description": "Maxillary partial denture - cast metal framework with resin denture bases", "Price": 1700 },
|
||||
{ "Procedure Code": "D5214", "Description": "Mandibular partial denture - cast metal framework with resin denture bases", "Price": 1700 },
|
||||
{ "Procedure Code": "D5225", "Description": "Maxillary partial denture - flexible base", "Price": "NC" },
|
||||
{ "Procedure Code": "D5226", "Description": "Mandibular partial denture - flexible base", "Price": "NC" },
|
||||
{ "Procedure Code": "D5510", "Description": "Repair broken complete denture base (QUAD)", "Price": 400 },
|
||||
{ "Procedure Code": "D5511", "Description": "Repair broken complete denture base, mandibular", "Price": 85 },
|
||||
{ "Procedure Code": "D5512", "Description": "Repair broken complete denture base, maxillary", "Price": 85 },
|
||||
{ "Procedure Code": "D5520", "Description": "Replace missing or broken teeth - complete denture (each tooth)", "Price": 200 },
|
||||
{ "Procedure Code": "D5611", "Description": "Repair broken resin partial denture base, mandibular", "Price": 77 },
|
||||
{ "Procedure Code": "D5612", "Description": "Repair broken resin partial denture base, maxillary", "Price": 77 },
|
||||
{ "Procedure Code": "D5621", "Description": "Repair broken cast partial denture base, mandibular", "Price": 104 },
|
||||
{ "Procedure Code": "D5622", "Description": "Repair broken cast partial denture base, maxillary", "Price": 104 },
|
||||
{ "Procedure Code": "D5630", "Description": "Repair or replace broken retentive/clasping materials - per tooth", "Price": 99 },
|
||||
{ "Procedure Code": "D5640", "Description": "Replace broken teeth - per tooth", "Price": 77 },
|
||||
{ "Procedure Code": "D5650", "Description": "Add tooth to existing partial denture", "Price": 92 },
|
||||
{ "Procedure Code": "D5660", "Description": "Add clasp to existing partial denture per tooth", "Price": 98 },
|
||||
{ "Procedure Code": "D5730", "Description": "Reline complete maxillary denture (chairside)", "Price": 500 },
|
||||
{ "Procedure Code": "D5731", "Description": "Reline complete mandibular denture (chairside)", "Price": 500 },
|
||||
{ "Procedure Code": "D5740", "Description": "Reline maxillary partial denture (chairside)", "Price": "NC" },
|
||||
{ "Procedure Code": "D5741", "Description": "Reline mandibular partial denture (chairside)", "Price": "NC" },
|
||||
{ "Procedure Code": "D5750", "Description": "Reline complete maxillary denture (laboratory)", "Price": 600 },
|
||||
{ "Procedure Code": "D5751", "Description": "Reline complete mandibular denture (laboratory)", "Price": 600 },
|
||||
{ "Procedure Code": "D5760", "Description": "Reline maxillary partial denture (laboratory)", "Price": "NC" },
|
||||
{ "Procedure Code": "D5761", "Description": "Reline mandibular partial denture (laboratory)", "Price": "NC" },
|
||||
{ "Procedure Code": "D6010", "Description": "Surgical placement of implant body", "Price": 1600 },
|
||||
{ "Procedure Code": "D6056", "Description": "Prefabricated abutment", "Price": 750 },
|
||||
{ "Procedure Code": "D6057", "Description": "Custom abutment", "Price": 800 },
|
||||
{ "Procedure Code": "D6058", "Description": "Implant crown - porcelain/ceramic", "Price": 1400 },
|
||||
{ "Procedure Code": "D6059", "Description": "Implant crown - PFM", "Price": 1400 },
|
||||
{ "Procedure Code": "D6100", "Description": "Surgical removal of implant body", "Price": 320 },
|
||||
{ "Procedure Code": "D6110", "Description": "Implant/abutment supported removable denture - maxillary", "Price": 1600 },
|
||||
{ "Procedure Code": "D6111", "Description": "Implant/abutment supported removable denture - mandibular", "Price": 1600 },
|
||||
{ "Procedure Code": "D6241", "Description": "Pontic - porcelain fused to metal", "Price": "NC" },
|
||||
{ "Procedure Code": "D6242", "Description": "Pontic - noble metal", "Price": 1400 },
|
||||
{ "Procedure Code": "D6245", "Description": "Pontic - porcelain/ceramic", "Price": 1400 },
|
||||
{ "Procedure Code": "D6751", "Description": "Retainer crown - porcelain fused to metal", "Price": "NC" },
|
||||
{ "Procedure Code": "D6930", "Description": "Re-cement or re-bond fixed partial denture", "Price": "NC" },
|
||||
{ "Procedure Code": "D6980", "Description": "Fixed partial denture repair", "Price": "NC" },
|
||||
{ "Procedure Code": "D6999", "Description": "Fixed prosthodontic procedure, by report", "Price": "IC" },
|
||||
{ "Procedure Code": "D7111", "Description": "Extraction, coronal remnants - primary tooth", "Price": 120 },
|
||||
{ "Procedure Code": "D7140", "Description": "Extraction, erupted tooth or exposed root (simple)", "Price": 150 },
|
||||
{ "Procedure Code": "D7210", "Description": "Extraction, erupted tooth requiring removal of bone and/or sectioning (surgical)", "Price": 280 },
|
||||
{ "Procedure Code": "D7220", "Description": "Removal of impacted tooth - soft tissue", "Price": 380 },
|
||||
{ "Procedure Code": "D7230", "Description": "Removal of impacted tooth - partially bony", "Price": 450 },
|
||||
{ "Procedure Code": "D7240", "Description": "Removal of impacted tooth - completely bony", "Price": 550 },
|
||||
{ "Procedure Code": "D7250", "Description": "Surgical removal of residual tooth roots", "Price": 280 },
|
||||
{ "Procedure Code": "D7251", "Description": "Coronectomy - intentional partial tooth removal, impacted teeth only", "Price": 134 },
|
||||
{ "Procedure Code": "D7270", "Description": "Tooth reimplantation and/or stabilization of accidentally evulsed or displaced tooth", "Price": 106 },
|
||||
{ "Procedure Code": "D7280", "Description": "Surgical access of an unerupted tooth", "Price": "NC" },
|
||||
{ "Procedure Code": "D7283", "Description": "Placement of device to facilitate eruption of impacted tooth", "Price": "NC" },
|
||||
{ "Procedure Code": "D7310", "Description": "Alveoloplasty in conjunction with extractions - four or more teeth per quadrant", "Price": 142 },
|
||||
{ "Procedure Code": "D7311", "Description": "Alveoloplasty in conjunction with extractions - one to three teeth per quadrant", "Price": 128 },
|
||||
{ "Procedure Code": "D7320", "Description": "Alveoloplasty not in conjunction with extractions - four or more teeth per quadrant", "Price": 187 },
|
||||
{ "Procedure Code": "D7321", "Description": "Alveoloplasty not in conjunction with extractions - one to three teeth per quadrant", "Price": 149 },
|
||||
{ "Procedure Code": "D7340", "Description": "Vestibuloplasty - ridge extension (second epithelialization)", "Price": 747 },
|
||||
{ "Procedure Code": "D7350", "Description": "Vestibuloplasty - ridge extension (Oral surgeon only)", "Price": 943 },
|
||||
{ "Procedure Code": "D7410", "Description": "Excision of benign lesion - diameter up to 1.25 cm", "Price": 115 },
|
||||
{ "Procedure Code": "D7411", "Description": "Excision of benign lesion - diameter greater than 1.25 cm", "Price": 208 },
|
||||
{ "Procedure Code": "D7450", "Description": "Removal of benign odontogenic cyst or tumor - up to 1.25 cm", "Price": 248 },
|
||||
{ "Procedure Code": "D7451", "Description": "Removal of benign odontogenic cyst or tumor - greater than 1.25 cm", "Price": 288 },
|
||||
{ "Procedure Code": "D7460", "Description": "Removal of benign nonodontogenic cyst or tumor - up to 1.25 cm", "Price": 121 },
|
||||
{ "Procedure Code": "D7461", "Description": "Removal of benign nonodontogenic cyst or tumor - greater than 1.25 cm", "Price": 143 },
|
||||
{ "Procedure Code": "D7471", "Description": "Removal of lateral exostosis (maxilla or mandible)", "Price": 143 },
|
||||
{ "Procedure Code": "D7472", "Description": "Removal of torus palatinus", "Price": 143 },
|
||||
{ "Procedure Code": "D7473", "Description": "Removal of torus mandibularis", "Price": 143 },
|
||||
{ "Procedure Code": "D7910", "Description": "Suture of recent small wounds up to 5 cm", "Price": 400 },
|
||||
{ "Procedure Code": "D7950", "Description": "Bone graft - maxilla/mandible", "Price": 800 },
|
||||
{ "Procedure Code": "D7953", "Description": "Bone graft for socket preservation after extraction", "Price": 800 },
|
||||
{ "Procedure Code": "D7961", "Description": "Buccal/labial frenectomy (frenulectomy)", "Price": 107 },
|
||||
{ "Procedure Code": "D7962", "Description": "Lingual frenectomy (frenulectomy)", "Price": 107 },
|
||||
{ "Procedure Code": "D7963", "Description": "Frenuloplasty", "Price": 416 },
|
||||
{ "Procedure Code": "D7970", "Description": "Excision of hyperplastic tissue - per arch", "Price": 246 },
|
||||
{ "Procedure Code": "D7999", "Description": "Unspecified oral surgery procedure, by report", "Price": "IC" },
|
||||
{ "Procedure Code": "D8010", "Description": "Limited orthodontic treatment of the primary dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8020", "Description": "Limited orthodontic treatment of the transitional dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8030", "Description": "Limited orthodontic treatment of the adolescent dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8040", "Description": "Limited orthodontic treatment of the adult dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8070", "Description": "Comprehensive orthodontic treatment of the transitional dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8080", "Description": "Comprehensive orthodontic treatment of the adolescent dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8090", "Description": "Comprehensive orthodontic treatment of the adult dentition (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8660", "Description": "Pre-orthodontic treatment examination (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8670", "Description": "Periodic orthodontic treatment visit (Orthodontist only)", "Price": 215 },
|
||||
{ "Procedure Code": "D8680", "Description": "Orthodontic retention (Orthodontist only)", "Price": 85 },
|
||||
{ "Procedure Code": "D8703", "Description": "Replacement of lost or broken retainer - maxillary (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8704", "Description": "Replacement of lost or broken retainer - mandibular (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D8999", "Description": "Unspecified orthodontic procedure, by report (Orthodontist only)", "Price": "NC" },
|
||||
{ "Procedure Code": "D9110", "Description": "Palliative treatment of dental pain - per visit", "Price": 36 },
|
||||
{ "Procedure Code": "D9222", "Description": "Deep sedation/general anesthesia - first 15 minutes", "Price": 90 },
|
||||
{ "Procedure Code": "D9223", "Description": "Deep sedation/general anesthesia - each additional 15-minute increment", "Price": 90 },
|
||||
{ "Procedure Code": "D9230", "Description": "Analgesia, anxiolysis, inhalation of nitrous oxide", "Price": 15 },
|
||||
{ "Procedure Code": "D9248", "Description": "Nonintravenous conscious sedation", "Price": 45 },
|
||||
{ "Procedure Code": "D9310", "Description": "Consultation - diagnostic service (Specialist only)", "Price": 63 },
|
||||
{ "Procedure Code": "D9410", "Description": "House/extended care facility call, once per facility per day", "Price": 39 },
|
||||
{ "Procedure Code": "D9450", "Description": "Rural add-on encounter payment", "Price": 31 },
|
||||
{ "Procedure Code": "D9920", "Description": "Behavior management, by report", "Price": 86 },
|
||||
{ "Procedure Code": "D9930", "Description": "Treatment of complications (postsurgical) - unusual circumstances, by report", "Price": 30 },
|
||||
{ "Procedure Code": "D9941", "Description": "Fabrication of athletic mouthguard", "Price": "NC" },
|
||||
{ "Procedure Code": "D9944", "Description": "Occlusal guard - hard appliance, full arch", "Price": "NC" },
|
||||
{ "Procedure Code": "D9945", "Description": "Occlusal guard - soft appliance, full arch", "Price": "NC" },
|
||||
{ "Procedure Code": "D9946", "Description": "Occlusal guard - hard appliance, partial arch", "Price": "NC" },
|
||||
{ "Procedure Code": "D9999", "Description": "Unspecified adjunctive procedure, by report", "Price": "IC" }
|
||||
]
|
||||
@@ -828,6 +828,7 @@ export function ClaimForm({
|
||||
if (!p) return "";
|
||||
if (p.includes("masshealth") || p === "mh" || p === "mass health" || p.includes("cmsp")) return "MH";
|
||||
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
|
||||
if (p.includes("indiv")) return "DeltaIndiv";
|
||||
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
|
||||
if (p.includes("delta ins") || p === "deltains") return "DeltaIns";
|
||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
|
||||
@@ -1660,6 +1661,8 @@ export function ClaimForm({
|
||||
runWithPriceCheck(() => handleBCBSClaim());
|
||||
} else if (siteKey === "ALTUS") {
|
||||
runWithPriceCheck(() => handleAltusClaim());
|
||||
} else if (siteKey === "DELTAINDIV" || siteKey === "DELTA_INDIV") {
|
||||
runWithPriceCheck(() => handleDeltaIndivClaim());
|
||||
} else {
|
||||
toast({
|
||||
title: "No automated claim for this insurance",
|
||||
@@ -1997,6 +2000,7 @@ export function ClaimForm({
|
||||
const ADA_PAPER_PAYERS = {
|
||||
BCBS_MA: { label: "BCBS", insuranceProvider: "BCBS MA", mailTo: "BCBS MA" },
|
||||
ALTUS: { label: "Altus", insuranceProvider: "Altus Dental", mailTo: "Altus Dental" },
|
||||
DELTA_INDIV: { label: "Delta Indiv", insuranceProvider: "Delta Dental Indiv", mailTo: "Delta Dental" },
|
||||
} as const;
|
||||
|
||||
const handleAdaPaperClaim = async (
|
||||
@@ -2093,6 +2097,11 @@ export function ClaimForm({
|
||||
opts?: { auto?: boolean; preauth?: boolean },
|
||||
) => handleAdaPaperClaim("ALTUS", formToUse, opts);
|
||||
|
||||
const handleDeltaIndivClaim = (
|
||||
formToUse?: ClaimFormData & { uploadedFiles?: File[] },
|
||||
opts?: { auto?: boolean; preauth?: boolean },
|
||||
) => handleAdaPaperClaim("DELTA_INDIV", formToUse, opts);
|
||||
|
||||
const handleClaimSaved = async () => {
|
||||
const missingFields: string[] = [];
|
||||
if (!form.memberId?.trim()) missingFields.push("Member ID");
|
||||
@@ -2244,6 +2253,8 @@ export function ClaimForm({
|
||||
handleBCBSClaim(pricedForm, { auto: true });
|
||||
} else if (key === "altus") {
|
||||
handleAltusClaim(pricedForm, { auto: true });
|
||||
} else if (key === "deltaindiv" || key === "delta_indiv") {
|
||||
handleDeltaIndivClaim(pricedForm, { auto: true });
|
||||
} else {
|
||||
handleMHSubmit(pricedForm);
|
||||
}
|
||||
@@ -2278,6 +2289,13 @@ export function ClaimForm({
|
||||
insuranceSiteKey: "altus",
|
||||
});
|
||||
handleAltusClaim(pricedForm, { auto: true, preauth: true });
|
||||
} else if (key === "deltaindiv" || key === "delta_indiv") {
|
||||
const pricedForm = mapPricesForForm({
|
||||
form: { ...form, insuranceSiteKey: "deltaindiv" },
|
||||
patientDOB: patient?.dateOfBirth ?? "",
|
||||
insuranceSiteKey: "deltaindiv",
|
||||
});
|
||||
handleDeltaIndivClaim(pricedForm, { auto: true, preauth: true });
|
||||
} else {
|
||||
// Re-apply fee-schedule prices with the now-loaded patient DOB before submitting —
|
||||
// the earlier chatbot-prefill pass may have run before `patient` finished loading,
|
||||
@@ -2421,6 +2439,7 @@ export function ClaimForm({
|
||||
<SelectItem value="CCA">CCA</SelectItem>
|
||||
<SelectItem value="DDMA">DDMA</SelectItem>
|
||||
<SelectItem value="DeltaIns">Delta Ins</SelectItem>
|
||||
<SelectItem value="DeltaIndiv">Delta Indiv</SelectItem>
|
||||
<SelectItem value="TuftsSCO">Tufts SCO</SelectItem>
|
||||
<SelectItem value="UnitedSCO">United SCO</SelectItem>
|
||||
<SelectItem value="BCBS">BCBS</SelectItem>
|
||||
@@ -2945,6 +2964,13 @@ export function ClaimForm({
|
||||
>
|
||||
Altus Claim (ADA Form)
|
||||
</Button>
|
||||
<Button
|
||||
className="w-44 bg-teal-700 hover:bg-teal-800 text-white"
|
||||
onClick={() => runWithPriceCheck(() => handleDeltaIndivClaim())}
|
||||
title="Save the claim and open a filled ADA paper form to print and mail to Delta Dental (P.O. Box 103, Stevens Point, WI)"
|
||||
>
|
||||
Delta Indiv Claim (ADA Form)
|
||||
</Button>
|
||||
<Button
|
||||
className="w-36 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => runWithPriceCheck(handleClaimSaved)}
|
||||
@@ -3046,6 +3072,7 @@ export function ClaimForm({
|
||||
<SelectItem value="CCA">CCA</SelectItem>
|
||||
<SelectItem value="DDMA">DDMA</SelectItem>
|
||||
<SelectItem value="DeltaIns">Delta Ins</SelectItem>
|
||||
<SelectItem value="DeltaIndiv">Delta Indiv</SelectItem>
|
||||
<SelectItem value="TuftsSCO">Tufts SCO</SelectItem>
|
||||
<SelectItem value="UnitedSCO">United SCO</SelectItem>
|
||||
<SelectItem value="BCBS">BCBS</SelectItem>
|
||||
@@ -3494,6 +3521,13 @@ export function ClaimForm({
|
||||
>
|
||||
Altus PreAuth (ADA Form)
|
||||
</Button>
|
||||
<Button
|
||||
className="w-44 bg-teal-700 hover:bg-teal-800 text-white"
|
||||
onClick={() => handleDeltaIndivClaim(undefined, { preauth: true })}
|
||||
title="Save the preauth and open a filled ADA form (Request for Predetermination/Preauthorization) to print and mail to Delta Dental (P.O. Box 103, Stevens Point, WI)"
|
||||
>
|
||||
Delta Indiv PreAuth (ADA Form)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -354,7 +354,7 @@ export default function ClaimViewModal({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{/bcbs|blue cross|altus/i.test(claim.insuranceProvider ?? "") && (
|
||||
{/bcbs|blue cross|altus|indiv/i.test(claim.insuranceProvider ?? "") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
@@ -362,7 +362,11 @@ export default function ClaimViewModal({
|
||||
showAdaClaimForm(
|
||||
claim.id!,
|
||||
tab,
|
||||
/altus/i.test(claim.insuranceProvider ?? "") ? "ALTUS" : "BCBS_MA",
|
||||
/altus/i.test(claim.insuranceProvider ?? "")
|
||||
? "ALTUS"
|
||||
: /indiv/i.test(claim.insuranceProvider ?? "")
|
||||
? "DELTA_INDIV"
|
||||
: "BCBS_MA",
|
||||
).catch((err) =>
|
||||
toast({
|
||||
title: "ADA form failed",
|
||||
|
||||
@@ -194,7 +194,7 @@ export function AltusEligibilityButton({
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Altus
|
||||
Altus (ID, First name, DOB)
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -116,10 +116,10 @@ export function DeltaIndivEligibilityButton({
|
||||
// ── Socket event handlers ─────────────────────────────────────────────────
|
||||
|
||||
const handleDeltaIndivStart = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
if (!lastName || !dateOfBirth) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Member ID and Date of Birth are required.",
|
||||
description: "Last Name and Date of Birth are required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -419,7 +419,7 @@ export function DeltaIndivEligibilityButton({
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Delta Indiv
|
||||
Delta Indiv (Last name, DOB or ID)
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -355,7 +355,7 @@ export function MetLifeEligibilityButton({
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Metlife Dental
|
||||
Metlife Dental (ID or SSN)
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -137,6 +137,7 @@ const INSURANCE_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "mh", label: "MassHealth" },
|
||||
{ value: "ddma", label: "DDMA" },
|
||||
{ value: "delta-ins", label: "Deltains" },
|
||||
{ value: "delta-indiv", label: "Delta Indiv" },
|
||||
{ value: "united-sco", label: "United SCO" },
|
||||
{ value: "united-ucard", label: "United Ucard" },
|
||||
{ value: "tufts-sco", label: "Tufts SCO" },
|
||||
@@ -601,13 +602,17 @@ export function ChatbotButton() {
|
||||
if (!eligibilityIdData) return;
|
||||
addMsg("user", "Check eligibility now");
|
||||
addMsg("bot", "Opening the eligibility check page...");
|
||||
if (eligibilityIdData.memberId) {
|
||||
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
|
||||
let { memberId, firstName, lastName } = eligibilityIdData;
|
||||
// Delta Indiv searches by last name + DOB: a letters-only "Member ID"
|
||||
// (e.g. "MOUSSAID 01/22/1991") is really the last name.
|
||||
if (eligibilityIdData.autoCheck === "delta-indiv" && /^[A-Za-z'\- ]+$/.test(memberId) && !lastName) {
|
||||
lastName = memberId;
|
||||
memberId = "";
|
||||
}
|
||||
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck, {
|
||||
firstName: eligibilityIdData.firstName,
|
||||
lastName: eligibilityIdData.lastName,
|
||||
});
|
||||
if (memberId) {
|
||||
await syncEditedPatientFields(memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
|
||||
}
|
||||
prefillAndNavigate(memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck, { firstName, lastName });
|
||||
};
|
||||
|
||||
const handleBatchEligibilityRun = () => {
|
||||
@@ -1186,7 +1191,15 @@ export function ChatbotButton() {
|
||||
<Select
|
||||
value={eligibilityIdData.autoCheck}
|
||||
onValueChange={(value) =>
|
||||
setEligibilityIdData((prev) => (prev ? { ...prev, autoCheck: value } : prev))
|
||||
setEligibilityIdData((prev) => {
|
||||
if (!prev) return prev;
|
||||
// Delta Indiv searches by last name + DOB: a letters-only "Member ID"
|
||||
// (e.g. "MOUSSAID 01/22/1991") is really the last name.
|
||||
if (value === "delta-indiv" && /^[A-Za-z'\- ]+$/.test(prev.memberId) && !prev.lastName) {
|
||||
return { ...prev, autoCheck: value, lastName: prev.memberId, firstName: prev.firstName ?? "", memberId: "" };
|
||||
}
|
||||
return { ...prev, autoCheck: value };
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs bg-white">
|
||||
|
||||
@@ -89,6 +89,7 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
|
||||
const p = (val || "").toLowerCase().trim();
|
||||
if (p.includes("masshealth") || p === "mh" || p === "mass health" || p.includes("cmsp")) return "MassHealth";
|
||||
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
|
||||
if (p.includes("indiv")) return "Delta Dental Indiv";
|
||||
if (p.includes("ddma")) return "DDMA";
|
||||
if (p.includes("delta dental") || p.includes("delta ins") || p === "deltains") return "DeltaIns";
|
||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
|
||||
@@ -504,6 +505,7 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
|
||||
<SelectItem value="CCA">CCA</SelectItem>
|
||||
<SelectItem value="DDMA">DDMA</SelectItem>
|
||||
<SelectItem value="DeltaIns">Delta Ins</SelectItem>
|
||||
<SelectItem value="Delta Dental Indiv">Delta Indiv</SelectItem>
|
||||
<SelectItem value="TuftsSCO">Tufts SCO</SelectItem>
|
||||
<SelectItem value="UnitedSCO">United SCO</SelectItem>
|
||||
<SelectItem value="BCBS">BCBS</SelectItem>
|
||||
|
||||
@@ -122,6 +122,7 @@ const PREAUTH_SITE_KEYS: Record<string, string> = {
|
||||
BCBS: "BCBS", // paper ADA form, filled and saved to Documents > PreAuth
|
||||
BCBSMA: "BCBS",
|
||||
ALTUS: "ALTUS", // paper ADA form, like BCBS
|
||||
DELTAINDIV: "DELTA_INDIV", // paper ADA form, like BCBS
|
||||
};
|
||||
const toPreauthSiteKey = (siteKey: string): string | undefined =>
|
||||
PREAUTH_SITE_KEYS[siteKey.replace(/_/g, "").toUpperCase()];
|
||||
@@ -131,6 +132,7 @@ function deriveInsuranceSiteKey(provider: string | null | undefined): string {
|
||||
if (!p) return "";
|
||||
if (p.includes("masshealth") || p === "mh" || p === "mass health" || p.includes("cmsp")) return "MH";
|
||||
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
|
||||
if (p.includes("indiv")) return "DeltaIndiv";
|
||||
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
|
||||
if (p.includes("delta ins") || p === "deltains") return "DeltaIns";
|
||||
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
|
||||
@@ -1187,6 +1189,8 @@ export default function AppointmentsPage() {
|
||||
if (m < 0 || (m === 0 && today.getDate() < dobDate.getDate())) age--;
|
||||
}
|
||||
autoCheck = age !== null && age >= 21 ? "mh-history" : "cmsp";
|
||||
} else if (p.includes("indiv")) {
|
||||
autoCheck = "delta-indiv";
|
||||
} else if (p.includes("delta dental ma")) {
|
||||
autoCheck = "ddma";
|
||||
} else if (p.includes("delta dental ins")) {
|
||||
@@ -1743,8 +1747,8 @@ export default function AppointmentsPage() {
|
||||
autoSubmit: true,
|
||||
}));
|
||||
}
|
||||
// BCBS / Altus are paper forms filled and saved in the background — come straight back here
|
||||
if (/^(bcbs|altus)/i.test(siteKey)) sessionStorage.setItem("ai_claim_return_to_schedule", "1");
|
||||
// BCBS / Altus / Delta Indiv are paper forms filled and saved in the background — come straight back here
|
||||
if (/^(bcbs|altus|delta_?indiv)/i.test(siteKey)) sessionStorage.setItem("ai_claim_return_to_schedule", "1");
|
||||
else sessionStorage.removeItem("ai_claim_return_to_schedule");
|
||||
setAiClaimModalOpen(false);
|
||||
setLocation(`/claims?appointmentId=${appointmentId}`);
|
||||
|
||||
@@ -120,6 +120,8 @@ export default function InsuranceStatusPage() {
|
||||
const isFormIncomplete = !memberId || !dateOfBirth;
|
||||
// United Ucard can also search by first name + last name + DOB when there's no member ID.
|
||||
const isUcardFormIncomplete = !dateOfBirth || (!memberId && !(firstName && lastName));
|
||||
// Delta Indiv portal searches by last name + DOB (member ID not needed)
|
||||
const isDeltaIndivFormIncomplete = !dateOfBirth || !lastName;
|
||||
// Altus searches by Member ID, or by Subscriber Last Name when the ID is unknown; First Name + DOB always required.
|
||||
const isAltusFormIncomplete = !dateOfBirth || !firstName?.trim() || (!memberId && !lastName?.trim());
|
||||
const [isCheckingEligibilityStatus, setIsCheckingEligibilityStatus] =
|
||||
@@ -194,8 +196,15 @@ export default function InsuranceStatusPage() {
|
||||
if (!raw) return;
|
||||
try {
|
||||
const { memberId: id, dob, autoCheck: ac, firstName: fn, lastName: ln } = JSON.parse(raw);
|
||||
if (id) setMemberId(id);
|
||||
if (fn) setFirstName(fn);
|
||||
if (ac === "delta-indiv") {
|
||||
// Delta Indiv can search by last name + DOB alone — clear any stale
|
||||
// member ID / first name left over from a previous patient.
|
||||
setMemberId(id ?? "");
|
||||
setFirstName(fn ?? "");
|
||||
} else {
|
||||
if (id) setMemberId(id);
|
||||
if (fn) setFirstName(fn);
|
||||
}
|
||||
if (ln) setLastName(ln);
|
||||
if (dob) {
|
||||
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD.
|
||||
@@ -759,7 +768,11 @@ export default function InsuranceStatusPage() {
|
||||
// Auto-trigger after prefill (from chatbot or schedule page "Check Eligibility")
|
||||
useEffect(() => {
|
||||
if (!pendingAutoCheck.current || !dateOfBirth) return;
|
||||
if (!memberId && !((pendingAutoCheck.current === "united-ucard" || pendingAutoCheck.current === "altus") && firstName && lastName)) return;
|
||||
if (
|
||||
!memberId &&
|
||||
!((pendingAutoCheck.current === "united-ucard" || pendingAutoCheck.current === "altus") && firstName && lastName) &&
|
||||
!(pendingAutoCheck.current === "delta-indiv" && lastName)
|
||||
) return;
|
||||
const check = pendingAutoCheck.current;
|
||||
pendingAutoCheck.current = null;
|
||||
if (check === "mh" || check === "mh-history") {
|
||||
@@ -1124,7 +1137,7 @@ export default function InsuranceStatusPage() {
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
isFormIncomplete={isDeltaIndivFormIncomplete}
|
||||
autoTrigger={triggerTarget === "delta-indiv"}
|
||||
onAutoTriggered={() => setTriggerTarget(null)}
|
||||
onPdfReady={async (pdfId, fallbackFilename) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import rawTuftsSCOCodeTable from "@/assets/data/procedureCodesTuftsSCO.json";
|
||||
import rawDeltainsCodeTable from "@/assets/data/procedureCodesDeltains.json";
|
||||
import rawBCBSCodeTable from "@/assets/data/procedureCodesBCBS.json";
|
||||
import rawAltusCodeTable from "@/assets/data/procedureCodesAltus.json";
|
||||
import rawDeltaIndivCodeTable from "@/assets/data/procedureCodesDeltaIndiv.json";
|
||||
import rawMetLifeCodeTable from "@/assets/data/procedureCodesMetLife.json";
|
||||
import { PROCEDURE_COMBOS } from "./procedureCombos";
|
||||
|
||||
@@ -30,6 +31,7 @@ const TUFTSSCO_CODE_TABLE = rawTuftsSCOCodeTable as CodeRow[];
|
||||
const DELTAINS_CODE_TABLE = rawDeltainsCodeTable as CodeRow[];
|
||||
const BCBS_CODE_TABLE = rawBCBSCodeTable as CodeRow[];
|
||||
const ALTUS_CODE_TABLE = rawAltusCodeTable as CodeRow[];
|
||||
const DELTAINDIV_CODE_TABLE = rawDeltaIndivCodeTable as CodeRow[];
|
||||
const METLIFE_CODE_TABLE = rawMetLifeCodeTable as CodeRow[];
|
||||
|
||||
export type ClaimFormLike = {
|
||||
@@ -109,6 +111,15 @@ const METLIFE_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
return m;
|
||||
})();
|
||||
|
||||
const DELTAINDIV_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
const m = new Map<string, CodeRow>();
|
||||
for (const r of DELTAINDIV_CODE_TABLE) {
|
||||
const k = normalizeCode(String(r["Procedure Code"] || ""));
|
||||
if (k && !m.has(k)) m.set(k, r);
|
||||
}
|
||||
return m;
|
||||
})();
|
||||
|
||||
const UNITEDDH_CODE_MAP: Map<string, CodeRow> = (() => {
|
||||
const m = new Map<string, CodeRow>();
|
||||
for (const r of UNITEDDH_CODE_TABLE) {
|
||||
@@ -152,6 +163,7 @@ function getCodeMap(insuranceSiteKey?: string): Map<string, CodeRow> {
|
||||
if (k === "ddma") return DDMA_CODE_MAP;
|
||||
if (k === "bcbs" || k === "bcbsma") return BCBS_CODE_MAP;
|
||||
if (k === "altus") return ALTUS_CODE_MAP;
|
||||
if (k === "deltaindiv") return DELTAINDIV_CODE_MAP;
|
||||
if (k === "metlife" || k === "metlifedental") return METLIFE_CODE_MAP;
|
||||
if (k === "deltains") return DELTAINS_CODE_MAP;
|
||||
if (k === "unitedsco" || k === "uniteddh" || k === "dentalhub") return UNITEDDH_CODE_MAP;
|
||||
|
||||
@@ -53,7 +53,9 @@ print("=" * 50)
|
||||
print("SELENIUM AGENT STARTING - CLEARING SESSIONS")
|
||||
print("=" * 50)
|
||||
clear_ddma_session_on_startup()
|
||||
clear_deltaindiv_session_on_startup()
|
||||
# Delta Indiv keeps its cookies across restarts so the trusted-device record
|
||||
# survives and the confirmation code isn't asked every time
|
||||
# (still cleared via /clear-deltaindiv-session when credentials change).
|
||||
clear_deltains_session_on_startup()
|
||||
clear_unitedsco_session_on_startup()
|
||||
clear_uniteducard_session_on_startup()
|
||||
@@ -579,7 +581,7 @@ async def deltaindiv_eligibility(request: Request):
|
||||
waiting_jobs += 1
|
||||
|
||||
# TODO: replace with the real Delta Indiv sign-in URL
|
||||
asyncio.create_task(_deltaindiv_worker_wrapper(sid, data, url="https://www.deltadentalma.com/sign-in"))
|
||||
asyncio.create_task(_deltaindiv_worker_wrapper(sid, data, url="https://my.deltadentalcoversme.com/sign-in"))
|
||||
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@ from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||
|
||||
from selenium_DeltaIndiv_eligibilityCheckWorker import AutomationDeltaIndivEligibilityCheck
|
||||
from selenium_DeltaIndiv_eligibilityCheckWorker import (
|
||||
AutomationDeltaIndivEligibilityCheck,
|
||||
OTP_INPUT_XPATH,
|
||||
OTP_CONFIRM_XPATH,
|
||||
SEARCH_FOR_PATIENT_XPATH,
|
||||
SEARCH_DASHBOARD_URL,
|
||||
)
|
||||
|
||||
# In-memory session store
|
||||
sessions: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -146,11 +152,11 @@ async def start_deltaindiv_run(sid: str, data: dict, url: str):
|
||||
if otp_value:
|
||||
print(f"[OTP poll {poll+1}] OTP received from app, typing it in...")
|
||||
try:
|
||||
otp_input = driver.find_element(By.XPATH, "//input[@data-test-id='MfaCode']")
|
||||
otp_input = driver.find_element(By.XPATH, OTP_INPUT_XPATH)
|
||||
otp_input.clear()
|
||||
otp_input.send_keys(otp_value)
|
||||
try:
|
||||
verify_btn = driver.find_element(By.XPATH, "//button[@data-test-id='ConfirmSubmitButton']")
|
||||
verify_btn = driver.find_element(By.XPATH, OTP_CONFIRM_XPATH)
|
||||
verify_btn.click()
|
||||
except Exception:
|
||||
otp_input.send_keys("\n")
|
||||
@@ -164,27 +170,22 @@ async def start_deltaindiv_run(sid: str, data: dict, url: str):
|
||||
current_url = driver.current_url.lower()
|
||||
print(f"[OTP poll {poll+1}/{max_polls}] URL: {current_url[:70]}...")
|
||||
|
||||
if "member" in current_url or "dashboard" in current_url or "eligibility" in current_url:
|
||||
try:
|
||||
member_search = WebDriverWait(driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
|
||||
)
|
||||
print("[OTP] Member search found — login successful!")
|
||||
login_success = True
|
||||
break
|
||||
except TimeoutException:
|
||||
print("[OTP] On member page but search input not found, continuing...")
|
||||
# Logged in once the dashboard's "Search for Patient" button shows (any URL)
|
||||
if driver.find_elements(By.XPATH, SEARCH_FOR_PATIENT_XPATH):
|
||||
print("[OTP] Search for Patient found — login successful!")
|
||||
login_success = True
|
||||
break
|
||||
|
||||
# Check if OTP input is still visible (user hasn't finished)
|
||||
try:
|
||||
otp_input_elem = driver.find_element(By.XPATH, "//input[@data-test-id='MfaCode']")
|
||||
otp_input_elem = driver.find_element(By.XPATH, OTP_INPUT_XPATH)
|
||||
print(f"[OTP poll {poll+1}] OTP input still visible - waiting...")
|
||||
except Exception:
|
||||
# OTP input gone — may mean login is completing; try members page
|
||||
if "sign-in" in current_url:
|
||||
print("[OTP] OTP input gone, trying to navigate to members page...")
|
||||
try:
|
||||
driver.get("https://www.deltadentalma.com/private/provider/dashboard")
|
||||
driver.get(SEARCH_DASHBOARD_URL)
|
||||
await asyncio.sleep(2)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -196,10 +197,10 @@ async def start_deltaindiv_run(sid: str, data: dict, url: str):
|
||||
# Final check — navigate directly to members page
|
||||
try:
|
||||
print("[OTP] Final attempt - navigating to members page...")
|
||||
driver.get("https://www.deltadentalma.com/private/provider/dashboard")
|
||||
driver.get(SEARCH_DASHBOARD_URL)
|
||||
await asyncio.sleep(3)
|
||||
member_search = WebDriverWait(driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
|
||||
WebDriverWait(driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, SEARCH_FOR_PATIENT_XPATH))
|
||||
)
|
||||
print("[OTP] Member search found — login successful!")
|
||||
login_success = True
|
||||
|
||||
@@ -11,9 +11,26 @@ import os
|
||||
import re
|
||||
import base64
|
||||
import calendar
|
||||
from datetime import datetime, date
|
||||
|
||||
from deltaindiv_browser_manager import get_browser_manager
|
||||
|
||||
# Provider Dashboard "Search for Patient" button (Individual and Family plans)
|
||||
SEARCH_FOR_PATIENT_XPATH = "//a[@data-test-id='SearchForPatientButton']"
|
||||
SEARCH_DASHBOARD_URL = "https://my.deltadentalcoversme.com/private/provider/dashboard"
|
||||
|
||||
# deltadentalma.com/self-service/mfa page: "Confirmation Code" input + CONFIRM button
|
||||
OTP_INPUT_XPATH = (
|
||||
"//input[@data-test-id='MfaCode']"
|
||||
" | //label[normalize-space()='Confirmation Code']/preceding::input[not(@type='hidden')][1]"
|
||||
" | //label[normalize-space()='Confirmation Code']/following::input[not(@type='hidden')][1]"
|
||||
" | //label[normalize-space()='Confirmation Code']/..//input[not(@type='hidden')]"
|
||||
)
|
||||
OTP_CONFIRM_XPATH = (
|
||||
"//button[@data-test-id='ConfirmSubmitButton']"
|
||||
" | //button[translate(normalize-space(), 'confirm', 'CONFIRM')='CONFIRM']"
|
||||
)
|
||||
|
||||
class AutomationDeltaIndivEligibilityCheck:
|
||||
def __init__(self, data):
|
||||
self.headless = False
|
||||
@@ -46,7 +63,7 @@ class AutomationDeltaIndivEligibilityCheck:
|
||||
|
||||
# Try to click logout button if visible
|
||||
try:
|
||||
self.driver.get("https://www.deltadentalma.com/")
|
||||
self.driver.get("https://my.deltadentalcoversme.com/")
|
||||
time.sleep(2)
|
||||
|
||||
logout_selectors = [
|
||||
@@ -95,58 +112,27 @@ class AutomationDeltaIndivEligibilityCheck:
|
||||
self.driver.get(url)
|
||||
time.sleep(2)
|
||||
|
||||
# Check if already on a logged-in page (persistent session from profile)
|
||||
# Persistent session: if the dashboard still shows "Search for Patient",
|
||||
# we're logged in — skip the sign-in / OTP flow entirely.
|
||||
print(f"[login] Current URL: {self.driver.current_url}")
|
||||
if self.driver.find_elements(By.XPATH, SEARCH_FOR_PATIENT_XPATH):
|
||||
print("[login] Search for Patient already visible - ALREADY_LOGGED_IN")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
try:
|
||||
current_url = self.driver.current_url
|
||||
print(f"[login] Current URL: {current_url}")
|
||||
|
||||
logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients", "private/provider"]
|
||||
is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns)
|
||||
|
||||
if is_logged_in_url and "sign-in" not in current_url.lower():
|
||||
print("[login] Already on logged-in page - skipping login entirely")
|
||||
if "member" not in current_url.lower():
|
||||
try:
|
||||
member_search = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
|
||||
)
|
||||
print("[login] Found member search input - returning ALREADY_LOGGED_IN")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except TimeoutException:
|
||||
members_url = "https://www.deltadentalma.com/private/provider/dashboard"
|
||||
print(f"[login] Navigating to members page: {members_url}")
|
||||
self.driver.get(members_url)
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
member_search = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
|
||||
)
|
||||
print("[login] Member search found - ALREADY_LOGGED_IN")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except TimeoutException:
|
||||
print("[login] Could not find member search, will try login")
|
||||
except Exception as e:
|
||||
print(f"[login] Error checking current state: {e}")
|
||||
print(f"[login] Checking session via dashboard: {SEARCH_DASHBOARD_URL}")
|
||||
self.driver.get(SEARCH_DASHBOARD_URL)
|
||||
WebDriverWait(self.driver, 8).until(
|
||||
EC.presence_of_element_located((By.XPATH, SEARCH_FOR_PATIENT_XPATH))
|
||||
)
|
||||
print("[login] Session still valid - ALREADY_LOGGED_IN")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except TimeoutException:
|
||||
print(f"[login] Not logged in (landed on {self.driver.current_url}) - signing in")
|
||||
|
||||
# Navigate to login URL
|
||||
self.driver.get(url)
|
||||
time.sleep(2)
|
||||
|
||||
# Check if session redirected us straight to member search
|
||||
try:
|
||||
current_url = self.driver.current_url
|
||||
print(f"[login] URL after navigation: {current_url}")
|
||||
|
||||
if "sign-in" not in current_url.lower():
|
||||
member_search = WebDriverWait(self.driver, 3).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
|
||||
)
|
||||
if member_search:
|
||||
print("[login] Session valid - skipping login")
|
||||
return "ALREADY_LOGGED_IN"
|
||||
except TimeoutException:
|
||||
print("[login] Proceeding with login")
|
||||
print(f"[login] URL after navigation: {self.driver.current_url}")
|
||||
|
||||
# Dismiss any "Authentication flow continued in another tab" modal
|
||||
modal_dismissed = False
|
||||
@@ -173,7 +159,7 @@ class AutomationDeltaIndivEligibilityCheck:
|
||||
try:
|
||||
otp_candidate = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//input[@data-test-id='MfaCode']")
|
||||
(By.XPATH, OTP_INPUT_XPATH)
|
||||
)
|
||||
)
|
||||
if otp_candidate:
|
||||
@@ -190,7 +176,7 @@ class AutomationDeltaIndivEligibilityCheck:
|
||||
time.sleep(2)
|
||||
try:
|
||||
member_search = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
|
||||
EC.presence_of_element_located((By.XPATH, "//a[@data-test-id='SearchForPatientButton' or contains(@href, 'check-patient-benefits-search')]"))
|
||||
)
|
||||
if member_search:
|
||||
print("[login] Already authenticated after modal dismiss")
|
||||
@@ -198,77 +184,130 @@ class AutomationDeltaIndivEligibilityCheck:
|
||||
except TimeoutException:
|
||||
pass
|
||||
|
||||
# Fill login form
|
||||
# coversme sign-in page may open on the "Member" tab — click "Dentist" first
|
||||
# so the "Universal Delta Sign In" link appears
|
||||
try:
|
||||
email_field = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.XPATH, "//input[@data-test-id='Username']"))
|
||||
dentist_tab = WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((
|
||||
By.XPATH, "//span[contains(@class,'mainLabel') and normalize-space()='Dentist']"
|
||||
)))
|
||||
dentist_tab.click()
|
||||
print("[login] Clicked Dentist tab")
|
||||
time.sleep(1)
|
||||
except TimeoutException:
|
||||
print("[login] Dentist tab not found - continuing")
|
||||
|
||||
# coversme sign-in page: click "Universal Delta Sign In" to reach the DDPA login form
|
||||
try:
|
||||
universal_btn = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable((By.XPATH, "//a[@data-test-id='DdpaLoginLink']"))
|
||||
)
|
||||
universal_btn.click()
|
||||
print("[login] Clicked Universal Delta Sign In")
|
||||
time.sleep(2)
|
||||
except TimeoutException:
|
||||
if "deltadentalcoversme.com/sign-in" in self.driver.current_url.lower():
|
||||
# Still on coversme — don't fall through to the Member tab's username form
|
||||
print("[login] Universal Delta Sign In link not found on coversme sign-in page")
|
||||
return "ERROR:LOGIN FAILED: Universal Delta Sign In link not found"
|
||||
print("[login] Universal Delta Sign In link not found - assuming already on login form")
|
||||
|
||||
# auth.deltadental.com "Have we met?" page: enter username, then click Next
|
||||
username_xpath = (
|
||||
"//input[@id='usernameInput' or @name='username' or @data-test-id='Username']"
|
||||
)
|
||||
try:
|
||||
username_field = WebDriverWait(self.driver, 15).until(
|
||||
EC.element_to_be_clickable((By.XPATH, username_xpath))
|
||||
)
|
||||
except TimeoutException:
|
||||
print("[login] Could not find login form - page may have changed")
|
||||
print(f"[login] Could not find username field - page may have changed. URL: {self.driver.current_url}")
|
||||
return "ERROR: Login form not found"
|
||||
|
||||
email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@data-test-id='Username']")))
|
||||
email_field.clear()
|
||||
email_field.send_keys(self.deltaindiv_username)
|
||||
username_field.clear()
|
||||
username_field.send_keys(self.deltaindiv_username)
|
||||
print("[login] Entered username")
|
||||
|
||||
password_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@data-test-id='Password']")))
|
||||
# Next is an <a id="verify-user">; aria-disabled flips to "false" once a username is typed
|
||||
next_button = wait.until(EC.element_to_be_clickable((
|
||||
By.XPATH, "//a[@id='verify-user' and @aria-disabled='false']"
|
||||
)))
|
||||
next_button.click()
|
||||
print("[login] Clicked Next")
|
||||
time.sleep(2)
|
||||
|
||||
# dp-prod-ddma auth0 page: username is prefilled, enter password, then SIGN IN
|
||||
password_field = wait.until(EC.element_to_be_clickable((By.XPATH, "//input[@id='password']")))
|
||||
password_field.clear()
|
||||
password_field.send_keys(self.deltaindiv_password)
|
||||
print("[login] Entered password")
|
||||
|
||||
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@data-test-id='SigninSubmitButton']")))
|
||||
login_button.click()
|
||||
try:
|
||||
login_button = WebDriverWait(self.driver, 5).until(EC.element_to_be_clickable((
|
||||
By.XPATH,
|
||||
"//button[translate(normalize-space(), 'sign', 'SIGN')='SIGN IN' or @type='submit']"
|
||||
)))
|
||||
login_button.click()
|
||||
print("[login] Clicked SIGN IN")
|
||||
except TimeoutException:
|
||||
password_field.send_keys(Keys.RETURN)
|
||||
print("[login] SIGN IN button not found - submitted with Enter")
|
||||
|
||||
# Save credentials hash after login attempt
|
||||
if self.deltaindiv_username:
|
||||
browser_manager.save_credentials_hash(self.deltaindiv_username)
|
||||
|
||||
# OTP detection — wait up to 30 seconds for OTP input
|
||||
# After SIGN IN wait (up to 30s) for whichever page comes next:
|
||||
# confirmation-code (MFA) page or the dashboard.
|
||||
def next_page(d):
|
||||
url = d.current_url.lower()
|
||||
if "self-service/mfa" in url or d.find_elements(By.XPATH, OTP_INPUT_XPATH):
|
||||
return "otp"
|
||||
if d.find_elements(By.XPATH, SEARCH_FOR_PATIENT_XPATH):
|
||||
return "dashboard"
|
||||
return False
|
||||
|
||||
try:
|
||||
otp_candidate = WebDriverWait(self.driver, 30).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.XPATH, "//input[@data-test-id='MfaCode']")
|
||||
)
|
||||
)
|
||||
if otp_candidate:
|
||||
print("[login] OTP input detected -> OTP_REQUIRED")
|
||||
return "OTP_REQUIRED"
|
||||
landed = WebDriverWait(self.driver, 30).until(next_page)
|
||||
except TimeoutException:
|
||||
print("[login] No OTP input detected in allowed time.")
|
||||
# Check if we're now on the member search page (login succeeded without OTP)
|
||||
try:
|
||||
current_url = self.driver.current_url.lower()
|
||||
if "member" in current_url or "dashboard" in current_url:
|
||||
member_search = WebDriverWait(self.driver, 5).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//a[contains(@href, 'check-patient-benefits-search')]"))
|
||||
)
|
||||
print("[login] Login successful - now on member search page")
|
||||
return "SUCCESS"
|
||||
except TimeoutException:
|
||||
pass
|
||||
landed = None
|
||||
print(f"[login] After SIGN IN: {landed or 'nothing recognised'} — URL: {self.driver.current_url}")
|
||||
|
||||
# Check for error messages
|
||||
try:
|
||||
error_elem = WebDriverWait(self.driver, 3).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//*[contains(@class,'error') or contains(text(),'invalid') or contains(text(),'failed')]"))
|
||||
)
|
||||
print(f"[login] Login failed - error detected: {error_elem.text}")
|
||||
return f"ERROR:LOGIN FAILED: {error_elem.text}"
|
||||
except TimeoutException:
|
||||
pass
|
||||
if landed == "otp":
|
||||
print("[login] Confirmation code page detected -> OTP_REQUIRED")
|
||||
return "OTP_REQUIRED"
|
||||
|
||||
if "sign-in" in self.driver.current_url.lower():
|
||||
print("[login] Login failed - still on login page")
|
||||
return "ERROR:LOGIN FAILED: Still on login page"
|
||||
|
||||
print("[login] Assuming login succeeded (no errors detected)")
|
||||
if landed == "dashboard":
|
||||
print("[login] Login successful - Search for Patient visible")
|
||||
return "SUCCESS"
|
||||
|
||||
# Only a visible, non-empty error message counts as a failed login
|
||||
for el in self.driver.find_elements(
|
||||
By.XPATH,
|
||||
"//*[contains(@class,'error') or contains(@class,'alert-danger') or @role='alert']"
|
||||
):
|
||||
try:
|
||||
text = el.text.strip()
|
||||
if text and el.is_displayed():
|
||||
print(f"[login] Login failed - error detected: {text}")
|
||||
return f"ERROR:LOGIN FAILED: {text}"
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if "sign-in" in self.driver.current_url.lower() or self.driver.find_elements(
|
||||
By.XPATH, "//input[@id='password']"
|
||||
):
|
||||
print("[login] Login failed - still on login page")
|
||||
return "ERROR:LOGIN FAILED: Still on login page"
|
||||
|
||||
print("[login] Assuming login succeeded (no errors detected)")
|
||||
return "SUCCESS"
|
||||
|
||||
except Exception as e:
|
||||
print("[login] Exception during login:", e)
|
||||
return f"ERROR:LOGIN FAILED: {e}"
|
||||
|
||||
def step1(self):
|
||||
"""Open the Group/Health Connector search page and search by Subscriber ID + Birthdate."""
|
||||
"""Click Search for Patient on the dashboard and search by Last Name + Date of Birth."""
|
||||
wait = WebDriverWait(self.driver, 30)
|
||||
|
||||
try:
|
||||
@@ -279,113 +318,100 @@ class AutomationDeltaIndivEligibilityCheck:
|
||||
fields.append(f"DOB: {self.dateOfBirth}")
|
||||
print(f"[Delta Indiv step1] Starting search with: {', '.join(fields)}")
|
||||
|
||||
search_page_url = "https://www.deltadentalma.com/private/provider/check-patient-benefits-search"
|
||||
|
||||
# Try navigating straight to the search page first — avoids any click/visibility issues
|
||||
print(f"[Delta Indiv step1] Navigating directly to search page: {search_page_url}")
|
||||
self.driver.get(search_page_url)
|
||||
time.sleep(2)
|
||||
|
||||
if "check-patient-benefits-search" not in self.driver.current_url.lower():
|
||||
# Direct deep-link didn't stick (e.g. bounced back to sign-in/dashboard) —
|
||||
# fall back to clicking the Group/Health Connector link from the dashboard.
|
||||
print(f"[Delta Indiv step1] Direct navigation redirected to: {self.driver.current_url} — falling back to clicking the link")
|
||||
|
||||
if "private/provider/dashboard" not in self.driver.current_url.lower():
|
||||
dashboard_url = "https://www.deltadentalma.com/private/provider/dashboard"
|
||||
print(f"[Delta Indiv step1] Navigating to dashboard: {dashboard_url}")
|
||||
self.driver.get(dashboard_url)
|
||||
# Provider Dashboard -> "Search for Patient" (Individual and Family plans).
|
||||
# Look for the button wherever login landed; only navigate if it isn't there.
|
||||
search_link = None
|
||||
for nav_url in (None, SEARCH_DASHBOARD_URL, "https://my.deltadentalcoversme.com/"):
|
||||
if nav_url:
|
||||
print(f"[Delta Indiv step1] Search for Patient not found — navigating to {nav_url}")
|
||||
self.driver.get(nav_url)
|
||||
time.sleep(2)
|
||||
|
||||
link_xpath = "//a[contains(@href, 'check-patient-benefits-search')]"
|
||||
try:
|
||||
connector_link = WebDriverWait(self.driver, 20).until(
|
||||
EC.element_to_be_clickable((By.XPATH, link_xpath))
|
||||
search_link = WebDriverWait(self.driver, 5 if nav_url is None else 15).until(
|
||||
EC.presence_of_element_located((By.XPATH, SEARCH_FOR_PATIENT_XPATH))
|
||||
)
|
||||
connector_link.click()
|
||||
break
|
||||
except TimeoutException:
|
||||
# Element may exist but be hidden (e.g. a responsive duplicate) — try any match via JS click
|
||||
candidates = self.driver.find_elements(By.XPATH, link_xpath)
|
||||
print(f"[Delta Indiv step1] element_to_be_clickable timed out; found {len(candidates)} matching link(s) in DOM")
|
||||
if candidates:
|
||||
try:
|
||||
self.driver.execute_script("arguments[0].click();", candidates[0])
|
||||
except Exception as js_err:
|
||||
print(f"[Delta Indiv step1] JS click failed: {js_err}")
|
||||
print(f"[Delta Indiv step1] Current URL: {self.driver.current_url}")
|
||||
return "ERROR: Could not open Group/Health Connector search page"
|
||||
else:
|
||||
print(f"[Delta Indiv step1] No Group/Health Connector link found in DOM. Current URL: {self.driver.current_url}")
|
||||
return "ERROR: Could not open Group/Health Connector search page"
|
||||
print(f"[Delta Indiv step1] No Search for Patient button at: {self.driver.current_url}")
|
||||
|
||||
if search_link is None:
|
||||
if "check-patient-benefits-search" not in self.driver.current_url.lower():
|
||||
return "ERROR: Could not open patient search page"
|
||||
else:
|
||||
print(f"[Delta Indiv step1] Found Search for Patient on: {self.driver.current_url}")
|
||||
href = search_link.get_attribute("href")
|
||||
try:
|
||||
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", search_link)
|
||||
search_link.click()
|
||||
except Exception as click_err:
|
||||
print(f"[Delta Indiv step1] Normal click failed ({click_err}) — using JS click")
|
||||
self.driver.execute_script("arguments[0].click();", search_link)
|
||||
print("[Delta Indiv step1] Clicked Search for Patient")
|
||||
|
||||
try:
|
||||
wait.until(lambda d: "check-patient-benefits-search" in d.current_url)
|
||||
WebDriverWait(self.driver, 15).until(
|
||||
lambda d: "check-patient-benefits-search" in d.current_url.lower()
|
||||
)
|
||||
except TimeoutException:
|
||||
print(f"[Delta Indiv step1] Click did not navigate to search page. Current URL: {self.driver.current_url}")
|
||||
return "ERROR: Could not open Group/Health Connector search page"
|
||||
if not href:
|
||||
print(f"[Delta Indiv step1] Click did not navigate. Current URL: {self.driver.current_url}")
|
||||
return "ERROR: Could not open patient search page"
|
||||
print(f"[Delta Indiv step1] Click did not navigate — opening link directly: {href}")
|
||||
self.driver.get(href)
|
||||
try:
|
||||
wait.until(lambda d: "check-patient-benefits-search" in d.current_url.lower())
|
||||
except TimeoutException:
|
||||
print(f"[Delta Indiv step1] Could not reach search page. Current URL: {self.driver.current_url}")
|
||||
return "ERROR: Could not open patient search page"
|
||||
|
||||
print(f"[Delta Indiv step1] On Group/Health Connector search page: {self.driver.current_url}")
|
||||
print(f"[Delta Indiv step1] On patient search page: {self.driver.current_url}")
|
||||
|
||||
# Fill Birthdate (masked mm/dd/yyyy input — send digits only, mask inserts slashes)
|
||||
if self.dateOfBirth:
|
||||
try:
|
||||
dob_parts = self.dateOfBirth.split("-")
|
||||
year = dob_parts[0]
|
||||
month = dob_parts[1].zfill(2)
|
||||
day = dob_parts[2].zfill(2)
|
||||
# "Search by Patient Details" tab (default): Last Name + Date of Birth, then Submit
|
||||
if not self.lastName or not self.dateOfBirth:
|
||||
return "ERROR: Last name and date of birth are required for Delta Indiv search"
|
||||
|
||||
birthdate_input = wait.until(EC.element_to_be_clickable(
|
||||
(By.XPATH, "//input[@data-test-id='InStateSearchBirthdate']")
|
||||
))
|
||||
birthdate_input.click()
|
||||
birthdate_input.send_keys(Keys.CONTROL, "a")
|
||||
birthdate_input.send_keys(Keys.BACKSPACE)
|
||||
birthdate_input.send_keys(f"{month}{day}{year}")
|
||||
print(f"[Delta Indiv step1] Filled Birthdate: {month}/{day}/{year}")
|
||||
except Exception as e:
|
||||
print(f"[Delta Indiv step1] Warning: Could not fill Birthdate: {e}")
|
||||
last_name_input = wait.until(EC.element_to_be_clickable(
|
||||
(By.XPATH, "//input[@data-test-id='LastName']")
|
||||
))
|
||||
last_name_input.clear()
|
||||
last_name_input.send_keys(self.lastName)
|
||||
print(f"[Delta Indiv step1] Filled Last Name: {self.lastName}")
|
||||
|
||||
# Fill Subscriber ID (== member ID)
|
||||
if self.memberId:
|
||||
try:
|
||||
subscriber_input = wait.until(EC.element_to_be_clickable(
|
||||
(By.XPATH, "//input[@data-test-id='InStateSearchSubscriberId']")
|
||||
))
|
||||
subscriber_input.clear()
|
||||
subscriber_input.send_keys(self.memberId)
|
||||
print(f"[Delta Indiv step1] Filled Subscriber ID: {self.memberId}")
|
||||
except Exception as e:
|
||||
print(f"[Delta Indiv step1] Warning: Could not fill Subscriber ID: {e}")
|
||||
# Masked mm/dd/yyyy input — send digits only, mask inserts slashes
|
||||
dob_parts = self.dateOfBirth.split("-")
|
||||
year = dob_parts[0]
|
||||
month = dob_parts[1].zfill(2)
|
||||
day = dob_parts[2].zfill(2)
|
||||
|
||||
dob_input = wait.until(EC.element_to_be_clickable(
|
||||
(By.XPATH, "//input[@data-test-id='DateOfBirth']")
|
||||
))
|
||||
dob_input.click()
|
||||
dob_input.send_keys(Keys.CONTROL, "a")
|
||||
dob_input.send_keys(Keys.BACKSPACE)
|
||||
dob_input.send_keys(f"{month}{day}{year}")
|
||||
print(f"[Delta Indiv step1] Filled Date of Birth: {month}/{day}/{year}")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# Click Search button
|
||||
search_btn = wait.until(EC.element_to_be_clickable(
|
||||
(By.XPATH, "//button[@data-test-id='InStateSearchButton']")
|
||||
submit_btn = wait.until(EC.element_to_be_clickable(
|
||||
(By.XPATH, "//button[@data-test-id='SearchByPatientButton']")
|
||||
))
|
||||
search_btn.click()
|
||||
print("[Delta Indiv step1] Clicked Search button")
|
||||
submit_btn.click()
|
||||
print("[Delta Indiv step1] Clicked Submit")
|
||||
|
||||
# Wait for either the Members results table to render, or (when the
|
||||
# Subscriber ID + DOB match exactly one family member) Delta Indiv to skip
|
||||
# the results list and navigate straight to the patient detail page.
|
||||
# Note: the detail page URL is nested UNDER the search page path
|
||||
# (".../check-patient-benefits-search/check-patient-benefit-details"),
|
||||
# so "check-patient-benefits-search" alone can't distinguish them —
|
||||
# only the detail page's own "-benefit-details" segment can.
|
||||
# Submit normally lands straight on the "Benefits for <NAME>" details page;
|
||||
# a results list (multiple matches) is handled in step2.
|
||||
try:
|
||||
WebDriverWait(self.driver, 15).until(
|
||||
lambda d: d.find_elements(By.XPATH, "//table//tbody//tr")
|
||||
or "check-patient-benefit-details" in d.current_url.lower()
|
||||
WebDriverWait(self.driver, 20).until(
|
||||
lambda d: self._on_detail_page()
|
||||
or d.find_elements(By.XPATH, "//table//tbody//tr//a")
|
||||
)
|
||||
time.sleep(2)
|
||||
except TimeoutException:
|
||||
print("[Delta Indiv step1] Warning: No results table appeared within timeout")
|
||||
print("[Delta Indiv step1] Warning: No patient details or results appeared within timeout")
|
||||
return "ERROR: INVALID SEARCH CRITERIA"
|
||||
|
||||
if "check-patient-benefit-details" in self.driver.current_url.lower():
|
||||
print(f"[Delta Indiv step1] Search navigated directly to detail page: {self.driver.current_url}")
|
||||
|
||||
print("[Delta Indiv step1] Search completed successfully")
|
||||
return "Success"
|
||||
|
||||
@@ -393,270 +419,178 @@ class AutomationDeltaIndivEligibilityCheck:
|
||||
print(f"[Delta Indiv step1] Exception: {e}")
|
||||
return f"ERROR:STEP1 - {e}"
|
||||
|
||||
def step2(self):
|
||||
"""Navigate to patient detail page and generate PDF."""
|
||||
wait = WebDriverWait(self.driver, 90)
|
||||
def _on_detail_page(self):
|
||||
"""True when the "Benefits for <NAME>" patient details page is showing."""
|
||||
try:
|
||||
return bool(self.driver.find_elements(
|
||||
By.XPATH,
|
||||
"//h1[starts-with(normalize-space(), 'Benefits for')]"
|
||||
" | //*[normalize-space()='Subscriber #:']"
|
||||
))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _open_patient_from_results(self):
|
||||
"""If the search returned a list instead of the details page, click the matching patient."""
|
||||
rows = self.driver.find_elements(By.XPATH, "//table//tbody//tr[.//a]")
|
||||
print(f"[Delta Indiv step2] Search returned {len(rows)} result row(s)")
|
||||
if not rows:
|
||||
return False
|
||||
|
||||
target_row = None
|
||||
if self.dateOfBirth:
|
||||
try:
|
||||
y, m, d = self.dateOfBirth.split("-")
|
||||
dob_variants = {
|
||||
f"{calendar.month_abbr[int(m)]} {int(d)}, {y}".lower(),
|
||||
f"{m.zfill(2)}/{d.zfill(2)}/{y}",
|
||||
}
|
||||
target_row = next(
|
||||
(r for r in rows if any(v in r.text.lower() for v in dob_variants)), None
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Delta Indiv step2] DOB matching error (non-fatal): {e}")
|
||||
if target_row is None and self.firstName:
|
||||
target_row = next(
|
||||
(r for r in rows if self.firstName.strip().lower() in r.text.lower()), None
|
||||
)
|
||||
if target_row is None:
|
||||
target_row = rows[0]
|
||||
print("[Delta Indiv step2] No DOB/name match — using first result row")
|
||||
|
||||
target_row.find_element(By.XPATH, ".//a").click()
|
||||
WebDriverWait(self.driver, 20).until(lambda d: self._on_detail_page())
|
||||
return True
|
||||
|
||||
def _write_full_page_pdf(self, pdf_path):
|
||||
"""Print the current window as ONE long PDF page (no page breaks)."""
|
||||
dims = self.driver.execute_script(
|
||||
"return {w: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),"
|
||||
" h: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)};"
|
||||
)
|
||||
# 8.5in wide; shrink to fit the page width, then make the paper as tall as the content
|
||||
margin_in = 0.3
|
||||
page_width_in = 8.5
|
||||
scale = max(0.1, min(1.0, (page_width_in - 2 * margin_in) / (dims["w"] / 96)))
|
||||
page_height_in = dims["h"] / 96 * scale + 2 * margin_in + 0.2
|
||||
result = self.driver.execute_cdp_cmd("Page.printToPDF", {
|
||||
"landscape": False,
|
||||
"displayHeaderFooter": False,
|
||||
"printBackground": True,
|
||||
"preferCSSPageSize": False,
|
||||
"paperWidth": page_width_in,
|
||||
"paperHeight": page_height_in,
|
||||
"marginTop": margin_in,
|
||||
"marginBottom": margin_in,
|
||||
"marginLeft": margin_in,
|
||||
"marginRight": margin_in,
|
||||
"scale": scale,
|
||||
})
|
||||
with open(pdf_path, "wb") as f:
|
||||
f.write(base64.b64decode(result.get("data", "")))
|
||||
|
||||
def _save_print_pdf(self, pdf_path):
|
||||
"""
|
||||
Click the details page's Print link — it downloads a PDF of the page — and move
|
||||
that download to pdf_path. Falls back to printing the page itself if no PDF arrives.
|
||||
"""
|
||||
files_before = set(os.listdir(self.download_dir))
|
||||
|
||||
try:
|
||||
import re
|
||||
print_link = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//a[@data-test-id='PrintCpdIcon']"))
|
||||
)
|
||||
self.driver.execute_script("arguments[0].click();", print_link)
|
||||
print("[Delta Indiv step2] Clicked Print")
|
||||
|
||||
eligibilityText = "unknown"
|
||||
foundMemberId = self.memberId or ""
|
||||
# If the caller already knows this patient (e.g. an existing patient record
|
||||
# being rechecked), use that name directly — no need to scrape/parse it off
|
||||
# the Delta Indiv page, only the eligibility status is actually needed.
|
||||
patientName = f"{self.firstName} {self.lastName}".strip() if self.firstName and self.lastName else ""
|
||||
known_patient_name = bool(patientName)
|
||||
def downloaded_pdf(_):
|
||||
new = set(os.listdir(self.download_dir)) - files_before
|
||||
# Chrome writes .crdownload until the download finishes
|
||||
if any(f.endswith(".crdownload") for f in new):
|
||||
return False
|
||||
pdfs = [f for f in new if f.lower().endswith(".pdf")]
|
||||
return pdfs[0] if pdfs else False
|
||||
|
||||
# Delta Indiv now sometimes skips the family-members results table entirely and
|
||||
# navigates straight to the single-patient detail page (when Subscriber ID +
|
||||
# DOB match exactly one person). In that case there's no members table to
|
||||
# parse here — skip straight to the detail-page fallback extraction below.
|
||||
already_on_detail_page = "check-patient-benefit-details" in self.driver.current_url.lower()
|
||||
name = WebDriverWait(self.driver, 60).until(downloaded_pdf)
|
||||
os.replace(os.path.join(self.download_dir, name), pdf_path)
|
||||
print(f"[Delta Indiv step2] Print downloaded PDF: {name}")
|
||||
except Exception as e:
|
||||
print(f"[Delta Indiv step2] Print download failed ({e}) — printing the page itself")
|
||||
self._write_full_page_pdf(pdf_path)
|
||||
|
||||
rows = []
|
||||
target_row = None
|
||||
print(f"[Delta Indiv step2] PDF saved at: {pdf_path}")
|
||||
|
||||
if not already_on_detail_page:
|
||||
# Wait for results table, then pause for full render
|
||||
try:
|
||||
WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
|
||||
)
|
||||
time.sleep(2) # Let the row content fully render after table appears
|
||||
except TimeoutException:
|
||||
print("[Delta Indiv step2] Warning: Results table not found within timeout")
|
||||
def step2(self):
|
||||
"""On the patient details page: extract Subscriber #, name and eligibility, then save a PDF."""
|
||||
try:
|
||||
if not self._on_detail_page():
|
||||
if not self._open_patient_from_results():
|
||||
return {"status": "error", "message": "Patient details page not found"}
|
||||
|
||||
# The Members table lists the whole family sharing this subscriber ID —
|
||||
# find the row matching this patient's DOB (falls back to last name, then first row).
|
||||
rows = self.driver.find_elements(By.XPATH, "//table//tbody//tr")
|
||||
print(f"[Delta Indiv step2] Found {len(rows)} family member row(s)")
|
||||
else:
|
||||
print(f"[Delta Indiv step2] Already on detail page: {self.driver.current_url}")
|
||||
print(f"[Delta Indiv step2] On patient details page: {self.driver.current_url}")
|
||||
|
||||
if self.dateOfBirth and rows:
|
||||
try:
|
||||
dob_parts = self.dateOfBirth.split("-")
|
||||
dob_year, dob_month, dob_day = dob_parts[0], int(dob_parts[1]), int(dob_parts[2])
|
||||
expected_dob = f"{calendar.month_abbr[dob_month]} {dob_day}, {dob_year}".lower()
|
||||
for row in rows:
|
||||
cells = row.find_elements(By.XPATH, "./td")
|
||||
if len(cells) >= 3 and cells[2].text.strip().lower() == expected_dob:
|
||||
target_row = row
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[Delta Indiv step2] DOB matching error (non-fatal): {e}")
|
||||
|
||||
if target_row is None and self.lastName and rows:
|
||||
for row in rows:
|
||||
if self.lastName.strip().lower() in row.text.lower():
|
||||
target_row = row
|
||||
break
|
||||
|
||||
if target_row is None and rows:
|
||||
target_row = rows[0]
|
||||
print("[Delta Indiv step2] No DOB/name match — defaulting to first family member row")
|
||||
|
||||
if target_row is not None:
|
||||
try:
|
||||
cells = target_row.find_elements(By.XPATH, "./td")
|
||||
if cells and not known_patient_name:
|
||||
patientName = cells[0].text.strip()
|
||||
print(f"[Delta Indiv step2] Extracted patient name from row: '{patientName}'")
|
||||
if len(cells) >= 2 and cells[1].text.strip():
|
||||
foundMemberId = cells[1].text.strip()
|
||||
if len(cells) >= 7:
|
||||
status_text = cells[6].text.strip().lower()
|
||||
if "inactive" in status_text or "termed" in status_text:
|
||||
eligibilityText = "inactive"
|
||||
elif "active" in status_text:
|
||||
eligibilityText = "active"
|
||||
else:
|
||||
eligibilityText = status_text
|
||||
print(f"[Delta Indiv step2] Found eligibility status: {eligibilityText}")
|
||||
except Exception as e:
|
||||
print(f"[Delta Indiv step2] Error reading matched row cells: {e}")
|
||||
|
||||
# Click into the member's detail page
|
||||
try:
|
||||
name_link = target_row.find_element(By.XPATH, ".//a")
|
||||
name_link.click()
|
||||
print(f"[Delta Indiv step2] Clicked into detail page for: {patientName}")
|
||||
except Exception as e:
|
||||
print(f"[Delta Indiv step2] Warning: Could not click patient name link: {e}")
|
||||
else:
|
||||
print("[Delta Indiv step2] Warning: No family member rows found in search results")
|
||||
|
||||
# Wait for the detail page to load (URL gains the "-benefit-details" segment)
|
||||
try:
|
||||
WebDriverWait(self.driver, 15).until(
|
||||
lambda d: "check-patient-benefit-details" in d.current_url.lower()
|
||||
)
|
||||
print(f"[Delta Indiv step2] Navigated to detail page: {self.driver.current_url}")
|
||||
except Exception:
|
||||
print(f"[Delta Indiv step2] Warning: Still on search page: {self.driver.current_url}")
|
||||
|
||||
# Wait for meaningful content on the detail page
|
||||
for selector in [
|
||||
"//*[contains(text(),'Date of Birth') or contains(text(),'Address') or contains(text(),'Member ID')]",
|
||||
"//table", "//h1", "//h2",
|
||||
]:
|
||||
try:
|
||||
WebDriverWait(self.driver, 15).until(
|
||||
EC.presence_of_element_located((By.XPATH, selector))
|
||||
)
|
||||
print(f"[Delta Indiv step2] Detail page content loaded: {selector}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
time.sleep(3) # Let JavaScript finish rendering all sections
|
||||
|
||||
# Fallback: refine patient name from the detail page text if row extraction failed
|
||||
if not patientName:
|
||||
try:
|
||||
page_lines = self.driver.execute_script(
|
||||
"return document.body.innerText;"
|
||||
).split('\n')
|
||||
page_lines = [l.strip() for l in page_lines if l.strip()]
|
||||
except Exception as e:
|
||||
print(f"[Delta Indiv step2] Could not get page text: {e}")
|
||||
page_lines = []
|
||||
|
||||
# UI noise words that appear in accessibility/sort-button text or field labels
|
||||
ui_noise = ['click', 'sort', 'activate', 'direction', 'enter', 'space',
|
||||
'current', 'use ', 'table', 'column', 'filter', 'search',
|
||||
'date', 'birth', 'relationship', 'subscriber', 'coverage',
|
||||
'status', 'period', 'network', 'plan', 'deductible']
|
||||
|
||||
def looks_like_name(text):
|
||||
"""Return True if text is a plausible patient name."""
|
||||
t = text.strip()
|
||||
# Must be 2–60 chars, letters/spaces/hyphens/apostrophes only (no periods)
|
||||
if not t or not (2 <= len(t) <= 60):
|
||||
return False
|
||||
if not re.match(r"^[A-Za-z\s\-']+$", t):
|
||||
return False
|
||||
# Must not be UI noise
|
||||
if any(w in t.lower() for w in ui_noise):
|
||||
return False
|
||||
return True
|
||||
|
||||
# Preferred: the "Benefits for JOSHUA ZHANG" heading names the actual
|
||||
# patient. "Subscriber name:" is the policy holder (e.g. a parent), so
|
||||
# it is only used below if this heading is missing.
|
||||
for line in page_lines:
|
||||
heading = re.match(r'^benefits\s+for\s+(.+)$', line, re.IGNORECASE)
|
||||
if heading and looks_like_name(heading.group(1)):
|
||||
patientName = heading.group(1).strip()
|
||||
print(f"[Delta Indiv step2] Extracted name from 'Benefits for' heading: '{patientName}'")
|
||||
break
|
||||
|
||||
# Scan lines for the "Name" label. When "Name" appears alone we scan
|
||||
# forward past accessibility text to the first plausible name value.
|
||||
for i, line in enumerate(page_lines):
|
||||
if patientName:
|
||||
break
|
||||
# Case 1: "Name : Value" or "Name: Value" on the same line
|
||||
# (Delta Indiv's detail page labels this "Subscriber name:", so that
|
||||
# prefix must be accepted rather than excluded)
|
||||
same_line = re.match(
|
||||
r'^(?:member\s+|subscriber\s+)?name\s*[:\-]\s*(.+)$', line, re.IGNORECASE
|
||||
)
|
||||
if same_line and not re.search(
|
||||
r'(provider|group|plan)\s+name', line, re.IGNORECASE
|
||||
):
|
||||
candidate = same_line.group(1).strip()
|
||||
if looks_like_name(candidate):
|
||||
patientName = candidate
|
||||
print(f"[Delta Indiv step2] Extracted name from 'Name:' label: '{patientName}'")
|
||||
break
|
||||
|
||||
# Case 2: "Name" or "Name:" alone on a line — scan forward for value
|
||||
elif re.match(r'^(?:member\s+|subscriber\s+)?name\s*:?\s*$', line, re.IGNORECASE):
|
||||
for j in range(i + 1, min(i + 6, len(page_lines))):
|
||||
candidate = page_lines[j].strip()
|
||||
if looks_like_name(candidate):
|
||||
patientName = candidate
|
||||
print(f"[Delta Indiv step2] Extracted name from 'Name' label (line +{j-i}): '{patientName}'")
|
||||
break
|
||||
if patientName:
|
||||
break
|
||||
|
||||
# Clean patient name — strip any remaining date artifacts
|
||||
if patientName:
|
||||
cleaned = re.sub(r'\s*DOB[:\s]*\d{1,2}/\d{1,2}/\d{2,4}\s*', '', patientName, flags=re.IGNORECASE).strip()
|
||||
cleaned = re.sub(r'\s+\d{1,2}/\d{1,2}/\d{2,4}$', '', cleaned).strip()
|
||||
patientName = cleaned if cleaned else patientName
|
||||
|
||||
if not patientName:
|
||||
print("[Delta Indiv step2] Could not extract patient name from detail page")
|
||||
|
||||
# Fallback: refine eligibility status from the detail page's "Coverage is:"
|
||||
# label if the family-table row extraction didn't set it (e.g. Delta Indiv
|
||||
# navigated straight to the detail page, skipping the members table).
|
||||
if eligibilityText == "unknown":
|
||||
try:
|
||||
page_text = self.driver.execute_script("return document.body.innerText;")
|
||||
coverage_match = re.search(
|
||||
r'coverage\s+is\s*[:\-]?\s*([A-Za-z]+)', page_text, re.IGNORECASE
|
||||
)
|
||||
if coverage_match:
|
||||
status_text = coverage_match.group(1).strip().lower()
|
||||
if "inactive" in status_text or "termed" in status_text:
|
||||
eligibilityText = "inactive"
|
||||
elif "active" in status_text:
|
||||
eligibilityText = "active"
|
||||
else:
|
||||
eligibilityText = status_text
|
||||
print(f"[Delta Indiv step2] Extracted eligibility from 'Coverage is:' label: '{eligibilityText}'")
|
||||
except Exception as e:
|
||||
print(f"[Delta Indiv step2] Could not extract eligibility from 'Coverage is:' label: {e}")
|
||||
|
||||
# Wait for the page to be fully ready before capturing PDF
|
||||
try:
|
||||
WebDriverWait(self.driver, 30).until(
|
||||
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3) # Let the benefit maximum sections finish rendering
|
||||
|
||||
# Extra wait for lazy-loaded sections (benefits summary, member history, etc.)
|
||||
time.sleep(4)
|
||||
page_text = self.driver.execute_script("return document.body.innerText;") or ""
|
||||
|
||||
# Generate PDF via Chrome DevTools Protocol
|
||||
print("[Delta Indiv step2] Generating PDF of patient detail page...")
|
||||
pdf_options = {
|
||||
"landscape": False,
|
||||
"displayHeaderFooter": False,
|
||||
"printBackground": True,
|
||||
"preferCSSPageSize": True,
|
||||
"paperWidth": 8.5,
|
||||
"paperHeight": 11,
|
||||
"marginTop": 0.4,
|
||||
"marginBottom": 0.4,
|
||||
"marginLeft": 0.4,
|
||||
"marginRight": 0.4,
|
||||
"scale": 0.9,
|
||||
}
|
||||
def label_value(pattern):
|
||||
m = re.search(pattern, page_text, re.IGNORECASE)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
result = self.driver.execute_cdp_cmd("Page.printToPDF", pdf_options)
|
||||
pdf_data = base64.b64decode(result.get('data', ''))
|
||||
pdf_id = foundMemberId or self.memberId or "unknown"
|
||||
# "Benefits for KAOUTER MOUSSAID" heading names the patient
|
||||
# ("Subscriber name:" is the policy holder, which may be a parent)
|
||||
patientName = label_value(r"(?m)^\s*Benefits\s+for\s+(.+?)\s*$")
|
||||
if not patientName:
|
||||
patientName = f"{self.firstName} {self.lastName}".strip()
|
||||
firstName = patientName.split()[0] if patientName else self.firstName
|
||||
|
||||
foundMemberId = label_value(r"Subscriber\s*#\s*:\s*([A-Za-z0-9\-]+)") or self.memberId or ""
|
||||
coverageIs = label_value(r"Coverage\s+is\s*:\s*([A-Za-z]+)")
|
||||
eligibleThrough = label_value(
|
||||
r"Eligible\s+through\s*:\s*([A-Za-z]{3,9}\.?\s+\d{1,2},\s*\d{4})"
|
||||
)
|
||||
|
||||
# Eligibility: active while "Eligible through" is today or later;
|
||||
# fall back to "Coverage is:" when the date can't be read.
|
||||
eligibilityText = "unknown"
|
||||
through_date = None
|
||||
for fmt in ("%b %d, %Y", "%B %d, %Y"):
|
||||
try:
|
||||
through_date = datetime.strptime(eligibleThrough.replace(".", ""), fmt).date()
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if through_date:
|
||||
eligibilityText = "active" if through_date >= date.today() else "inactive"
|
||||
elif coverageIs:
|
||||
c = coverageIs.lower()
|
||||
eligibilityText = "inactive" if ("inactive" in c or "termed" in c) else ("active" if "active" in c else c)
|
||||
|
||||
print(
|
||||
f"[Delta Indiv step2] Extracted — Name: '{patientName}', First: '{firstName}', "
|
||||
f"Subscriber #: '{foundMemberId}', Coverage is: '{coverageIs}', "
|
||||
f"Eligible through: '{eligibleThrough}' -> {eligibilityText}"
|
||||
)
|
||||
|
||||
pdf_id = foundMemberId or "unknown"
|
||||
pdf_path = os.path.join(self.download_dir, f"eligibility_{pdf_id}.pdf")
|
||||
with open(pdf_path, "wb") as f:
|
||||
f.write(pdf_data)
|
||||
|
||||
print(f"[Delta Indiv step2] PDF saved at: {pdf_path}")
|
||||
|
||||
print(f"[Delta Indiv step2] Final — PatientName: '{patientName}', MemberID: '{foundMemberId}'")
|
||||
self._save_print_pdf(pdf_path)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"eligibility": eligibilityText,
|
||||
"eligibleThrough": eligibleThrough,
|
||||
"coverageIs": coverageIs,
|
||||
"ss_path": pdf_path, # kept for backward compatibility
|
||||
"pdf_path": pdf_path, # explicit pdf_path
|
||||
"pdf_path": pdf_path,
|
||||
"patientName": patientName,
|
||||
"firstName": firstName,
|
||||
"memberId": foundMemberId,
|
||||
"dateOfBirth": self.dateOfBirth,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user