Fixed quote escaping problem in view.php.
[openemr.git] / interface / billing / sl_eob_process.php
blob02759ce5a7c96408fc2a6bbd3d8cd4c548e71b93
1 <?php
2 // Copyright (C) 2006-2007 Rod Roark <rod@sunsetsystems.com>
3 //
4 // This program is free software; you can redistribute it and/or
5 // modify it under the terms of the GNU General Public License
6 // as published by the Free Software Foundation; either version 2
7 // of the License, or (at your option) any later version.
9 // This processes X12 835 remittances and produces a report.
11 // Buffer all output so we can archive it to a file.
12 ob_start();
14 include_once("../globals.php");
15 include_once("../../library/invoice_summary.inc.php");
16 include_once("../../library/sl_eob.inc.php");
17 include_once("../../library/parse_era.inc.php");
18 include_once("claim_status_codes.php");
19 include_once("adjustment_reason_codes.php");
20 include_once("remark_codes.php");
22 $debug = $_GET['debug'] ? 1 : 0; // set to 1 for debugging mode
23 $paydate = parse_date($_GET['paydate']);
24 $encount = 0;
26 $last_ptname = '';
27 $last_invnumber = '';
28 $last_code = '';
29 $invoice_total = 0.00;
31 $INTEGRATED_AR = $GLOBALS['oer_config']['ws_accounting']['enabled'] === 2;
33 ///////////////////////// Assorted Functions /////////////////////////
35 function parse_date($date) {
36 $date = substr(trim($date), 0, 10);
37 if (preg_match('/^(\d\d\d\d)\D*(\d\d)\D*(\d\d)$/', $date, $matches)) {
38 return $matches[1] . '-' . $matches[2] . '-' . $matches[3];
40 return '';
43 function writeMessageLine($bgcolor, $class, $description) {
44 $dline =
45 " <tr bgcolor='$bgcolor'>\n" .
46 " <td class='$class' colspan='4'>&nbsp;</td>\n" .
47 " <td class='$class'>$description</td>\n" .
48 " <td class='$class' colspan='2'>&nbsp;</td>\n" .
49 " </tr>\n";
50 echo $dline;
53 function writeDetailLine($bgcolor, $class, $ptname, $invnumber,
54 $code, $date, $description, $amount, $balance)
56 global $last_ptname, $last_invnumber, $last_code;
57 if ($ptname == $last_ptname) $ptname = '&nbsp;';
58 else $last_ptname = $ptname;
59 if ($invnumber == $last_invnumber) $invnumber = '&nbsp;';
60 else $last_invnumber = $invnumber;
61 if ($code == $last_code) $code = '&nbsp;';
62 else $last_code = $code;
63 if ($amount ) $amount = sprintf("%.2f", $amount );
64 if ($balance) $balance = sprintf("%.2f", $balance);
65 $dline =
66 " <tr bgcolor='$bgcolor'>\n" .
67 " <td class='$class'>$ptname</td>\n" .
68 " <td class='$class'>$invnumber</td>\n" .
69 " <td class='$class'>$code</td>\n" .
70 " <td class='$class'>$date</td>\n" .
71 " <td class='$class'>$description</td>\n" .
72 " <td class='$class' align='right'>$amount</td>\n" .
73 " <td class='$class' align='right'>$balance</td>\n" .
74 " </tr>\n";
75 echo $dline;
78 // This writes detail lines that were already in SQL-Ledger for a given
79 // charge item.
81 function writeOldDetail(&$prev, $ptname, $invnumber, $dos, $code, $bgcolor) {
82 global $invoice_total;
83 // $prev['total'] = 0.00; // to accumulate total charges
84 ksort($prev['dtl']);
85 foreach ($prev['dtl'] as $dkey => $ddata) {
86 $ddate = substr($dkey, 0, 10);
87 $description = $ddata['src'] . $ddata['rsn'];
88 if ($ddate == ' ') { // this is the service item
89 $ddate = $dos;
90 $description = 'Service Item';
92 $amount = sprintf("%.2f", $ddata['chg'] - $ddata['pmt']);
93 $invoice_total = sprintf("%.2f", $invoice_total + $amount);
94 writeDetailLine($bgcolor, 'olddetail', $ptname, $invnumber,
95 $code, $ddate, $description, $amount, $invoice_total);
99 // This is called back by parse_era() once per claim.
101 function era_callback(&$out) {
102 global $encount, $debug, $claim_status_codes, $adjustment_reasons, $remark_codes;
103 global $invoice_total, $last_code, $paydate, $INTEGRATED_AR;
105 // Some heading information.
106 if ($encount == 0) {
107 writeMessageLine('#ffffff', 'infdetail',
108 "Payer: " . htmlentities($out['payer_name']));
109 if ($debug) {
110 writeMessageLine('#ffffff', 'infdetail',
111 "WITHOUT UPDATE is selected; no changes will be applied.");
115 $last_code = '';
116 $invoice_total = 0.00;
117 $bgcolor = (++$encount & 1) ? "#ddddff" : "#ffdddd";
118 list($pid, $encounter, $invnumber) = slInvoiceNumber($out);
120 // Get details, if we have them, for the invoice.
121 $inverror = true;
122 $codes = array();
123 if ($pid && $encounter) {
124 // Get invoice data into $arrow or $ferow.
125 if ($INTEGRATED_AR) {
126 $ferow = sqlQuery("SELECT e.*, p.fname, p.mname, p.lname " .
127 "FROM form_encounter AS e, patient_data AS p WHERE " .
128 "e.pid = '$pid' AND e.encounter = '$encounter' AND ".
129 "p.pid = e.pid");
130 if (empty($ferow)) {
131 $pid = $encounter = 0;
132 $invnumber = $out['our_claim_id'];
133 } else {
134 $inverror = false;
135 $codes = ar_get_invoice_summary($pid, $encounter, true);
136 // $svcdate = substr($ferow['date'], 0, 10);
138 } // end internal a/r
139 else {
140 $arres = SLQuery("SELECT ar.id, ar.notes, ar.shipvia, customer.name " .
141 "FROM ar, customer WHERE ar.invnumber = '$invnumber' AND " .
142 "customer.id = ar.customer_id");
143 if ($sl_err) die($sl_err);
144 $arrow = SLGetRow($arres, 0);
145 if ($arrow) {
146 $inverror = false;
147 $codes = get_invoice_summary($arrow['id'], true);
148 } else { // oops, no such invoice
149 $pid = $encounter = 0;
150 $invnumber = $out['our_claim_id'];
152 } // end not internal a/r
155 $insurance_id = 0;
156 foreach ($codes as $cdata) {
157 if ($cdata['ins']) {
158 $insurance_id = $cdata['ins'];
159 break;
163 // Show the claim status.
164 $csc = $out['claim_status_code'];
165 $inslabel = 'Ins1';
166 if ($csc == '1' || $csc == '19') $inslabel = 'Ins1';
167 if ($csc == '2' || $csc == '20') $inslabel = 'Ins2';
168 if ($csc == '3' || $csc == '21') $inslabel = 'Ins3';
169 $primary = ($inslabel == 'Ins1');
170 writeMessageLine($bgcolor, 'infdetail',
171 "Claim status $csc: " . $claim_status_codes[$csc]);
173 // Show an error message if the claim is missing or already posted.
174 if ($inverror) {
175 writeMessageLine($bgcolor, 'errdetail',
176 "The following claim is not in our database");
178 else {
179 // Skip this test. Claims can get multiple CLPs from the same payer!
181 // $insdone = strtolower($arrow['shipvia']);
182 // if (strpos($insdone, 'ins1') !== false) {
183 // $inverror = true;
184 // writeMessageLine($bgcolor, 'errdetail',
185 // "Primary insurance EOB was already posted for the following claim");
186 // }
189 if ($csc == '4') {
190 $inverror = true;
191 writeMessageLine($bgcolor, 'errdetail',
192 "Not posting adjustments for denied claims, please follow up manually!");
194 else if ($csc == '22') {
195 $inverror = true;
196 writeMessageLine($bgcolor, 'errdetail',
197 "Payment reversals are not automated, please enter manually!");
200 if ($out['warnings']) {
201 writeMessageLine($bgcolor, 'infdetail', nl2br(rtrim($out['warnings'])));
204 // Simplify some claim attributes for cleaner code.
205 $service_date = parse_date($out['dos']);
206 $check_date = $paydate ? $paydate : parse_date($out['check_date']);
207 $production_date = $paydate ? $paydate : parse_date($out['production_date']);
209 if ($INTEGRATED_AR) {
210 if (empty($ferow['lname'])) {
211 $patient_name = $out['patient_fname'] . ' ' . $out['patient_lname'];
212 } else {
213 $patient_name = $ferow['fname'] . ' ' . $ferow['lname'];
215 } else {
216 $patient_name = $arrow['name'] ? $arrow['name'] :
217 ($out['patient_fname'] . ' ' . $out['patient_lname']);
220 $error = $inverror;
222 // This loops once for each service item in this claim.
223 foreach ($out['svc'] as $svc) {
225 // Treat a modifier in the remit data as part of the procedure key.
226 // This key will then make its way into SQL-Ledger.
227 $codekey = $svc['code'];
228 if ($svc['mod']) $codekey .= ':' . $svc['mod'];
229 $prev = $codes[$codekey];
231 // This reports detail lines already on file for this service item.
232 if ($prev) {
233 writeOldDetail($prev, $patient_name, $invnumber, $service_date, $codekey, $bgcolor);
234 // Check for sanity in amount charged.
235 $prevchg = sprintf("%.2f", $prev['chg'] + $prev['adj']);
236 if ($prevchg != abs($svc['chg'])) {
237 writeMessageLine($bgcolor, 'errdetail',
238 "EOB charge amount " . $svc['chg'] . " for this code does not match our invoice");
239 $error = true;
242 // Check for already-existing primary remittance activity.
243 // Removed this check because it was not allowing for copays manually
244 // entered into the invoice under a non-copay billing code.
245 /****
246 if ((sprintf("%.2f",$prev['chg']) != sprintf("%.2f",$prev['bal']) ||
247 $prev['adj'] != 0) && $primary)
249 writeMessageLine($bgcolor, 'errdetail',
250 "This service item already has primary payments and/or adjustments!");
251 $error = true;
253 ****/
255 unset($codes[$codekey]);
258 // If the service item is not in our database...
259 else {
261 // This is not an error. If we are not in error mode and not debugging,
262 // insert the service item into SL. Then display it (in green if it
263 // was inserted, or in red if we are in error mode).
264 $description = "CPT4:$codekey Added by $inslabel $production_date";
265 if (!$error && !$debug) {
266 if ($INTEGRATED_AR) {
267 arPostCharge($pid, $encounter, 0, $svc['chg'], 1, $service_date,
268 $codekey, $description, $debug);
269 } else {
270 slPostCharge($arrow['id'], $svc['chg'], 1, $service_date, $codekey,
271 $insurance_id, $description, $debug);
273 $invoice_total += $svc['chg'];
275 $class = $error ? 'errdetail' : 'newdetail';
276 writeDetailLine($bgcolor, $class, $patient_name, $invnumber,
277 $codekey, $production_date, $description,
278 $svc['chg'], ($error ? '' : $invoice_total));
282 $class = $error ? 'errdetail' : 'newdetail';
284 // Report Allowed Amount.
285 if ($svc['allowed']) {
286 // A problem here is that some payers will include an adjustment
287 // reflecting the allowed amount, others not. So here we need to
288 // check if the adjustment exists, and if not then create it. We
289 // assume that any nonzero CO (Contractual Obligation) or PI
290 // (Payer Initiated) adjustment is good enough.
291 $contract_adj = sprintf("%.2f", $svc['chg'] - $svc['allowed']);
292 foreach ($svc['adj'] as $adj) {
293 if (($adj['group_code'] == 'CO' || $adj['group_code'] == 'PI') && $adj['amount'] != 0)
294 $contract_adj = 0;
296 if ($contract_adj > 0) {
297 $svc['adj'][] = array('group_code' => 'CO', 'reason_code' => 'A2',
298 'amount' => $contract_adj);
300 writeMessageLine($bgcolor, 'infdetail',
301 'Allowed amount is ' . sprintf("%.2f", $svc['allowed']));
304 // Report miscellaneous remarks.
305 if ($svc['remark']) {
306 $rmk = $svc['remark'];
307 writeMessageLine($bgcolor, 'infdetail', "$rmk: " . $remark_codes[$rmk]);
310 // Post and report the payment for this service item from the ERA.
311 // By the way a 'Claim' level payment is probably going to be negative,
312 // i.e. a payment reversal.
313 if ($svc['paid']) {
314 if (!$error && !$debug) {
315 if ($INTEGRATED_AR) {
316 arPostPayment($pid, $encounter, 0, $svc['paid'], $codekey,
317 substr($inslabel,3), $out['check_number'], $debug);
318 } else {
319 slPostPayment($arrow['id'], $svc['paid'], $check_date,
320 "$inslabel/" . $out['check_number'], $codekey, $insurance_id, $debug);
322 $invoice_total -= $svc['paid'];
324 $description = "$inslabel/" . $out['check_number'] . ' payment';
325 if ($svc['paid'] < 0) $description .= ' reversal';
326 writeDetailLine($bgcolor, $class, $patient_name, $invnumber,
327 $codekey, $check_date, $description,
328 0 - $svc['paid'], ($error ? '' : $invoice_total));
331 // Post and report adjustments from this ERA. Posted adjustment reasons
332 // must be 25 characters or less in order to fit on patient statements.
333 foreach ($svc['adj'] as $adj) {
334 $description = $adj['reason_code'] . ': ' . $adjustment_reasons[$adj['reason_code']];
335 if ($adj['group_code'] == 'PR' || !$primary) {
336 // Group code PR is Patient Responsibility. Enter these as zero
337 // adjustments to retain the note without crediting the claim.
338 if ($primary) {
339 /****
340 $reason = 'Pt resp: '; // Reasons should be 25 chars or less.
341 if ($adj['reason_code'] == '1') $reason = 'To deductible: ';
342 else if ($adj['reason_code'] == '2') $reason = 'Coinsurance: ';
343 else if ($adj['reason_code'] == '3') $reason = 'Co-pay: ';
344 ****/
345 $reason = "$inslabel ptresp: "; // Reasons should be 25 chars or less.
346 if ($adj['reason_code'] == '1') $reason = "$inslabel dedbl: ";
347 else if ($adj['reason_code'] == '2') $reason = "$inslabel coins: ";
348 else if ($adj['reason_code'] == '3') $reason = "$inslabel copay: ";
350 // Non-primary insurance adjustments are garbage, either repeating
351 // the primary or are not adjustments at all. Report them as notes
352 // but do not post any amounts.
353 else {
354 $reason = "$inslabel note " . $adj['reason_code'] . ': ';
355 /****
356 $reason .= sprintf("%.2f", $adj['amount']);
357 ****/
359 $reason .= sprintf("%.2f", $adj['amount']);
360 // Post a zero-dollar adjustment just to save it as a comment.
361 if (!$error && !$debug) {
362 if ($INTEGRATED_AR) {
363 arPostAdjustment($pid, $encounter, 0, 0, $codekey,
364 substr($inslabel,3), $reason, $debug);
365 } else {
366 slPostAdjustment($arrow['id'], 0, $production_date,
367 $out['check_number'], $codekey, $insurance_id,
368 $reason, $debug);
371 writeMessageLine($bgcolor, $class, $description . ' ' .
372 sprintf("%.2f", $adj['amount']));
374 // Other group codes for primary insurance are real adjustments.
375 else {
376 if (!$error && !$debug) {
377 if ($INTEGRATED_AR) {
378 arPostAdjustment($pid, $encounter, 0, $adj['amount'], $codekey,
379 substr($inslabel,3),
380 "Adjust code " . $adj['reason_code'], $debug);
381 } else {
382 slPostAdjustment($arrow['id'], $adj['amount'], $production_date,
383 $out['check_number'], $codekey, $insurance_id,
384 "$inslabel adjust code " . $adj['reason_code'], $debug);
386 $invoice_total -= $adj['amount'];
388 writeDetailLine($bgcolor, $class, $patient_name, $invnumber,
389 $codekey, $production_date, $description,
390 0 - $adj['amount'], ($error ? '' : $invoice_total));
394 } // End of service item
396 // Report any existing service items not mentioned in the ERA, and
397 // determine if any of them are still missing an insurance response
398 // (if so, then insurance is not yet done with the claim).
399 $insurance_done = true;
400 foreach ($codes as $code => $prev) {
401 // writeOldDetail($prev, $arrow['name'], $invnumber, $service_date, $code, $bgcolor);
402 writeOldDetail($prev, $patient_name, $invnumber, $service_date, $code, $bgcolor);
403 $got_response = false;
404 foreach ($prev['dtl'] as $ddata) {
405 if ($ddata['pmt'] || $ddata['rsn']) $got_response = true;
407 if (!$got_response) $insurance_done = false;
410 // Cleanup: If all is well, mark Ins<x> done and check for secondary billing.
411 if (!$error && !$debug && $insurance_done) {
412 if ($INTEGRATED_AR) {
413 $level_done = 0 + substr($inslabel, 3);
414 sqlStatement("UPDATE form_encounter " .
415 "SET last_level_closed = $level_done WHERE " .
416 "pid = '$pid' AND encounter = '$encounter'");
417 // Check for secondary insurance.
418 if ($primary && arGetPayerID($pid, $service_date, 2)) {
419 arSetupSecondary($pid, $encounter, $debug);
420 writeMessageLine($bgcolor, 'infdetail',
421 'This claim is now re-queued for secondary paper billing');
423 } else {
424 $shipvia = 'Done: Ins1';
425 if ($inslabel != 'Ins1') $shipvia .= ',Ins2';
426 if ($inslabel == 'Ins3') $shipvia .= ',Ins3';
427 $query = "UPDATE ar SET shipvia = '$shipvia' WHERE id = " . $arrow['id'];
428 SLQuery($query);
429 if ($sl_err) die($sl_err);
430 // Check for secondary insurance.
431 $insgot = strtolower($arrow['notes']);
432 if ($primary && strpos($insgot, 'ins2') !== false) {
433 slSetupSecondary($arrow['id'], $debug);
434 writeMessageLine($bgcolor, 'infdetail',
435 'This claim is now re-queued for secondary paper billing');
442 /////////////////////////// End Functions ////////////////////////////
444 $info_msg = "";
446 $eraname = $_GET['eraname'];
447 if (! $eraname) die(xl("You cannot access this page directly."));
449 // Open the output file early so that in case it fails, we do not post a
450 // bunch of stuff without saving the report. Also be sure to retain any old
451 // report files. Do not save the report if this is a no-update situation.
453 if (!$debug) {
454 $nameprefix = "$webserver_root/era/$eraname";
455 $namesuffix = '';
456 for ($i = 1; is_file("$nameprefix$namesuffix.html"); ++$i) {
457 $namesuffix = "_$i";
459 $fnreport = "$nameprefix$namesuffix.html";
460 $fhreport = fopen($fnreport, 'w');
461 if (!$fhreport) die(xl("Cannot create") . " '$fnreport'");
464 if (!$INTEGRATED_AR) slInitialize();
466 <html>
467 <head>
468 <? html_header_show();?>
469 <link rel=stylesheet href="<?php echo $css_header;?>" type="text/css">
470 <style type="text/css">
471 body { font-family:sans-serif; font-size:8pt; font-weight:normal }
472 .dehead { color:#000000; font-family:sans-serif; font-size:9pt; font-weight:bold }
473 .olddetail { color:#000000; font-family:sans-serif; font-size:9pt; font-weight:normal }
474 .newdetail { color:#00dd00; font-family:sans-serif; font-size:9pt; font-weight:normal }
475 .errdetail { color:#dd0000; font-family:sans-serif; font-size:9pt; font-weight:normal }
476 .infdetail { color:#0000ff; font-family:sans-serif; font-size:9pt; font-weight:normal }
477 </style>
478 <title><?php xl('EOB Posting - Electronic Remittances','e')?></title>
479 <script language="JavaScript">
480 </script>
481 </head>
482 <body leftmargin='0' topmargin='0' marginwidth='0' marginheight='0'>
483 <center>
485 <table border='0' cellpadding='2' cellspacing='0' width='100%'>
487 <tr bgcolor="#cccccc">
488 <td class="dehead">
489 <?php xl('Patient','e') ?>
490 </td>
491 <td class="dehead">
492 <?php xl('Invoice','e') ?>
493 </td>
494 <td class="dehead">
495 <?php xl('Code','e') ?>
496 </td>
497 <td class="dehead">
498 <?php xl('Date','e') ?>
499 </td>
500 <td class="dehead">
501 <?php xl('Description','e') ?>
502 </td>
503 <td class="dehead" align="right">
504 <?php xl('Amount','e') ?>&nbsp;
505 </td>
506 <td class="dehead" align="right">
507 <?php xl('Balance','e') ?>&nbsp;
508 </td>
509 </tr>
511 <?php
512 $alertmsg = parse_era("$webserver_root/era/$eraname.edi", 'era_callback');
514 if (!$INTEGRATED_AR) slTerminate();
516 </table>
517 </center>
518 <script language="JavaScript">
519 <?php
520 if ($alertmsg) echo " alert('" . htmlentities($alertmsg) . "');\n";
522 </script>
523 </body>
524 </html>
525 <?php
526 // Save all of this script's output to a report file.
527 if (!$debug) {
528 fwrite($fhreport, ob_get_contents());
529 fclose($fhreport);
531 ob_end_flush();