various corrections and improvements to remit processing
[openemr.git] / interface / billing / sl_eob_process.php
blob1267e5b25b30bc4459222d4c67a6d9a36806d153
1 <?php
2 // Copyright (C) 2006 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 // Caveats:
12 // Currently we assume that all 835's come from primary insurance, and
13 // that secondary claims always go out on paper. This should be made
14 // more general at some point. So far we have only tested with a
15 // family practice clinic using Zirmed.
17 // Buffer all output so we can archive it to a file.
18 ob_start();
20 include_once("../globals.php");
21 include_once("../../library/invoice_summary.inc.php");
22 include_once("../../library/sl_eob.inc.php");
23 include_once("../../library/parse_era.inc.php");
24 include_once("claim_status_codes.php");
25 include_once("adjustment_reason_codes.php");
26 include_once("remark_codes.php");
28 $debug = $_GET['debug'] ? 1 : 0; // set to 1 for debugging mode
29 $encount = 0;
31 $last_ptname = '';
32 $last_invnumber = '';
33 $last_code = '';
34 $invoice_total = 0.00;
36 ///////////////////////// Assorted Functions /////////////////////////
38 function parse_date($date) {
39 $date = substr(trim($date), 0, 10);
40 if (preg_match('/^(\d\d\d\d)(\d\d)(\d\d)$/', $date, $matches)) {
41 return $matches[1] . '-' . $matches[2] . '-' . $matches[3];
43 return '';
46 function writeMessageLine($bgcolor, $class, $description) {
47 $dline =
48 " <tr bgcolor='$bgcolor'>\n" .
49 " <td class='$class' colspan='4'>&nbsp;</td>\n" .
50 " <td class='$class'>$description</td>\n" .
51 " <td class='$class' colspan='2'>&nbsp;</td>\n" .
52 " </tr>\n";
53 echo $dline;
56 function writeDetailLine($bgcolor, $class, $ptname, $invnumber,
57 $code, $date, $description, $amount, $balance)
59 global $last_ptname, $last_invnumber, $last_code;
60 if ($ptname == $last_ptname) $ptname = '&nbsp;';
61 else $last_ptname = $ptname;
62 if ($invnumber == $last_invnumber) $invnumber = '&nbsp;';
63 else $last_invnumber = $invnumber;
64 if ($code == $last_code) $code = '&nbsp;';
65 else $last_code = $code;
66 if ($amount ) $amount = sprintf("%.2f", $amount );
67 if ($balance) $balance = sprintf("%.2f", $balance);
68 $dline =
69 " <tr bgcolor='$bgcolor'>\n" .
70 " <td class='$class'>$ptname</td>\n" .
71 " <td class='$class'>$invnumber</td>\n" .
72 " <td class='$class'>$code</td>\n" .
73 " <td class='$class'>$date</td>\n" .
74 " <td class='$class'>$description</td>\n" .
75 " <td class='$class' align='right'>$amount</td>\n" .
76 " <td class='$class' align='right'>$balance</td>\n" .
77 " </tr>\n";
78 echo $dline;
81 // This writes detail lines that were already in SQL-Ledger for a given
82 // charge item.
84 function writeOldDetail(&$prev, $ptname, $invnumber, $dos, $code, $bgcolor) {
85 global $invoice_total;
86 // $prev['total'] = 0.00; // to accumulate total charges
87 ksort($prev['dtl']);
88 foreach ($prev['dtl'] as $dkey => $ddata) {
89 $ddate = substr($dkey, 0, 10);
90 $description = $ddata['src'] . $ddata['rsn'];
91 if ($ddate == ' ') { // this is the service item
92 $ddate = $dos;
93 $description = 'Service Item';
95 $amount = sprintf("%.2f", $ddata['chg'] - $ddata['pmt']);
96 $invoice_total = sprintf("%.2f", $invoice_total + $amount);
97 writeDetailLine($bgcolor, 'olddetail', $ptname, $invnumber,
98 $code, $ddate, $description, $amount, $invoice_total);
102 // This is called back by parse_era() once per claim.
104 function era_callback(&$out) {
105 global $encount, $debug, $claim_status_codes, $adjustment_reasons, $remark_codes;
106 global $invoice_total, $last_code;
108 // Some heading information.
109 if ($encount == 0) {
110 writeMessageLine('#ffffff', 'infdetail',
111 "Payer: " . htmlentities($out['payer_name']));
112 if ($debug) {
113 writeMessageLine('#ffffff', 'infdetail',
114 "WITHOUT UPDATE is selected; no changes will be applied.");
118 $last_code = '';
119 $invoice_total = 0.00;
120 $bgcolor = (++$encount & 1) ? "#ddddff" : "#ffdddd";
121 list($pid, $encounter, $invnumber) = slInvoiceNumber($out);
123 // Get details, if we have them, for the invoice.
124 $inverror = true;
125 $codes = array();
126 if ($pid && $encounter) {
127 // Get invoice data into $arrow.
128 $arres = SLQuery("SELECT ar.id, ar.notes, ar.shipvia, customer.name " .
129 "FROM ar, customer WHERE ar.invnumber = '$invnumber' AND " .
130 "customer.id = ar.customer_id");
131 if ($sl_err) die($sl_err);
132 $arrow = SLGetRow($arres, 0);
133 if ($arrow) {
134 $inverror = false;
135 $codes = get_invoice_summary($arrow['id'], true);
136 } else { // oops, no such invoice
137 $pid = $encounter = 0;
138 $invnumber = $out['our_claim_id'];
142 $insurance_id = 0;
143 foreach ($codes as $cdata) {
144 if ($cdata['ins']) {
145 $insurance_id = $cdata['ins'];
146 break;
150 // Show the claim status.
151 $csc = $out['claim_status_code'];
152 $inslabel = 'Ins1';
153 if ($csc == '1' || $csc == '19') $inslabel = 'Ins1';
154 if ($csc == '2' || $csc == '20') $inslabel = 'Ins2';
155 if ($csc == '3' || $csc == '21') $inslabel = 'Ins3';
156 writeMessageLine($bgcolor, 'infdetail',
157 "Claim status $csc: " . $claim_status_codes[$csc]);
159 // Show an error message if the claim is missing or already posted.
160 if ($inverror) {
161 writeMessageLine($bgcolor, 'errdetail',
162 "The following claim is not in our database");
164 else {
165 // Skip this test. Claims can get multiple CLPs from the same payer!
167 // $insdone = strtolower($arrow['shipvia']);
168 // if (strpos($insdone, 'ins1') !== false) {
169 // $inverror = true;
170 // writeMessageLine($bgcolor, 'errdetail',
171 // "Primary insurance EOB was already posted for the following claim");
172 // }
175 if ($out['warnings']) {
176 writeMessageLine($bgcolor, 'infdetail', nl2br(rtrim($out['warnings'])));
179 // Simplify some claim attributes for cleaner code.
180 $service_date = parse_date($out['dos']);
181 $check_date = parse_date($out['check_date']);
182 $production_date = parse_date($out['production_date']);
183 $patient_name = $arrow['name'] ? $arrow['name'] :
184 ($out['patient_fname'] . ' ' . $out['patient_lname']);
186 $error = $inverror;
188 // This loops once for each service item in this claim.
189 foreach ($out['svc'] as $svc) {
190 $prev = $codes[$svc['code']];
192 // This reports detail lines already on file for this service item.
193 if ($prev) {
194 writeOldDetail($prev, $patient_name, $invnumber, $service_date, $svc['code'], $bgcolor);
195 // Check for sanity in amount charged.
196 $prevchg = sprintf("%.2f", $prev['chg'] + $prev['adj']);
197 if ($prevchg != $svc['chg']) {
198 writeMessageLine($bgcolor, 'errdetail',
199 "EOB charge amount " . $svc['chg'] . " for this code does not match our invoice");
200 $error = true;
203 // Check for duplicate payment. Should not happen.
205 // This is not right. What we want to do is check if we have
206 // any payments or adjustments from this payer for this service item,
207 // and produce an error if so. The point is that a duplicated claim
208 // submission may not give the same results as the original.
209 /****
210 foreach ($prev['dtl'] as $dkey => $ddata) {
211 if (! $ddata['pmt']) continue;
212 $ddate = parse_date($dkey);
213 if ($ddate == $check_date && $ddata['pmt'] == $svc['paid']) {
214 writeMessageLine($bgcolor, 'errdetail',
215 "This payment dated $check_date seems to be already posted!");
216 $error = true;
219 ****/
220 // The following replaces the above.
221 if ((sprintf("%.2f",$prev['chg']) != sprintf("%.2f",$prev['bal']) ||
222 $prev['adj'] != 0) && $inslabel == 'Ins1')
224 writeMessageLine($bgcolor, 'errdetail',
225 "This service item already has payments and/or adjustments!");
226 $error = true;
229 unset($codes[$svc['code']]);
232 // If the service item is not in our database...
233 else {
235 /****
236 writeDetailLine($bgcolor, 'errdetail', $patient_name, $invnumber,
237 $svc['code'], $service_date, '*** UNMATCHED SERVICE ITEM ***',
238 $svc['chg'], '');
239 $error = true;
240 ****/
242 // No, this is not an error. Instead, if we are not in error mode
243 // insert the service item into SL. Then display it (in green if it
244 // was inserted, or in red if we are in error mode).
245 $description = 'CPT4:' . $svc['code'] . " Added by $inslabel $production_date";
246 if (!$error && !$debug) {
247 slPostCharge($arrow['id'], $svc['chg'], $service_date, $svc['code'],
248 $insurance_id, $description, $debug);
249 $invoice_total += $svc['chg'];
251 $class = $error ? 'errdetail' : 'newdetail';
252 writeDetailLine($bgcolor, $class, $patient_name, $invnumber,
253 $svc['code'], $production_date, $description,
254 $svc['chg'], ($error ? '' : $invoice_total));
258 $class = $error ? 'errdetail' : 'newdetail';
260 // Report Allowed Amount.
261 if ($svc['allowed']) {
262 // A problem here is that some payers will include an adjustment
263 // reflecting the allowed amount, others not. So here we need to
264 // check if the adjustment exists, and if not then create it. We
265 // assume that any nonzero CO (Contractual Obligation) adjustment
266 // is good enough.
267 $contract_adj = sprintf("%.2f", $svc['chg'] - $svc['allowed']);
268 foreach ($svc['adj'] as $adj) {
269 if ($adj['group_code'] == 'CO' && $adj['amount'] != 0)
270 $contract_adj = 0;
272 if ($contract_adj > 0) {
273 $svc['adj'][] = array('group_code' => 'CO', 'reason_code' => 'A2',
274 'amount' => $contract_adj);
276 writeMessageLine($bgcolor, 'infdetail',
277 'Allowed amount is ' . sprintf("%.2f", $svc['allowed']));
280 // Report miscellaneous remarks.
281 if ($svc['remark']) {
282 $rmk = $svc['remark'];
283 writeMessageLine($bgcolor, 'infdetail', "$rmk: " . $remark_codes[$rmk]);
286 // Post and report the payment for this service item from the ERA.
287 if ($svc['paid']) {
288 if (!$error && !$debug) {
289 slPostPayment($arrow['id'], $svc['paid'], $check_date,
290 "$inslabel/" . $out['check_number'], $svc['code'], $insurance_id, $debug);
291 $invoice_total -= $svc['paid'];
293 writeDetailLine($bgcolor, $class, $patient_name, $invnumber,
294 $svc['code'], $check_date, "$inslabel/" . $out['check_number'] . ' payment',
295 0 - $svc['paid'], ($error ? '' : $invoice_total));
298 // Post and report adjustments from this ERA. Posted adjustment reasons
299 // must be 25 characters or less in order to fit on patient statements.
300 foreach ($svc['adj'] as $adj) {
301 $description = $adj['reason_code'] . ': ' . $adjustment_reasons[$adj['reason_code']];
302 // Group code PR is Patient Responsibility. Enter these as zero
303 // adjustments to retain the note without crediting the claim.
304 if ($adj['group_code'] == 'PR') {
305 $reason = 'Pt resp: '; // Reasons should be 25 chars or less.
306 if ($adj['reason_code'] == '1') $reason = 'To deductible: ';
307 else if ($adj['reason_code'] == '2') $reason = 'Coinsurance: ';
308 else if ($adj['reason_code'] == '3') $reason = 'Co-pay: ';
309 $reason .= sprintf("%.2f", $adj['amount']);
310 if (!$error && !$debug) {
311 slPostAdjustment($arrow['id'], 0, $production_date,
312 $out['check_number'], $svc['code'], $insurance_id,
313 $reason, $debug);
315 writeMessageLine($bgcolor, $class, $description . ' ' .
316 sprintf("%.2f", $adj['amount']));
318 // Other group codes are real adjustments.
319 else {
320 if (!$error && !$debug) {
321 slPostAdjustment($arrow['id'], $adj['amount'], $production_date,
322 $out['check_number'], $svc['code'], $insurance_id,
323 "$inslabel adjust code " . $adj['reason_code'], $debug);
324 $invoice_total -= $adj['amount'];
326 writeDetailLine($bgcolor, $class, $patient_name, $invnumber,
327 $svc['code'], $production_date, $description,
328 0 - $adj['amount'], ($error ? '' : $invoice_total));
332 } // End of service item
334 // Report any existing service items not mentioned in the ERA.
335 foreach ($codes as $code => $prev) {
336 writeOldDetail($prev, $arrow['name'], $invnumber, $service_date, $code, $bgcolor);
339 // Cleanup: If all is well, mark Ins1 done and check for secondary billing.
340 if (!$error && !$debug) {
341 $shipvia = 'Done: Ins1';
342 if ($inslabel != 'Ins1') $shipvia .= ',Ins2';
343 if ($inslabel == 'Ins3') $shipvia .= ',Ins3';
344 $query = "UPDATE ar SET shipvia = '$shipvia' WHERE id = " . $arrow['id'];
345 SLQuery($query);
346 if ($sl_err) die($sl_err);
347 // Check for secondary insurance.
348 $insgot = strtolower($arrow['notes']);
349 if ($inslabel == 'Ins1' && strpos($insgot, 'ins2') !== false) {
350 slSetupSecondary($arrow['id'], $debug);
351 writeMessageLine($bgcolor, 'infdetail',
352 'This claim is now re-queued for secondary paper billing');
358 /////////////////////////// End Functions ////////////////////////////
360 $info_msg = "";
362 $eraname = $_GET['eraname'];
363 if (! $eraname) die(xl("You cannot access this page directly."));
365 // Open the output file early so that in case it fails, we do not post a
366 // bunch of stuff without saving the report. Also be sure to retain any old
367 // report files. Do not save the report if this is a no-update situation.
369 if (!$debug) {
370 $nameprefix = "$webserver_root/era/$eraname";
371 $namesuffix = '';
372 for ($i = 1; is_file("$nameprefix$namesuffix.html"); ++$i) {
373 $namesuffix = "_$i";
375 $fnreport = "$nameprefix$namesuffix.html";
376 $fhreport = fopen($fnreport, 'w');
377 if (!$fhreport) die(xl("Cannot create") . " '$fnreport'");
380 slInitialize();
382 <html>
383 <head>
384 <link rel=stylesheet href="<?echo $css_header;?>" type="text/css">
385 <style type="text/css">
386 body { font-family:sans-serif; font-size:8pt; font-weight:normal }
387 .dehead { color:#000000; font-family:sans-serif; font-size:9pt; font-weight:bold }
388 .olddetail { color:#000000; font-family:sans-serif; font-size:9pt; font-weight:normal }
389 .newdetail { color:#00dd00; font-family:sans-serif; font-size:9pt; font-weight:normal }
390 .errdetail { color:#dd0000; font-family:sans-serif; font-size:9pt; font-weight:normal }
391 .infdetail { color:#0000ff; font-family:sans-serif; font-size:9pt; font-weight:normal }
392 </style>
393 <title><?xl('EOB Posting - Electronic Remittances','e')?></title>
394 <script language="JavaScript">
395 </script>
396 </head>
397 <body leftmargin='0' topmargin='0' marginwidth='0' marginheight='0'>
398 <center>
400 <table border='0' cellpadding='2' cellspacing='0' width='100%'>
402 <tr bgcolor="#cccccc">
403 <td class="dehead">
404 <?php xl('Patient','e') ?>
405 </td>
406 <td class="dehead">
407 <?php xl('Invoice','e') ?>
408 </td>
409 <td class="dehead">
410 <?php xl('Code','e') ?>
411 </td>
412 <td class="dehead">
413 <?php xl('Date','e') ?>
414 </td>
415 <td class="dehead">
416 <?php xl('Description','e') ?>
417 </td>
418 <td class="dehead" align="right">
419 <?php xl('Amount','e') ?>&nbsp;
420 </td>
421 <td class="dehead" align="right">
422 <?php xl('Balance','e') ?>&nbsp;
423 </td>
424 </tr>
426 <?php
427 $alertmsg = parse_era("$webserver_root/era/$eraname.edi", 'era_callback');
428 slTerminate();
430 </table>
431 </center>
432 <script language="JavaScript">
433 <?php
434 if ($alertmsg) echo " alert('" . htmlentities($alertmsg) . "');\n";
436 </script>
437 </body>
438 </html>
439 <?php
440 // Save all of this script's output to a report file.
441 if (!$debug) {
442 fwrite($fhreport, ob_get_contents());
443 fclose($fhreport);
445 ob_end_flush();