do not error out if any payments are already applied
[openemr.git] / interface / billing / sl_eob_process.php
blob7b1ca0dcc455dc966d11fe71805a93e24141abc1
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 // 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 $encount = 0;
25 $last_ptname = '';
26 $last_invnumber = '';
27 $last_code = '';
28 $invoice_total = 0.00;
30 ///////////////////////// Assorted Functions /////////////////////////
32 function parse_date($date) {
33 $date = substr(trim($date), 0, 10);
34 if (preg_match('/^(\d\d\d\d)(\d\d)(\d\d)$/', $date, $matches)) {
35 return $matches[1] . '-' . $matches[2] . '-' . $matches[3];
37 return '';
40 function writeMessageLine($bgcolor, $class, $description) {
41 $dline =
42 " <tr bgcolor='$bgcolor'>\n" .
43 " <td class='$class' colspan='4'>&nbsp;</td>\n" .
44 " <td class='$class'>$description</td>\n" .
45 " <td class='$class' colspan='2'>&nbsp;</td>\n" .
46 " </tr>\n";
47 echo $dline;
50 function writeDetailLine($bgcolor, $class, $ptname, $invnumber,
51 $code, $date, $description, $amount, $balance)
53 global $last_ptname, $last_invnumber, $last_code;
54 if ($ptname == $last_ptname) $ptname = '&nbsp;';
55 else $last_ptname = $ptname;
56 if ($invnumber == $last_invnumber) $invnumber = '&nbsp;';
57 else $last_invnumber = $invnumber;
58 if ($code == $last_code) $code = '&nbsp;';
59 else $last_code = $code;
60 if ($amount ) $amount = sprintf("%.2f", $amount );
61 if ($balance) $balance = sprintf("%.2f", $balance);
62 $dline =
63 " <tr bgcolor='$bgcolor'>\n" .
64 " <td class='$class'>$ptname</td>\n" .
65 " <td class='$class'>$invnumber</td>\n" .
66 " <td class='$class'>$code</td>\n" .
67 " <td class='$class'>$date</td>\n" .
68 " <td class='$class'>$description</td>\n" .
69 " <td class='$class' align='right'>$amount</td>\n" .
70 " <td class='$class' align='right'>$balance</td>\n" .
71 " </tr>\n";
72 echo $dline;
75 // This writes detail lines that were already in SQL-Ledger for a given
76 // charge item.
78 function writeOldDetail(&$prev, $ptname, $invnumber, $dos, $code, $bgcolor) {
79 global $invoice_total;
80 // $prev['total'] = 0.00; // to accumulate total charges
81 ksort($prev['dtl']);
82 foreach ($prev['dtl'] as $dkey => $ddata) {
83 $ddate = substr($dkey, 0, 10);
84 $description = $ddata['src'] . $ddata['rsn'];
85 if ($ddate == ' ') { // this is the service item
86 $ddate = $dos;
87 $description = 'Service Item';
89 $amount = sprintf("%.2f", $ddata['chg'] - $ddata['pmt']);
90 $invoice_total = sprintf("%.2f", $invoice_total + $amount);
91 writeDetailLine($bgcolor, 'olddetail', $ptname, $invnumber,
92 $code, $ddate, $description, $amount, $invoice_total);
96 // This is called back by parse_era() once per claim.
98 function era_callback(&$out) {
99 global $encount, $debug, $claim_status_codes, $adjustment_reasons, $remark_codes;
100 global $invoice_total, $last_code;
102 // Some heading information.
103 if ($encount == 0) {
104 writeMessageLine('#ffffff', 'infdetail',
105 "Payer: " . htmlentities($out['payer_name']));
106 if ($debug) {
107 writeMessageLine('#ffffff', 'infdetail',
108 "WITHOUT UPDATE is selected; no changes will be applied.");
112 $last_code = '';
113 $invoice_total = 0.00;
114 $bgcolor = (++$encount & 1) ? "#ddddff" : "#ffdddd";
115 list($pid, $encounter, $invnumber) = slInvoiceNumber($out);
117 // Get details, if we have them, for the invoice.
118 $inverror = true;
119 $codes = array();
120 if ($pid && $encounter) {
121 // Get invoice data into $arrow.
122 $arres = SLQuery("SELECT ar.id, ar.notes, ar.shipvia, customer.name " .
123 "FROM ar, customer WHERE ar.invnumber = '$invnumber' AND " .
124 "customer.id = ar.customer_id");
125 if ($sl_err) die($sl_err);
126 $arrow = SLGetRow($arres, 0);
127 if ($arrow) {
128 $inverror = false;
129 $codes = get_invoice_summary($arrow['id'], true);
130 } else { // oops, no such invoice
131 $pid = $encounter = 0;
132 $invnumber = $out['our_claim_id'];
136 $insurance_id = 0;
137 foreach ($codes as $cdata) {
138 if ($cdata['ins']) {
139 $insurance_id = $cdata['ins'];
140 break;
144 // Show the claim status.
145 $csc = $out['claim_status_code'];
146 $inslabel = 'Ins1';
147 if ($csc == '1' || $csc == '19') $inslabel = 'Ins1';
148 if ($csc == '2' || $csc == '20') $inslabel = 'Ins2';
149 if ($csc == '3' || $csc == '21') $inslabel = 'Ins3';
150 $primary = ($inslabel == 'Ins1');
151 writeMessageLine($bgcolor, 'infdetail',
152 "Claim status $csc: " . $claim_status_codes[$csc]);
154 // Show an error message if the claim is missing or already posted.
155 if ($inverror) {
156 writeMessageLine($bgcolor, 'errdetail',
157 "The following claim is not in our database");
159 else {
160 // Skip this test. Claims can get multiple CLPs from the same payer!
162 // $insdone = strtolower($arrow['shipvia']);
163 // if (strpos($insdone, 'ins1') !== false) {
164 // $inverror = true;
165 // writeMessageLine($bgcolor, 'errdetail',
166 // "Primary insurance EOB was already posted for the following claim");
167 // }
170 if ($out['warnings']) {
171 writeMessageLine($bgcolor, 'infdetail', nl2br(rtrim($out['warnings'])));
174 // Simplify some claim attributes for cleaner code.
175 $service_date = parse_date($out['dos']);
176 $check_date = parse_date($out['check_date']);
177 $production_date = parse_date($out['production_date']);
178 $patient_name = $arrow['name'] ? $arrow['name'] :
179 ($out['patient_fname'] . ' ' . $out['patient_lname']);
181 $error = $inverror;
183 // This loops once for each service item in this claim.
184 foreach ($out['svc'] as $svc) {
185 $prev = $codes[$svc['code']];
187 // This reports detail lines already on file for this service item.
188 if ($prev) {
189 writeOldDetail($prev, $patient_name, $invnumber, $service_date, $svc['code'], $bgcolor);
190 // Check for sanity in amount charged.
191 $prevchg = sprintf("%.2f", $prev['chg'] + $prev['adj']);
192 if ($prevchg != $svc['chg']) {
193 writeMessageLine($bgcolor, 'errdetail',
194 "EOB charge amount " . $svc['chg'] . " for this code does not match our invoice");
195 $error = true;
198 // Check for already-existing primary remittance activity.
199 // Removed this check because it was not allowing for copays manually
200 // entered into the invoice under a non-copay billing code.
201 /****
202 if ((sprintf("%.2f",$prev['chg']) != sprintf("%.2f",$prev['bal']) ||
203 $prev['adj'] != 0) && $primary)
205 writeMessageLine($bgcolor, 'errdetail',
206 "This service item already has primary payments and/or adjustments!");
207 $error = true;
209 ****/
211 unset($codes[$svc['code']]);
214 // If the service item is not in our database...
215 else {
217 // This is not an error. If we are not in error mode and not debugging,
218 // insert the service item into SL. Then display it (in green if it
219 // was inserted, or in red if we are in error mode).
220 $description = 'CPT4:' . $svc['code'] . " Added by $inslabel $production_date";
221 if (!$error && !$debug) {
222 slPostCharge($arrow['id'], $svc['chg'], $service_date, $svc['code'],
223 $insurance_id, $description, $debug);
224 $invoice_total += $svc['chg'];
226 $class = $error ? 'errdetail' : 'newdetail';
227 writeDetailLine($bgcolor, $class, $patient_name, $invnumber,
228 $svc['code'], $production_date, $description,
229 $svc['chg'], ($error ? '' : $invoice_total));
233 $class = $error ? 'errdetail' : 'newdetail';
235 // Report Allowed Amount.
236 if ($svc['allowed']) {
237 // A problem here is that some payers will include an adjustment
238 // reflecting the allowed amount, others not. So here we need to
239 // check if the adjustment exists, and if not then create it. We
240 // assume that any nonzero CO (Contractual Obligation) adjustment
241 // is good enough.
242 $contract_adj = sprintf("%.2f", $svc['chg'] - $svc['allowed']);
243 foreach ($svc['adj'] as $adj) {
244 if ($adj['group_code'] == 'CO' && $adj['amount'] != 0)
245 $contract_adj = 0;
247 if ($contract_adj > 0) {
248 $svc['adj'][] = array('group_code' => 'CO', 'reason_code' => 'A2',
249 'amount' => $contract_adj);
251 writeMessageLine($bgcolor, 'infdetail',
252 'Allowed amount is ' . sprintf("%.2f", $svc['allowed']));
255 // Report miscellaneous remarks.
256 if ($svc['remark']) {
257 $rmk = $svc['remark'];
258 writeMessageLine($bgcolor, 'infdetail', "$rmk: " . $remark_codes[$rmk]);
261 // Post and report the payment for this service item from the ERA.
262 // By the way a 'Claim' level payment is probably going to be negative,
263 // i.e. a payment reversal.
264 if ($svc['paid']) {
265 if (!$error && !$debug) {
266 slPostPayment($arrow['id'], $svc['paid'], $check_date,
267 "$inslabel/" . $out['check_number'], $svc['code'], $insurance_id, $debug);
268 $invoice_total -= $svc['paid'];
270 $description = "$inslabel/" . $out['check_number'] . ' payment';
271 if ($svc['paid'] < 0) $description .= ' reversal';
272 writeDetailLine($bgcolor, $class, $patient_name, $invnumber,
273 $svc['code'], $check_date, $description,
274 0 - $svc['paid'], ($error ? '' : $invoice_total));
277 // Post and report adjustments from this ERA. Posted adjustment reasons
278 // must be 25 characters or less in order to fit on patient statements.
279 foreach ($svc['adj'] as $adj) {
280 $description = $adj['reason_code'] . ': ' . $adjustment_reasons[$adj['reason_code']];
281 if ($adj['group_code'] == 'PR' || !$primary) {
282 // Group code PR is Patient Responsibility. Enter these as zero
283 // adjustments to retain the note without crediting the claim.
284 if ($primary) {
285 $reason = 'Pt resp: '; // Reasons should be 25 chars or less.
286 if ($adj['reason_code'] == '1') $reason = 'To deductible: ';
287 else if ($adj['reason_code'] == '2') $reason = 'Coinsurance: ';
288 else if ($adj['reason_code'] == '3') $reason = 'Co-pay: ';
290 // Non-primary insurance adjustments are garbage, either repeating
291 // the primary or are not adjustments at all. Report them as notes
292 // but do not post any amounts.
293 else {
294 $reason = "$inslabel note " . $adj['reason_code'] . ': ';
295 $reason .= sprintf("%.2f", $adj['amount']);
297 $reason .= sprintf("%.2f", $adj['amount']);
298 // Post a zero-dollar adjustment just to save it as a comment.
299 if (!$error && !$debug) {
300 slPostAdjustment($arrow['id'], 0, $production_date,
301 $out['check_number'], $svc['code'], $insurance_id,
302 $reason, $debug);
304 writeMessageLine($bgcolor, $class, $description . ' ' .
305 sprintf("%.2f", $adj['amount']));
307 // Other group codes for primary insurance are real adjustments.
308 else {
309 if (!$error && !$debug) {
310 slPostAdjustment($arrow['id'], $adj['amount'], $production_date,
311 $out['check_number'], $svc['code'], $insurance_id,
312 "$inslabel adjust code " . $adj['reason_code'], $debug);
313 $invoice_total -= $adj['amount'];
315 writeDetailLine($bgcolor, $class, $patient_name, $invnumber,
316 $svc['code'], $production_date, $description,
317 0 - $adj['amount'], ($error ? '' : $invoice_total));
321 } // End of service item
323 // Report any existing service items not mentioned in the ERA.
324 foreach ($codes as $code => $prev) {
325 writeOldDetail($prev, $arrow['name'], $invnumber, $service_date, $code, $bgcolor);
328 // Cleanup: If all is well, mark Ins1 done and check for secondary billing.
329 if (!$error && !$debug) {
330 $shipvia = 'Done: Ins1';
331 if ($inslabel != 'Ins1') $shipvia .= ',Ins2';
332 if ($inslabel == 'Ins3') $shipvia .= ',Ins3';
333 $query = "UPDATE ar SET shipvia = '$shipvia' WHERE id = " . $arrow['id'];
334 SLQuery($query);
335 if ($sl_err) die($sl_err);
336 // Check for secondary insurance.
337 $insgot = strtolower($arrow['notes']);
338 if ($primary && strpos($insgot, 'ins2') !== false) {
339 slSetupSecondary($arrow['id'], $debug);
340 writeMessageLine($bgcolor, 'infdetail',
341 'This claim is now re-queued for secondary paper billing');
347 /////////////////////////// End Functions ////////////////////////////
349 $info_msg = "";
351 $eraname = $_GET['eraname'];
352 if (! $eraname) die(xl("You cannot access this page directly."));
354 // Open the output file early so that in case it fails, we do not post a
355 // bunch of stuff without saving the report. Also be sure to retain any old
356 // report files. Do not save the report if this is a no-update situation.
358 if (!$debug) {
359 $nameprefix = "$webserver_root/era/$eraname";
360 $namesuffix = '';
361 for ($i = 1; is_file("$nameprefix$namesuffix.html"); ++$i) {
362 $namesuffix = "_$i";
364 $fnreport = "$nameprefix$namesuffix.html";
365 $fhreport = fopen($fnreport, 'w');
366 if (!$fhreport) die(xl("Cannot create") . " '$fnreport'");
369 slInitialize();
371 <html>
372 <head>
373 <link rel=stylesheet href="<?echo $css_header;?>" type="text/css">
374 <style type="text/css">
375 body { font-family:sans-serif; font-size:8pt; font-weight:normal }
376 .dehead { color:#000000; font-family:sans-serif; font-size:9pt; font-weight:bold }
377 .olddetail { color:#000000; font-family:sans-serif; font-size:9pt; font-weight:normal }
378 .newdetail { color:#00dd00; font-family:sans-serif; font-size:9pt; font-weight:normal }
379 .errdetail { color:#dd0000; font-family:sans-serif; font-size:9pt; font-weight:normal }
380 .infdetail { color:#0000ff; font-family:sans-serif; font-size:9pt; font-weight:normal }
381 </style>
382 <title><?xl('EOB Posting - Electronic Remittances','e')?></title>
383 <script language="JavaScript">
384 </script>
385 </head>
386 <body leftmargin='0' topmargin='0' marginwidth='0' marginheight='0'>
387 <center>
389 <table border='0' cellpadding='2' cellspacing='0' width='100%'>
391 <tr bgcolor="#cccccc">
392 <td class="dehead">
393 <?php xl('Patient','e') ?>
394 </td>
395 <td class="dehead">
396 <?php xl('Invoice','e') ?>
397 </td>
398 <td class="dehead">
399 <?php xl('Code','e') ?>
400 </td>
401 <td class="dehead">
402 <?php xl('Date','e') ?>
403 </td>
404 <td class="dehead">
405 <?php xl('Description','e') ?>
406 </td>
407 <td class="dehead" align="right">
408 <?php xl('Amount','e') ?>&nbsp;
409 </td>
410 <td class="dehead" align="right">
411 <?php xl('Balance','e') ?>&nbsp;
412 </td>
413 </tr>
415 <?php
416 $alertmsg = parse_era("$webserver_root/era/$eraname.edi", 'era_callback');
417 slTerminate();
419 </table>
420 </center>
421 <script language="JavaScript">
422 <?php
423 if ($alertmsg) echo " alert('" . htmlentities($alertmsg) . "');\n";
425 </script>
426 </body>
427 </html>
428 <?php
429 // Save all of this script's output to a report file.
430 if (!$debug) {
431 fwrite($fhreport, ob_get_contents());
432 fclose($fhreport);
434 ob_end_flush();