import Decimal from "decimal.js"; import rawCodeTable from "@/assets/data/procedureCodesMH.json"; import rawCCACodeTable from "@/assets/data/procedureCodesCCA.json"; import rawDDMACodeTable from "@/assets/data/procedureCodesDDMA.json"; import rawUnitedDHCodeTable from "@/assets/data/procedureCodesUnitedDH.json"; import rawTuftsSCOCodeTable from "@/assets/data/procedureCodesTuftsSCO.json"; import { PROCEDURE_COMBOS } from "./procedureCombos"; const CODE_TABLE = rawCodeTable; const CCA_CODE_TABLE = rawCCACodeTable; const DDMA_CODE_TABLE = rawDDMACodeTable; const UNITEDDH_CODE_TABLE = rawUnitedDHCodeTable; const TUFTSSCO_CODE_TABLE = rawTuftsSCOCodeTable; /* ----------------------------- Helpers ----------------------------- */ export const COMBO_BUTTONS = Object.values(PROCEDURE_COMBOS).map((c) => ({ id: c.id, label: c.label, })); // Build a fast lookup map keyed by normalized code const normalizeCode = (code) => code.replace(/\s+/g, "").toUpperCase(); const CODE_MAP = (() => { const m = new Map(); for (const r of CODE_TABLE) { const k = normalizeCode(String(r["Procedure Code"] || "")); if (k && !m.has(k)) m.set(k, r); } return m; })(); const CCA_CODE_MAP = (() => { const m = new Map(); for (const r of CCA_CODE_TABLE) { const k = normalizeCode(String(r["Procedure Code"] || "")); if (k && !m.has(k)) m.set(k, r); } return m; })(); const DDMA_CODE_MAP = (() => { const m = new Map(); for (const r of DDMA_CODE_TABLE) { const k = normalizeCode(String(r["Procedure Code"] || "")); if (k && !m.has(k)) m.set(k, r); } return m; })(); const UNITEDDH_CODE_MAP = (() => { const m = new Map(); for (const r of UNITEDDH_CODE_TABLE) { const k = normalizeCode(String(r["Procedure Code"] || "")); if (k && !m.has(k)) m.set(k, r); } return m; })(); const TUFTSSCO_CODE_MAP = (() => { const m = new Map(); for (const r of TUFTSSCO_CODE_TABLE) { const k = normalizeCode(String(r["Procedure Code"] || "")); if (k && !m.has(k)) m.set(k, r); } return m; })(); /** Return the correct fee-schedule map for the given insurance type. */ function getCodeMap(insuranceSiteKey) { const k = (insuranceSiteKey ?? "").replace(/_/g, "").toLowerCase(); if (k === "cca") return CCA_CODE_MAP; if (k === "ddma") return DDMA_CODE_MAP; if (k === "unitedsco" || k === "uniteddh" || k === "dentalhub") return UNITEDDH_CODE_MAP; if (k === "tuftssco" || k === "tufts") return TUFTSSCO_CODE_MAP; return CODE_MAP; // default: MassHealth } // this function is solely for abbrevations feature in claim-form export function getDescriptionForCode(code) { if (!code) return undefined; const row = CODE_MAP.get(normalizeCode(code)); return row?.Description; } const isBlankPrice = (v) => { if (v == null) return true; const s = String(v).trim().toUpperCase(); return s === "" || s === "IC" || s === "NC"; }; const toDecimalOrZero = (v) => { if (isBlankPrice(v)) return new Decimal(0); const n = typeof v === "string" ? parseFloat(v) : v; return new Decimal(Number.isFinite(n) ? n : 0); }; const parseDate = (d) => { if (d instanceof Date) return new Date(d.getFullYear(), d.getMonth(), d.getDate()); const s = String(d).trim(); // MM/DD/YYYY const mdy = /^(\d{2})\/(\d{2})\/(\d{4})$/; const m1 = mdy.exec(s); if (m1) { const mm = Number(m1[1]); const dd = Number(m1[2]); const yyyy = Number(m1[3]); return new Date(yyyy, mm - 1, dd); } // YYYY-MM-DD const ymd = /^(\d{4})-(\d{2})-(\d{2})$/; const m2 = ymd.exec(s); if (m2) { const yyyy = Number(m2[1]); const mm = Number(m2[2]); const dd = Number(m2[3]); return new Date(yyyy, mm - 1, dd); } // Fallback return new Date(s); }; const ageOnDate = (dob, on) => { const birth = parseDate(dob); const ref = parseDate(on); let age = ref.getFullYear() - birth.getFullYear(); const hadBirthday = ref.getMonth() > birth.getMonth() || (ref.getMonth() === birth.getMonth() && ref.getDate() >= birth.getDate()); if (!hadBirthday) age -= 1; return age; }; /** * we can implement per-code age buckets without changing the JSON. * * Behavior: * - Default: same as before: age <= 21 -> PriceLTEQ21, else PriceGT21 * - Fallback to Price if tiered field is blank/IC/NC * - Special-cases D1110 and D1120 according to MH rules */ export function pickPriceForRowByAge(row, age, normalizedCode) { // Special-case rules (add more codes here if needed) if (normalizedCode) { // D1110: only valid for age >=14 if (normalizedCode === "D1110") { if (age < 14) return new Decimal(0); // D1110 not for children <14 // age >= 14: use age-split if present, then flat Price if (age <= 21 && !isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21); if (age > 21 && !isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21); if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price); return new Decimal(0); } // D1120: valid for child 0-13 only if (normalizedCode === "D1120") { if (age >= 14) return new Decimal(0); // NC for adults if (!isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21); if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price); return new Decimal(0); } } // Generic/default: age-split first, flat Price as fallback if (age <= 21 && !isBlankPrice(row.PriceLTEQ21)) return toDecimalOrZero(row.PriceLTEQ21); if (age > 21 && !isBlankPrice(row.PriceGT21)) return toDecimalOrZero(row.PriceGT21); if (!isBlankPrice(row.Price)) return toDecimalOrZero(row.Price); return new Decimal(0); } /** * Gets price for a code using age & code table. */ function getPriceForCodeWithAgeFromMap(map, code, age) { const norm = normalizeCode(code); const row = map.get(norm); return row ? pickPriceForRowByAge(row, age, norm) : new Decimal(0); } // helper keeping lines empty, export const makeEmptyLine = (lineDate) => ({ procedureCode: "", procedureDate: lineDate, quad: "", arch: "", toothNumber: "", toothSurface: "", totalBilled: new Decimal(0), totalAdjusted: new Decimal(0), totalPaid: new Decimal(0), }); // Ensure the array has at least `min` lines; append blank ones if needed. const ensureCapacity = (lines, min, lineDate) => { while (lines.length < min) { lines.push(makeEmptyLine(lineDate)); } }; /* ------------------------- Main entry points ------------------------- */ /** * Map prices for ALL existing lines in a form (your "Map Price" button), * using patient's DOB and the form's serviceDate (or per-line procedureDate). * Returns a NEW form object (immutable). */ export function mapPricesForForm(params) { const { form, patientDOB, insuranceSiteKey } = params; const map = getCodeMap(insuranceSiteKey); return { ...form, serviceLines: form.serviceLines.map((ln) => { const age = ageOnDate(patientDOB, form.serviceDate); const code = normalizeCode(ln.procedureCode || ""); if (!code) return { ...ln }; const price = getPriceForCodeWithAgeFromMap(map, code, age); return { ...ln, procedureCode: code, totalBilled: price }; }), }; } /** * Apply a preset combo (fills codes & prices) using patientDOB and serviceDate. * Returns a NEW form object (immutable). */ export function applyComboToForm(form, comboId, patientDOB, options = {}, insuranceSiteKey) { const preset = PROCEDURE_COMBOS[String(comboId)]; if (!preset) return form; const { append = true, startIndex, lineDate = form.serviceDate, clearTrailing = false, replaceAll = false, // NEW } = options; const next = { ...form, serviceLines: [...form.serviceLines] }; // Replace-all: blank all existing and start from 0 if (replaceAll) { for (let i = 0; i < next.serviceLines.length; i++) { next.serviceLines[i] = makeEmptyLine(lineDate); } } // determine insertion index let insertAt = 0; if (!replaceAll) { if (append) { let last = -1; next.serviceLines.forEach((ln, i) => { if (ln.procedureCode?.trim()) last = i; }); insertAt = Math.max(0, last + 1); } else if (typeof startIndex === "number") { insertAt = Math.max(0, Math.min(startIndex, next.serviceLines.length - 1)); } } // if replaceAll, insertAt stays 0 // Make sure we have enough rows for the whole combo ensureCapacity(next.serviceLines, insertAt + preset.codes.length, lineDate); const age = options.skipPrice ? 0 : ageOnDate(patientDOB, lineDate); const map = options.skipPrice ? CODE_MAP : getCodeMap(insuranceSiteKey); for (let j = 0; j < preset.codes.length; j++) { const i = insertAt + j; if (i >= next.serviceLines.length) break; const codeRaw = preset.codes[j]; if (!codeRaw) continue; const code = normalizeCode(codeRaw); const price = options.skipPrice ? new Decimal(0) : getPriceForCodeWithAgeFromMap(map, code, age); const original = next.serviceLines[i]; next.serviceLines[i] = { ...original, procedureCode: code, procedureDate: lineDate, quad: original?.quad ?? "", arch: original?.arch ?? "", toothNumber: preset.toothNumbers?.[j] ?? original?.toothNumber ?? "", toothSurface: original?.toothSurface ?? "", totalBilled: price, totalAdjusted: new Decimal(0), totalPaid: new Decimal(0), }; } if (replaceAll || clearTrailing) { const after = insertAt + preset.codes.length; for (let i = after; i < next.serviceLines.length; i++) { next.serviceLines[i] = makeEmptyLine(lineDate); } } return next; } export { CODE_MAP, CCA_CODE_MAP, DDMA_CODE_MAP, UNITEDDH_CODE_MAP, TUFTSSCO_CODE_MAP, getCodeMap, getPriceForCodeWithAgeFromMap }; /** Compare each service line's totalBilled against the fee schedule. * Returns lines where the entered price differs from the schedule price. * Returns empty array if the siteKey has no schedule (United, Tufts, etc.). */ export function findPriceMismatches(serviceLines, insuranceSiteKey, patientDOB, serviceDate) { const supported = ["MH", "MASSHEALTH", "CCA", "DDMA", "UNITEDDH", "UNITEDSCO", "TUFTSSCO"]; if (!insuranceSiteKey || !supported.includes(insuranceSiteKey.toUpperCase())) return []; const map = getCodeMap(insuranceSiteKey); const mismatches = []; for (const line of serviceLines) { const code = normalizeCode(line.procedureCode || ""); if (!code) continue; const enteredPrice = new Decimal(Number(line.totalBilled) || 0); if (enteredPrice.isZero()) continue; const age = ageOnDate(patientDOB, serviceDate); const schedulePrice = getPriceForCodeWithAgeFromMap(map, code, age); if (!schedulePrice.isZero() && !enteredPrice.equals(schedulePrice)) { mismatches.push({ procedureCode: code, enteredPrice: enteredPrice.toNumber(), schedulePrice: schedulePrice.toNumber(), }); } } return mismatches; }