3 * This processes X12 835 remittances and produces a report.
6 * @link http://www.open-emr.org
7 * @author Rod Roark <rod@sunsetsystems.com>
8 * @author Brady Miller <brady.g.miller@gmail.com>
9 * @author Stephen Waite <stephen.waite@cmsvt.com>
10 * @copyright Copyright (c) 2006-2010 Rod Roark <rod@sunsetsystems.com>
11 * @copyright Copyright (c) 2018 Brady Miller <brady.g.miller@gmail.com>
12 * @copyright Copyright (c) 2019 Stephen Waite <stephen.waite@cmsvt.com>
13 * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
16 // Buffer all output so we can archive it to a file.
19 require_once("../globals.php");
20 require_once("$srcdir/invoice_summary.inc.php");
22 use OpenEMR\Billing\BillingUtilities
;
23 use OpenEMR\Billing\ParseERA
;
24 use OpenEMR\Billing\SLEOB
;
25 use OpenEMR\Services\InsuranceService
;
27 $debug = $_GET['debug'] ?
1 : 0; // set to 1 for debugging mode
28 $paydate = parse_date($_GET['paydate']);
34 $invoice_total = 0.00;
35 $InsertionId; // last inserted ID of
37 ///////////////////////// Assorted Functions /////////////////////////
39 function parse_date($date)
41 $date = substr(trim($date), 0, 10);
42 if (preg_match('/^(\d\d\d\d)\D*(\d\d)\D*(\d\d)$/', $date, $matches)) {
43 return $matches[1] . '-' . $matches[2] . '-' . $matches[3];
49 function writeMessageLine($bgcolor, $class, $description, $nl2br_process = "false")
52 " <tr bgcolor='" . attr($bgcolor) . "'>\n" .
53 " <td class='" . attr($class) . "' colspan='4'> </td>\n";
55 $dline .= " <td class='" . attr($class) . "'>" . nl2br(text($description)) . "</td>\n";
57 $dline .= " <td class='" . attr($class) . "'>" . text($description) . "</td>\n";
60 " <td class='" . attr($class) . "' colspan='2'> </td>\n" .
65 function writeDetailLine(
77 global $last_ptname, $last_invnumber, $last_code;
78 if ($ptname == $last_ptname) {
81 $last_ptname = $ptname;
84 if ($invnumber == $last_invnumber) {
85 $invnumber = ' ';
87 $last_invnumber = $invnumber;
90 if ($code == $last_code) {
97 $amount = sprintf("%.2f", $amount);
101 $balance = sprintf("%.2f", $balance);
105 " <tr bgcolor='" . attr($bgcolor) . "'>\n" .
106 " <td class='" . attr($class) . "'>" . text($ptname) . "</td>\n" .
107 " <td class='" . attr($class) . "'>" . text($invnumber) . "</td>\n" .
108 " <td class='" . attr($class) . "'>" . text($code) . "</td>\n" .
109 " <td class='" . attr($class) . "'>" . text(oeFormatShortDate($date)) . "</td>\n" .
110 " <td class='" . attr($class) . "'>" . text($description) . "</td>\n" .
111 " <td class='" . attr($class) . "' align='right'>" . text(oeFormatMoney($amount)) . "</td>\n" .
112 " <td class='" . attr($class) . "' align='right'>" . text(oeFormatMoney($balance)) . "</td>\n" .
117 // This writes detail lines that were already in SQL-Ledger for a given
120 function writeOldDetail(&$prev, $ptname, $invnumber, $dos, $code, $bgcolor)
122 global $invoice_total;
123 // $prev['total'] = 0.00; // to accumulate total charges
125 foreach ($prev['dtl'] as $dkey => $ddata) {
126 $ddate = substr($dkey, 0, 10);
127 $description = $ddata['src'] . $ddata['rsn'];
128 if ($ddate == ' ') { // this is the service item
130 $description = 'Service Item';
133 $amount = sprintf("%.2f", $ddata['chg'] - $ddata['pmt']);
134 $invoice_total = sprintf("%.2f", $invoice_total +
$amount);
149 // This is called back by ParseERA::parse_era() once per claim.
151 function era_callback_check(&$out)
153 global $InsertionId;//last inserted ID of
154 global $StringToEcho,$debug;
156 if ($_GET['original']=='original') {
157 $StringToEcho="<br/><br/><br/><br/><br/><br/>";
158 $StringToEcho.="<table border='1' cellpadding='0' cellspacing='0' width='750'>";
159 $StringToEcho.="<tr bgcolor='#cccccc'><td width='50'></td><td class='dehead' width='150' align='center'>" . xlt('Check Number') . "</td><td class='dehead' width='400' align='center'>" . xlt('Payee Name') . "</td><td class='dehead' width='150' align='center'>" . xlt('Check Amount') . "</td></tr>";
161 for ($check_count=1; $check_count<=$out['check_count']; $check_count++
) {
162 if ($check_count%2
==1) {
168 $rs=sqlQ("select reference from ar_session where reference=?", array($out['check_number'.$check_count]));
169 if (sqlNumRows($rs)>0) {
174 $StringToEcho.="<tr bgcolor='" . attr($bgcolor) . "'>";
175 $StringToEcho.="<td><input type='checkbox' name='chk" . attr($out['check_number'.$check_count]) . "' value='" . attr($out['check_number'.$check_count]) . "'/></td>";
176 $StringToEcho.="<td>" . text($out['check_number'.$check_count]) . "</td>";
177 $StringToEcho.="<td>" . text($out['payee_name'.$check_count]) . "</td>";
178 $StringToEcho.="<td align='right'>" . text(number_format($out['check_amount'.$check_count], 2)) . "</td>";
179 $StringToEcho.="</tr>";
182 $StringToEcho.="<tr bgcolor='#cccccc'><td colspan='4' align='center'><input type='submit' name='CheckSubmit' value='Submit'/></td></tr>";
183 if ($WarningFlag==true) {
184 $StringToEcho.="<tr bgcolor='#ff0000'><td colspan='4' align='center'>" . xlt('Warning, Check Number already exist in the database') . "</td></tr>";
187 $StringToEcho.="</table>";
189 for ($check_count=1; $check_count<=$out['check_count']; $check_count++
) {
190 $chk_num=$out['check_number'.$check_count];
191 $chk_num=str_replace(' ', '_', $chk_num);
192 if (isset($_REQUEST['chk'.$chk_num])) {
193 $check_date=$out['check_date'.$check_count]?
$out['check_date'.$check_count]:$_REQUEST['paydate'];
194 $post_to_date=$_REQUEST['post_to_date']!=''?
$_REQUEST['post_to_date']:date('Y-m-d');
195 $deposit_date=$_REQUEST['deposit_date']!=''?
$_REQUEST['deposit_date']:date('Y-m-d');
196 $InsertionId[$out['check_number'.$check_count]]=SLEOB
::arPostSession($_REQUEST['InsId'], $out['check_number'.$check_count], $out['check_date'.$check_count], $out['check_amount'.$check_count], $post_to_date, $deposit_date, $debug);
201 function era_callback(&$out)
203 global $encount, $debug;
204 global $invoice_total, $last_code, $paydate;
205 global $InsertionId;//last inserted ID of
208 // Some heading information.
209 $chk_123=$out['check_number'];
210 $chk_123=str_replace(' ', '_', $chk_123);
211 if (isset($_REQUEST['chk'.$chk_123])) {
216 "Payer: " . $out['payer_name']
222 "WITHOUT UPDATE is selected; no changes will be applied."
228 $invoice_total = 0.00;
229 $bgcolor = (++
$encount & 1) ?
"#ddddff" : "#ffdddd";
230 list($pid, $encounter, $invnumber) = SLEOB
::slInvoiceNumber($out);
232 // Get details, if we have them, for the invoice.
235 if ($pid && $encounter) {
236 // Get invoice data into $arrow or $ferow.
237 $ferow = sqlQuery("SELECT e.*, p.fname, p.mname, p.lname " .
238 "FROM form_encounter AS e, patient_data AS p WHERE " .
239 "e.pid = ? AND e.encounter = ? AND ".
240 "p.pid = e.pid", array($pid, $encounter));
242 $pid = $encounter = 0;
243 $invnumber = $out['our_claim_id'];
246 $codes = ar_get_invoice_summary($pid, $encounter, true);
247 // $svcdate = substr($ferow['date'], 0, 10);
251 // Show the claim status.
252 $csc = $out['claim_status_code'];
254 if ($csc == '1' ||
$csc == '19') {
258 if ($csc == '2' ||
$csc == '20') {
262 if ($csc == '3' ||
$csc == '21') {
266 $primary = ($inslabel == 'Ins1');
270 "Claim status $csc: " . BillingUtilities
::claim_status_codes_CLP02
[$csc]
273 // Show an error message if the claim is missing or already posted.
278 "The following claim is not in our database"
281 // Skip this test. Claims can get multiple CLPs from the same payer!
283 // $insdone = strtolower($arrow['shipvia']);
284 // if (strpos($insdone, 'ins1') !== false) {
286 // writeMessageLine($bgcolor, 'errdetail',
287 // "Primary insurance EOB was already posted for the following claim");
291 if ($csc == '4') {//Denial case, code is stored in the claims table for display in the billing manager screen with reason explained.
294 if ($pid && $encounter) {
296 foreach ($out['svc'] as $svc) {
297 foreach ($svc['adj'] as $adj) {//Per code and modifier the reason will be showed in the billing manager.
298 $code_value .= $svc['code'].'_'.$svc['mod'].'_'.$adj['group_code'].'_'.$adj['reason_code'].',';
302 $code_value = substr($code_value, 0, -1);
303 //We store the reason code to display it with description in the billing manager screen.
304 //process_file is used as for the denial case file name will not be there, and extra field(to store reason) can be avoided.
305 BillingUtilities
::updateClaim(true, $pid, $encounter, $_REQUEST['InsId'], substr($inslabel, 3), 7, 0, $code_value);
312 "Not posting adjustments for denied claims, please follow up manually!"
314 } else if ($csc == '22') {
319 "Payment reversals are not automated, please enter manually!"
323 if ($out['warnings']) {
324 writeMessageLine($bgcolor, 'infdetail', rtrim($out['warnings']), true);
327 // Simplify some claim attributes for cleaner code.
328 $service_date = parse_date($out['dos']);
329 $check_date = $paydate ?
$paydate : parse_date($out['check_date']);
330 $production_date = $paydate ?
$paydate : parse_date($out['production_date']);
332 $insurance_id = SLEOB
::arGetPayerID($pid, $service_date, substr($inslabel, 3));
333 if (empty($ferow['lname'])) {
334 $patient_name = $out['patient_fname'] . ' ' . $out['patient_lname'];
336 $patient_name = $ferow['fname'] . ' ' . $ferow['lname'];
341 // This loops once for each service item in this claim.
342 foreach ($out['svc'] as $svc) {
343 // Treat a modifier in the remit data as part of the procedure key.
344 // This key will then make its way into SQL-Ledger.
345 $codekey = $svc['code'];
347 $codekey .= ':' . $svc['mod'];
350 $prev = $codes[$codekey];
351 $codetype = ''; //will hold code type, if exists
353 // This reports detail lines already on file for this service item.
355 $codetype = $codes[$codekey]['code_type']; //store code type
356 writeOldDetail($prev, $patient_name, $invnumber, $service_date, $codekey, $bgcolor);
357 // Check for sanity in amount charged.
358 $prevchg = sprintf("%.2f", $prev['chg'] +
$prev['adj']);
359 if ($prevchg != abs($svc['chg'])) {
363 "EOB charge amount " . $svc['chg'] . " for this code does not match our invoice"
368 // Check for already-existing primary remittance activity.
369 // Removed this check because it was not allowing for copays manually
370 // entered into the invoice under a non-copay billing code.
372 if ((sprintf("%.2f",$prev['chg']) != sprintf("%.2f",$prev['bal']) ||
373 $prev['adj'] != 0) && $primary)
375 writeMessageLine($bgcolor, 'errdetail',
376 "This service item already has primary payments and/or adjustments!");
381 unset($codes[$codekey]);
382 } // If the service item is not in our database...
384 // This is not an error. If we are not in error mode and not debugging,
385 // insert the service item into SL. Then display it (in green if it
386 // was inserted, or in red if we are in error mode).
387 $description = "CPT4:$codekey Added by $inslabel $production_date";
388 if (!$error && !$debug) {
402 $invoice_total +
= $svc['chg'];
405 $class = $error ?
'errdetail' : 'newdetail';
415 ($error ?
'' : $invoice_total)
419 $class = $error ?
'errdetail' : 'newdetail';
421 // Report Allowed Amount.
422 if ($svc['allowed']) {
423 // A problem here is that some payers will include an adjustment
424 // reflecting the allowed amount, others not. So here we need to
425 // check if the adjustment exists, and if not then create it. We
426 // assume that any nonzero CO (Contractual Obligation) or PI
427 // (Payer Initiated) adjustment is good enough.
428 $contract_adj = sprintf("%.2f", $svc['chg'] - $svc['allowed']);
429 foreach ($svc['adj'] as $adj) {
430 if (($adj['group_code'] == 'CO' ||
$adj['group_code'] == 'PI') && $adj['amount'] != 0) {
435 if ($contract_adj > 0) {
436 $svc['adj'][] = array('group_code' => 'CO', 'reason_code' => 'A2',
437 'amount' => $contract_adj);
443 'Allowed amount is ' . sprintf("%.2f", $svc['allowed'])
447 // Report miscellaneous remarks.
448 if ($svc['remark']) {
449 $rmk = $svc['remark'];
450 writeMessageLine($bgcolor, 'infdetail', "$rmk: " .
451 BillingUtilities
::remittance_advice_remark_codes
[$rmk]);
454 // Post and report the payment for this service item from the ERA.
455 // By the way a 'Claim' level payment is probably going to be negative,
456 // i.e. a payment reversal.
458 if (!$error && !$debug) {
459 SLEOB
::arPostPayment(
462 $InsertionId[$out['check_number']],
463 $svc['paid'], //$InsertionId[$out['check_number']] gives the session id
465 substr($inslabel, 3),
466 $out['check_number'],
471 $invoice_total -= $svc['paid'];
474 $description = "$inslabel/" . $out['check_number'] . ' payment';
475 if ($svc['paid'] < 0) {
476 $description .= ' reversal';
488 ($error ?
'' : $invoice_total)
492 // Post and report adjustments from this ERA. Posted adjustment reasons
493 // must be 25 characters or less in order to fit on patient statements.
494 foreach ($svc['adj'] as $adj) {
495 $description = $adj['reason_code'] . ': ' .
496 BillingUtilities
::claim_adjustment_reason_codes
[$adj['reason_code']];
497 if ($adj['group_code'] == 'PR' ||
!$primary) {
498 // Group code PR is Patient Responsibility. Enter these as zero
499 // adjustments to retain the note without crediting the claim.
502 $reason = 'Pt resp: '; // Reasons should be 25 chars or less.
503 if ($adj['reason_code'] == '1') $reason = 'To deductible: ';
504 else if ($adj['reason_code'] == '2') $reason = 'Coinsurance: ';
505 else if ($adj['reason_code'] == '3') $reason = 'Co-pay: ';
507 $reason = "$inslabel ptresp: "; // Reasons should be 25 chars or less.
508 if ($adj['reason_code'] == '1') {
509 $reason = "$inslabel dedbl: ";
510 } else if ($adj['reason_code'] == '2') {
511 $reason = "$inslabel coins: ";
512 } else if ($adj['reason_code'] == '3') {
513 $reason = "$inslabel copay: ";
515 } // Non-primary insurance adjustments are garbage, either repeating
516 // the primary or are not adjustments at all. Report them as notes
517 // but do not post any amounts.
519 $reason = "$inslabel note " . $adj['reason_code'] . ': ';
521 $reason .= sprintf("%.2f", $adj['amount']);
525 $reason .= sprintf("%.2f", $adj['amount']);
526 // Post a zero-dollar adjustment just to save it as a comment.
527 if (!$error && !$debug) {
528 SLEOB
::arPostAdjustment(
531 $InsertionId[$out['check_number']],
533 $codekey, //$InsertionId[$out['check_number']] gives the session id
534 substr($inslabel, 3),
542 writeMessageLine($bgcolor, $class, $description . ' ' .
543 sprintf("%.2f", $adj['amount']));
544 } // Other group codes for primary insurance are real adjustments.
546 if (!$error && !$debug) {
547 SLEOB
::arPostAdjustment(
550 $InsertionId[$out['check_number']],
551 $adj['amount'], //$InsertionId[$out['check_number']] gives the session id
553 substr($inslabel, 3),
554 "Adjust code " . $adj['reason_code'],
559 $invoice_total -= $adj['amount'];
571 ($error ?
'' : $invoice_total)
575 } // End of service item
577 // Report any existing service items not mentioned in the ERA, and
578 // determine if any of them are still missing an insurance response
579 // (if so, then insurance is not yet done with the claim).
580 $insurance_done = true;
581 foreach ($codes as $code => $prev) {
582 // writeOldDetail($prev, $arrow['name'], $invnumber, $service_date, $code, $bgcolor);
583 writeOldDetail($prev, $patient_name, $invnumber, $service_date, $code, $bgcolor);
584 $got_response = false;
585 foreach ($prev['dtl'] as $ddata) {
586 if ($ddata['pmt'] ||
$ddata['rsn']) {
587 $got_response = true;
591 if (!$got_response) {
592 $insurance_done = false;
596 // Cleanup: If all is well, mark Ins<x> done and check for secondary billing.
597 if (!$error && !$debug && $insurance_done) {
598 $level_done = 0 +
substr($inslabel, 3);
600 if ($out['crossover']==1) {//Automatic forward case.So need not again bill from the billing manager screen.
601 sqlStatement("UPDATE form_encounter " .
602 "SET last_level_closed = ?,last_level_billed=? WHERE " .
603 "pid = ? AND encounter = ?", array($level_done, $level_done, $pid, $encounter));
607 'This claim is processed by Insurance '.$level_done.' and automatically forwarded to Insurance '.($level_done+
1) .' for processing. '
610 sqlStatement("UPDATE form_encounter " .
611 "SET last_level_closed = ? WHERE " .
612 "pid = ? AND encounter = ?", array($level_done, $pid, $encounter));
615 // Check for secondary insurance.
616 if ($primary && SLEOB
::arGetPayerID($pid, $service_date, 2)) {
617 SLEOB
::arSetupSecondary($pid, $encounter, $debug, $out['crossover']);
619 if ($out['crossover']<>1) {
623 'This claim is now re-queued for secondary paper billing'
628 if ($out['corrected'] == '1') {
629 if ($GLOBALS['update_mbi']) {
630 if ($primary && (substr($inslabel, 3) == 1)) {
631 $updated_ins = InsuranceService
::getOne($pid, "primary");
632 $updated_ins['provider'] = $insurance_id;
633 $updated_ins['policy_number'] = $out['corrected_mbi'];
634 InsuranceService
::update($pid, "primary", $updated_ins);
635 } else { // tbd secondary medicare
636 // InsuranceService::update($pid, "secondary", array($insurance_id, '', $out['corrected_mbi']));
637 // will need to add method to insurance service to return policy type
643 "The policy number has been updated to " . $out['corrected_mbi']
649 "The policy number could be updated to " . $out['corrected_mbi'] . " if you enable it in globals"
657 /////////////////////////// End Functions ////////////////////////////
661 if (!verifyCsrfToken($_GET["csrf_token_form"])) {
665 $eraname = $_GET['eraname'];
668 die(xlt("You cannot access this page directly."));
671 // Open the output file early so that in case it fails, we do not post a
672 // bunch of stuff without saving the report. Also be sure to retain any old
673 // report files. Do not save the report if this is a no-update situation.
676 $nameprefix = $GLOBALS['OE_SITE_DIR'] . "/documents/era/$eraname";
678 for ($i = 1; is_file("$nameprefix$namesuffix.html"); ++
$i) {
682 $fnreport = "$nameprefix$namesuffix.html";
683 $fhreport = fopen($fnreport, 'w');
685 die(xlt("Cannot create") . " '" . text($fnreport) . "'");
692 <link rel
=stylesheet href
="<?php echo $css_header;?>" type
="text/css">
693 <style type
="text/css">
694 body
{ font
-family
:sans
-serif
; font
-size
:8pt
; font
-weight
:normal
}
695 .dehead
{ color
:#000000; font-family:sans-serif; font-size:9pt; font-weight:bold }
696 .olddetail
{ color
:#000000; font-family:sans-serif; font-size:9pt; font-weight:normal }
697 .newdetail
{ color
:#00dd00; font-family:sans-serif; font-size:9pt; font-weight:normal }
698 .errdetail
{ color
:#dd0000; font-family:sans-serif; font-size:9pt; font-weight:normal }
699 .infdetail
{ color
:#0000ff; font-family:sans-serif; font-size:9pt; font-weight:normal }
701 <title
><?php
echo xlt('EOB Posting - Electronic Remittances')?
></title
>
703 <body leftmargin
='0' topmargin
='0' marginwidth
='0' marginheight
='0'>
704 <form action
="sl_eob_process.php" method
="get" >
705 <input type
="hidden" name
="csrf_token_form" value
="<?php echo attr(collectCsrfToken()); ?>" />
709 if ($_GET['original']=='original') {
710 $alertmsg = ParseERA
::parse_era_for_check($GLOBALS['OE_SITE_DIR'] . "/documents/era/$eraname.edi", 'era_callback');
714 <table border
='0' cellpadding
='2' cellspacing
='0' width
='100%'>
716 <tr bgcolor
="#cccccc">
718 <?php
echo xlt('Patient'); ?
>
721 <?php
echo xlt('Invoice'); ?
>
724 <?php
echo xlt('Code'); ?
>
727 <?php
echo xlt('Date'); ?
>
730 <?php
echo xlt('Description'); ?
>
732 <td
class="dehead" align
="right">
733 <?php
echo xlt('Amount'); ?
> 
;
735 <td
class="dehead" align
="right">
736 <?php
echo xl('Balance'); ?
> 
;
743 $eraname=$_REQUEST['eraname'];
744 $alertmsg = ParseERA
::parse_era_for_check($GLOBALS['OE_SITE_DIR'] . "/documents/era/$eraname.edi");
745 $alertmsg = ParseERA
::parse_era($GLOBALS['OE_SITE_DIR'] . "/documents/era/$eraname.edi", 'era_callback');
747 $StringIssue=xl("Total Distribution for following check number is not full").': ';
749 foreach ($InsertionId as $key => $value) {
750 $rs= sqlQ("select pay_total from ar_session where session_id=?", array($value));
751 $row=sqlFetchArray($rs);
752 $pay_total=$row['pay_total'];
753 $rs= sqlQ("select sum(pay_amount) sum_pay_amount from ar_activity where session_id=?", array($value));
754 $row=sqlFetchArray($rs);
755 $pay_amount=$row['sum_pay_amount'];
757 if (($pay_total-$pay_amount)<>0) {
758 $StringIssue.=$key.' ';
763 if ($StringPrint=='Yes') {
764 echo "<script>alert(" . js_escape($StringIssue) . ")</script>";
775 <script language
="JavaScript">
778 echo " alert(" . js_escape($alertmsg) . ");\n";
782 <input type
="hidden" name
="paydate" value
="<?php echo attr(DateToYYYYMMDD($_REQUEST['paydate'])); ?>" />
783 <input type
="hidden" name
="post_to_date" value
="<?php echo attr(DateToYYYYMMDD($_REQUEST['post_to_date'])); ?>" />
784 <input type
="hidden" name
="deposit_date" value
="<?php echo attr(DateToYYYYMMDD($_REQUEST['deposit_date'])); ?>" />
785 <input type
="hidden" name
="debug" value
="<?php echo attr($_REQUEST['debug']); ?>" />
786 <input type
="hidden" name
="InsId" value
="<?php echo attr($_REQUEST['InsId']); ?>" />
787 <input type
="hidden" name
="eraname" value
="<?php echo attr($eraname); ?>" />
792 // Save all of this script's output to a report file.
794 fwrite($fhreport, ob_get_contents());