From 4bca85964967d6c041f9ce51f2e6a65e6f805f8c Mon Sep 17 00:00:00 2001 From: Mint Thompson Date: Thu, 22 Feb 2024 15:39:44 -0500 Subject: [PATCH] Add conditional checks for wide CSV format The same conditional checks for the tall CSV format also apply to the wide CSV format. Their implementations are different because the wide format has payer-specific columns. --- src/versions/2.0/csv.ts | 195 ++++++++++++++++---- test/2.0/csv.spec.ts | 386 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 542 insertions(+), 39 deletions(-) diff --git a/src/versions/2.0/csv.ts b/src/versions/2.0/csv.ts index 32431c9..05c29ed 100644 --- a/src/versions/2.0/csv.ts +++ b/src/versions/2.0/csv.ts @@ -91,7 +91,9 @@ const ERRORS = { }, REQUIRED: (column: string, suffix = ``) => `"${column}" is required${suffix}`, ONE_OF_REQUIRED: (columns: string[], suffix = "") => - `at least one of ${columns.join(", ")} is required${suffix}`, + `at least one of ${columns + .map((column) => `"${column}"`) + .join(", ")} is required${suffix}`, } /** @private */ @@ -351,8 +353,36 @@ export function validateRow( ) ) + // Some conditional checks have date-dependent enforcement. + const enforceConditionals = new Date().getFullYear() >= 2025 + // If code type is NDC, then the corresponding drug unit of measure and + // drug type of measure data elements must be encoded. Required beginning 1/1/2025. + const allCodeTypes = columns + .filter((column) => { + return /^code \| \d+ | type$/.test(column) + }) + .map((codeTypeColumn) => row[codeTypeColumn]) + if (allCodeTypes.some((codeType) => matchesString(codeType, "NDC"))) { + ;["drug_unit_of_measurement", "drug_type_of_measurement"].forEach( + (field) => { + errors.push( + ...validateRequiredField( + row, + field, + index, + columns.indexOf(field), + " when an NDC code is present" + ).map((csvErr) => { + csvErr.warning = !enforceConditionals + return csvErr + }) + ) + } + ) + } + if (wide) { - errors.push(...validateWideFields(row, index)) + errors.push(...validateWideFields(row, index, columns)) } else { errors.push(...validateTallFields(row, index, columns)) } @@ -370,9 +400,31 @@ function validateModifierRow( const errors: CsvValidationError[] = [] // If a modifier is encoded without an item or service, then a description and one of the following // is the minimum information required: - // additional_payer_notes, standard_charge | negotiated_dollar, standard_charge | negotiated_percentage, or standard_charge | negotiated_algorithm + // additional_generic_notes, additional_payer_notes, standard_charge | negotiated_dollar, standard_charge | negotiated_percentage, or standard_charge | negotiated_algorithm if (wide) { + const payersPlans = getPayersPlans(columns) + const payersPlansColumns: string[] = payersPlans + .flatMap((payerPlan) => [ + ["standard_charge", ...payerPlan, "negotiated_dollar"], + ["standard_charge", ...payerPlan, "negotiated_percentage"], + ["standard_charge", ...payerPlan, "negotiated_algorithm"], + ["additional_payer_notes", ...payerPlan], + ]) + .map((c) => c.join(" | ")) + const modifierRequiredFields = [ + "additional_generic_notes", + ...payersPlansColumns, + ] + errors.push( + ...validateOneOfRequiredField( + row, + modifierRequiredFields, + index, + columns.indexOf(modifierRequiredFields[0]), + " for wide format when a modifier is encoded without an item or service" + ) + ) } else { const modifierRequiredFields = [ "additional_generic_notes", @@ -445,7 +497,7 @@ function validateModifierRow( ) if (wide) { - // validateWideModifierFields + errors.push(...validateWideModifierFields(row, index, columns)) } else { errors.push(...validateTallModifierFields(row, index, columns)) } @@ -456,15 +508,16 @@ function validateModifierRow( /** @private */ export function validateWideFields( row: { [key: string]: string }, - index: number + index: number, + columns: string[] ): CsvValidationError[] { const errors: CsvValidationError[] = [] // TODO: Is checking that all are present covered in checking columns? // TODO: Is order maintained on entries? likely not - Object.entries(row).forEach(([field, value], columnIndex) => { + columns.forEach((field, columnIndex) => { if ( field.includes("contracting_method") && - !STANDARD_CHARGE_METHODOLOGY.includes(value as StandardChargeMethod) + !STANDARD_CHARGE_METHODOLOGY.includes(row[field] as StandardChargeMethod) ) { errors.push( csvErr( @@ -473,7 +526,7 @@ export function validateWideFields( field, ERRORS.ALLOWED_VALUES( field, - value, + row[field], STANDARD_CHARGE_METHODOLOGY as unknown as string[] ) ) @@ -481,7 +534,7 @@ export function validateWideFields( } else if (field.includes("standard_charge")) { if ( field.includes(" | percent") && - !value.trim() && + !row[field].trim() && !row[field.replace(" | percent", "")].trim() ) { errors.push( @@ -495,6 +548,100 @@ export function validateWideFields( } } }) + + // Some conditional checks have date-dependent enforcement. + const enforceConditionals = new Date().getFullYear() >= 2025 + + // If there is a "payer specific negotiated charge" encoded as a dollar amount, + // there must be a corresponding valid value encoded for the deidentified minimum and deidentified maximum negotiated charge data. + const dollarChargeColumns = columns.filter((column) => + column.endsWith("| negotiated_dollar") + ) + if (dollarChargeColumns.some((column) => row[column].trim().length > 0)) { + ;["standard_charge | min", "standard_charge | max"].forEach((field) => { + errors.push( + ...validateRequiredField( + row, + field, + index, + columns.indexOf(field), + " when a negotiated dollar amount is present" + ) + ) + }) + } + + // If a "payer specific negotiated charge" can only be expressed as a percentage or algorithm, + // then a corresponding "Estimated Allowed Amount" must also be encoded. Required beginning 1/1/2025. + const payersPlans = getPayersPlans(columns) + payersPlans.forEach(([payer, plan]) => { + if ( + ( + row[`standard_charge | ${payer} | ${plan} | negotiated_dollar`] || "" + ).trim().length === 0 && + (( + row[`standard_charge | ${payer} | ${plan} | negotiated_percentage`] || + "" + ).trim().length > 0 || + ( + row[`standard_charge | ${payer} | ${plan} | negotiated_algorithm`] || + "" + ).trim().length > 0) + ) { + errors.push( + ...validateRequiredFloatField( + row, + `estimated_amount | ${payer} | ${plan}`, + index, + columns.indexOf(`estimated_amount | ${payer} | ${plan}`), + " when a negotiated percentage or algorithm is present, but negotiated dollar is not present" + ).map((csvErr) => { + csvErr.warning = !enforceConditionals + return csvErr + }) + ) + } + }) + return errors +} + +/** @private */ +// checks the same fields as validateWideFields, but they are now optional +export function validateWideModifierFields( + row: { [key: string]: string }, + index: number, + columns: string[] +): CsvValidationError[] { + const errors: CsvValidationError[] = [] + + const payersPlans = getPayersPlans(columns) + const floatChargeFields = payersPlans + .flatMap((payerPlan) => [ + ["standard_charge", ...payerPlan, "negotiated_dollar"], + ["standard_charge", ...payerPlan, "negotiated_percentage"], + ]) + .map((c) => c.join(" | ")) + floatChargeFields.forEach((field) => { + errors.push( + ...validateOptionalFloatField(row, field, index, columns.indexOf(field)) + ) + }) + + const methodologyFields = payersPlans.map((payerPlan) => + ["standard_charge", ...payerPlan, "methodology"].join(" | ") + ) + methodologyFields.forEach((field) => { + errors.push( + ...validateOptionalEnumField( + row, + field, + index, + columns.indexOf(field), + STANDARD_CHARGE_METHODOLOGY + ) + ) + }) + return errors } @@ -592,9 +739,7 @@ export function validateTallFields( if ( (row["standard_charge | negotiated_dollar"] || "").trim().length === 0 && ((row["standard_charge | negotiated_percentage"] || "").trim().length > 0 || - (row["standard_charge | negotiated_algorithm"] || "").trim().length > - 0) && - (row["estimated_amount"] || "").trim().length === 0 + (row["standard_charge | negotiated_algorithm"] || "").trim().length > 0) ) { errors.push( ...validateRequiredFloatField( @@ -610,32 +755,6 @@ export function validateTallFields( ) } - // If code type is NDC, then the corresponding drug unit of measure and - // drug type of measure data elements must be encoded. Required beginning 1/1/2025. - const allCodeTypes = columns - .filter((column) => { - return /^code \| \d+ | type$/.test(column) - }) - .map((codeTypeColumn) => row[codeTypeColumn]) - if (allCodeTypes.some((codeType) => matchesString(codeType, "NDC"))) { - ;["drug_unit_of_measurement", "drug_type_of_measurement"].forEach( - (field) => { - errors.push( - ...validateRequiredField( - row, - field, - index, - columns.indexOf(field), - " when an NDC code is present" - ).map((csvErr) => { - csvErr.warning = !enforceConditionals - return csvErr - }) - ) - } - ) - } - return errors } diff --git a/test/2.0/csv.spec.ts b/test/2.0/csv.spec.ts index 2225036..0c04dc3 100644 --- a/test/2.0/csv.spec.ts +++ b/test/2.0/csv.spec.ts @@ -551,7 +551,7 @@ test("validateRow tall conditionals", (t) => { t.is(invalidModifierErrors.length, 1) t.assert( invalidModifierErrors[0].message.includes( - "at least one of additional_generic_notes, standard_charge | negotiated_dollar, standard_charge | negotiated_percentage, standard_charge | negotiated_algorithm is required for tall format when a modifier is encoded without an item or service" + 'at least one of "additional_generic_notes", "standard_charge | negotiated_dollar", "standard_charge | negotiated_percentage", "standard_charge | negotiated_algorithm" is required for tall format when a modifier is encoded without an item or service' ) ) const modifierWithNotesRow = { @@ -642,3 +642,387 @@ test("validateRow tall conditionals", (t) => { ) ) }) + +test("validateRow wide conditionals", (t) => { + const columns = [ + ...BASE_COLUMNS, + "code | 1", + "code | 1 | type", + "code | 2", + "code | 2 | type", + "standard_charge | Payer One | Basic Plan | negotiated_dollar", + "standard_charge | Payer One | Basic Plan | negotiated_percentage", + "standard_charge | Payer One | Basic Plan | negotiated_algorithm", + "estimated_amount | Payer One | Basic Plan", + "standard_charge | Payer One | Basic Plan | methodology", + "additional_payer_notes | Payer One | Basic Plan", + "standard_charge | Payer Two | Special Plan | negotiated_dollar", + "standard_charge | Payer Two | Special Plan | negotiated_percentage", + "standard_charge | Payer Two | Special Plan | negotiated_algorithm", + "estimated_amount | Payer Two | Special Plan", + "standard_charge | Payer Two | Special Plan | methodology", + "additional_payer_notes | Payer Two | Special Plan", + ] + const basicRow = { + description: "basic description", + setting: "inpatient", + "code | 1": "12345", + "code | 1 | type": "DRG", + "code | 2": "", + "code | 2 | type": "", + drug_unit_of_measurement: "", + drug_type_of_measurement: "", + modifiers: "", + "standard_charge | gross": "100", + "standard_charge | discounted_cash": "200.50", + "standard_charge | min": "50", + "standard_charge | max": "500", + additional_generic_notes: "", + "standard_charge | Payer One | Basic Plan | negotiated_dollar": "", + "standard_charge | Payer One | Basic Plan | negotiated_percentage": "", + "standard_charge | Payer One | Basic Plan | negotiated_algorithm": "", + "estimated_amount | Payer One | Basic Plan": "", + "standard_charge | Payer One | Basic Plan | methodology": "", + "additional_payer_notes | Payer One | Basic Plan": "", + "standard_charge | Payer Two | Special Plan | negotiated_dollar": "", + "standard_charge | Payer Two | Special Plan | negotiated_percentage": "", + "standard_charge | Payer Two | Special Plan | negotiated_algorithm": "", + "estimated_amount | Payer Two | Special Plan": "", + "standard_charge | Payer Two | Special Plan | methodology": "", + "additional_payer_notes | Payer Two | Special Plan": "", + } + // If there is a "payer specific negotiated charge" encoded as a dollar amount, + // there must be a corresponding valid value encoded for the deidentified minimum and deidentified maximum negotiated charge data. + const dollarNoBoundsRow = { + ...basicRow, + "standard_charge | Payer One | Basic Plan | negotiated_dollar": "300", + "standard_charge | min": "", + "standard_charge | max": "", + } + const dollarNoBoundsErrors = validateRow(dollarNoBoundsRow, 5, columns, true) + t.is(dollarNoBoundsErrors.length, 2) + t.assert( + dollarNoBoundsErrors[0].message.includes( + '"standard_charge | min" is required when a negotiated dollar amount is present' + ) + ) + t.assert( + dollarNoBoundsErrors[1].message.includes( + '"standard_charge | max" is required when a negotiated dollar amount is present' + ) + ) + const percentageNoBoundsRow = { + ...basicRow, + "standard_charge | Payer One | Basic Plan | negotiated_percentage": "80", + "standard_charge | min": "", + "standard_charge | max": "", + "estimated_amount | Payer One | Basic Plan": "160", + } + const percentageNoBoundsErrors = validateRow( + percentageNoBoundsRow, + 6, + columns, + true + ) + t.is(percentageNoBoundsErrors.length, 0) + const algorithmNoBoundsRow = { + ...basicRow, + "standard_charge | Payer One | Basic Plan | negotiated_algorithm": + "standard logarithm table", + "standard_charge | min": "", + "standard_charge | max": "", + "estimated_amount | Payer One | Basic Plan": "160", + } + const algorithmNoBoundsErrors = validateRow( + algorithmNoBoundsRow, + 7, + columns, + true + ) + t.is(algorithmNoBoundsErrors.length, 0) + + // If a "payer specific negotiated charge" can only be expressed as a percentage or algorithm, + // then a corresponding "Estimated Allowed Amount" must also be encoded. Required beginning 1/1/2025. + const enforceConditionals = new Date().getFullYear() >= 2025 + + const percentageWithEstimateRow = { + ...basicRow, + "standard_charge | Payer One | Basic Plan | negotiated_percentage": "80", + "estimated_amount | Payer One | Basic Plan": "160", + } + const percentageWithEstimateErrors = validateRow( + percentageWithEstimateRow, + 8, + columns, + true + ) + t.is(percentageWithEstimateErrors.length, 0) + const percentageNoEstimateRow = { + ...basicRow, + "standard_charge | Payer One | Basic Plan | negotiated_percentage": "80", + } + const percentageNoEstimateErrors = validateRow( + percentageNoEstimateRow, + 9, + columns, + true + ) + t.is(percentageNoEstimateErrors.length, 1) + t.assert( + percentageNoEstimateErrors[0].message.includes( + '"estimated_amount | Payer One | Basic Plan" is required to be a positive number when a negotiated percentage or algorithm is present, but negotiated dollar is not present' + ) + ) + t.is(percentageNoEstimateErrors[0].warning, !enforceConditionals) + const percentageWrongEstimateRow = { + ...basicRow, + "standard_charge | Payer One | Basic Plan | negotiated_percentage": "80", + "estimated_amount | Payer Two | Special Plan": "55", + } + const percentageWrongEstimateErrors = validateRow( + percentageWrongEstimateRow, + 10, + columns, + true + ) + t.is(percentageWrongEstimateErrors.length, 1) + t.assert( + percentageWrongEstimateErrors[0].message.includes( + '"estimated_amount | Payer One | Basic Plan" is required to be a positive number when a negotiated percentage or algorithm is present, but negotiated dollar is not present' + ) + ) + t.is(percentageWrongEstimateErrors[0].warning, !enforceConditionals) + const algorithmWithEstimateRow = { + ...basicRow, + "standard_charge | Payer Two | Special Plan | negotiated_algorithm": + "useful function", + "estimated_amount | Payer Two | Special Plan": "55", + } + const algorithmWithEstimateErrors = validateRow( + algorithmWithEstimateRow, + 11, + columns, + true + ) + t.is(algorithmWithEstimateErrors.length, 0) + const algorithmNoEstimateRow = { + ...basicRow, + "standard_charge | Payer Two | Special Plan | negotiated_algorithm": + "useful function", + } + const algorithmNoEstimateErrors = validateRow( + algorithmNoEstimateRow, + 12, + columns, + true + ) + t.is(algorithmNoEstimateErrors.length, 1) + t.assert( + algorithmNoEstimateErrors[0].message.includes( + '"estimated_amount | Payer Two | Special Plan" is required to be a positive number when a negotiated percentage or algorithm is present, but negotiated dollar is not present' + ) + ) + t.is(algorithmNoEstimateErrors[0].warning, !enforceConditionals) + const algorithmWrongEstimateRow = { + ...basicRow, + "standard_charge | Payer Two | Special Plan | negotiated_algorithm": + "useful function", + "estimated_amount | Payer One | Basic Plan": "55", + } + const algorithmWrongEstimateErrors = validateRow( + algorithmWrongEstimateRow, + 13, + columns, + true + ) + t.is(algorithmWrongEstimateErrors.length, 1) + t.assert( + algorithmWrongEstimateErrors[0].message.includes( + '"estimated_amount | Payer Two | Special Plan" is required to be a positive number when a negotiated percentage or algorithm is present, but negotiated dollar is not present' + ) + ) + t.is(algorithmWrongEstimateErrors[0].warning, !enforceConditionals) + + // If code type is NDC, then the corresponding drug unit of measure and + // drug type of measure data elements must be encoded. Required beginning 1/1/2025. + const ndcNoMeasurementRow = { + ...basicRow, + "code | 1 | type": "NDC", + "standard_charge | Payer One | Basic Plan | negotiated_dollar": "300", + drug_unit_of_measurement: "", + drug_type_of_measurement: "", + } + const ndcNoMeasurementErrors = validateRow( + ndcNoMeasurementRow, + 14, + columns, + true + ) + t.is(ndcNoMeasurementErrors.length, 2) + t.assert( + ndcNoMeasurementErrors[0].message.includes( + '"drug_unit_of_measurement" is required when an NDC code is present' + ) + ) + t.assert( + ndcNoMeasurementErrors[1].message.includes( + '"drug_type_of_measurement" is required when an NDC code is present' + ) + ) + t.is(ndcNoMeasurementErrors[0].warning, !enforceConditionals) + t.is(ndcNoMeasurementErrors[1].warning, !enforceConditionals) + const ndcSecondNoMeasurementRow = { + ...basicRow, + "code | 2": "12345", + "code | 2 | type": "NDC", + "standard_charge | Payer One | Basic Plan | negotiated_dollar": "300", + drug_unit_of_measurement: "", + drug_type_of_measurement: "", + } + const ndcSecondNoMeasurementErrors = validateRow( + ndcSecondNoMeasurementRow, + 15, + columns, + true + ) + t.is(ndcSecondNoMeasurementErrors.length, 2) + t.assert( + ndcSecondNoMeasurementErrors[0].message.includes( + '"drug_unit_of_measurement" is required when an NDC code is present' + ) + ) + t.assert( + ndcSecondNoMeasurementErrors[1].message.includes( + '"drug_type_of_measurement" is required when an NDC code is present' + ) + ) + t.is(ndcSecondNoMeasurementErrors[0].warning, !enforceConditionals) + t.is(ndcSecondNoMeasurementErrors[1].warning, !enforceConditionals) + + // If a modifier is encoded without an item or service, then a description and one of the following + // is the minimum information required: + // additional_generic_notes, additional_payer_notes, standard_charge | negotiated_dollar, + // standard_charge | negotiated_percentage, or standard_charge | negotiated_algorithm + const invalidModifierRow = { + ...basicRow, + "code | 1": "", + "code | 1 | type": "", + modifiers: "50", + } + const invalidModifierErrors = validateRow( + invalidModifierRow, + 16, + columns, + true + ) + t.is(invalidModifierErrors.length, 1) + t.assert( + invalidModifierErrors[0].message.includes( + 'at least one of "additional_generic_notes", "standard_charge | Payer One | Basic Plan | negotiated_dollar", "standard_charge | Payer One | Basic Plan | negotiated_percentage", "standard_charge | Payer One | Basic Plan | negotiated_algorithm", "additional_payer_notes | Payer One | Basic Plan", "standard_charge | Payer Two | Special Plan | negotiated_dollar", "standard_charge | Payer Two | Special Plan | negotiated_percentage", "standard_charge | Payer Two | Special Plan | negotiated_algorithm", "additional_payer_notes | Payer Two | Special Plan" is required for wide format when a modifier is encoded without an item or service' + ) + ) + const modifierWithGenericNotesRow = { + ...basicRow, + "code | 1": "", + "code | 1 | type": "", + modifiers: "50", + additional_generic_notes: "useful notes about the modifier", + } + const modifierWithGenericNotesErrors = validateRow( + modifierWithGenericNotesRow, + 17, + columns, + true + ) + t.is(modifierWithGenericNotesErrors.length, 0) + const modifierWithPayerNotesRow = { + ...basicRow, + "code | 1": "", + "code | 1 | type": "", + modifiers: "50", + "additional_payer_notes | Payer One | Basic Plan": + "useful notes for this payer", + } + const modifierWithPayerNotesErrors = validateRow( + modifierWithPayerNotesRow, + 18, + columns, + true + ) + t.is(modifierWithPayerNotesErrors.length, 0) + const modifierWithDollarRow = { + ...basicRow, + "code | 1": "", + "code | 1 | type": "", + modifiers: "50", + "standard_charge | Payer Two | Special Plan | negotiated_dollar": "151", + } + const modifierWithDollarErrors = validateRow( + modifierWithDollarRow, + 19, + columns, + true + ) + t.is(modifierWithDollarErrors.length, 0) + const modifierWithPercentageRow = { + ...basicRow, + "code | 1": "", + "code | 1 | type": "", + modifiers: "50", + "standard_charge | Payer One | Basic Plan | negotiated_percentage": "110", + } + const modifierWithPercentageErrors = validateRow( + modifierWithPercentageRow, + 20, + columns, + true + ) + t.is(modifierWithPercentageErrors.length, 0) + const modifierWithAlgorithmRow = { + ...basicRow, + "code | 1": "", + "code | 1 | type": "", + modifiers: "50", + "standard_charge | Payer Two | Special Plan | negotiated_algorithm": + "consult the table of numbers", + } + const modifierWithAlgorithmErrors = validateRow( + modifierWithAlgorithmRow, + 21, + columns, + true + ) + t.is(modifierWithAlgorithmErrors.length, 0) + // types are still enforced for a modifier row + const modifierWithWrongTypesRow = { + ...basicRow, + "code | 1": "", + "code | 1 | type": "", + modifiers: "50", + "standard_charge | Payer One | Basic Plan | negotiated_dollar": "$100", + "standard_charge | Payer One | Basic Plan | negotiated_percentage": "15%", + "standard_charge | Payer Two | Special Plan | methodology": "secret", + } + const modifierWithWrongTypesErrors = validateRow( + modifierWithWrongTypesRow, + 22, + columns, + true + ) + t.is(modifierWithWrongTypesErrors.length, 3) + t.assert( + modifierWithWrongTypesErrors[0].message.includes( + '"standard_charge | Payer One | Basic Plan | negotiated_dollar" value "$100" is not a valid positive number' + ) + ) + t.assert( + modifierWithWrongTypesErrors[1].message.includes( + '"standard_charge | Payer One | Basic Plan | negotiated_percentage" value "15%" is not a valid positive number' + ) + ) + t.assert( + modifierWithWrongTypesErrors[2].message.includes( + '"standard_charge | Payer Two | Special Plan | methodology" value "secret" is not one of the allowed values' + ) + ) +})