Odyssey
cuReporting.prg
1 <?php
2 /**
3  * File: cuReporting.prg
4  * Authors SPB and Gus.
5  *
6  * This file was formerly cuReports.prg, cuReports, and cu_reports. Most of the reports were migrated from the old script of cu_reports. They were probably collected over 20 years or so.
7  * Then it was renamed cuReports and it was modernized to use the kendo framework for the engine. Most of the code is from that revision.
8  * It was then added to Odyssey where mostly the reports were updated to use new schemas. That stint was under Gus.
9  * Now, it is slightly being updated again. #199 requires grouping so that was added and it is generally updated for the mistakes that I realize. One of those mistakes was lack of comments.
10  *
11  * The main heavy work is in the include reporting.i which previous names were reports.i and reports. Reporting.i is common to this file, info_reports.prg, loggingReport.prg,
12  * and adm_reports.prg. The rest of the scripts need to be moved from reports.i to reporting.i before reports.i is removed.
13  *
14  * This file is for cu-centric reports. For reports across ALL cu's see info_reports.prg. See adm_reports.prg for non-CU reports.
15  */
16 
17 require_once("$sharedLibrary/reporting.i");
18 require_once("$admLibrary/userSearch.i");
19 require_once("$admLibrary/aGroupSupport.i");
20 require_once("$admLibrary/aMemberSupport.i");
21 
22 // Need to translate to translate list.
23 require_once("$sharedLibrary/hcuTranslate.i");
24 require_once("$bankingLibrary/hcuTransferScheduled.i");
25 
26 $parameters = array("a" => array("operation" => ""));
27 $string = array("filter" => FILTER_SANITIZE_STRING);
28 $array = array("filter" => FILTER_DEFAULT); // For inputs that require {[]}
29 HCU_ImportVars($parameters, "a", array("operation" => $string, "csv" => $string, "report" => $string, "sort" => $array, "filter" => $array));
30 
31 $operation = HCU_array_key_exists("operation", $parameters["a"]) ? trim($parameters["a"]["operation"]) : "";
32 $csv = HCU_array_key_exists("csv", $parameters["a"]) ? trim($parameters["a"]["csv"]) : "";
33 $report = HCU_array_key_exists("report", $parameters["a"]) ? trim($parameters["a"]["report"]) : "";
34 $filter = HCU_array_key_exists("filter", $parameters["a"]) ? trim($parameters["a"]["filter"]) : "";
35 $sort = HCU_array_key_exists("sort", $parameters["a"]) ? trim($parameters["a"]["sort"]) : "";
36 
37 $self = "$menu_link?ft=$ft";
38 
39 $sql = "select flagset, pname from cuadmin where cu = '$Cu'";
40 $sth = db_query($sql,$dbh);
41 list($cuflags, $prodname) = db_fetch_array($sth,0);
42 $prodname = (trim("$prodname") == "" ? "$Cu" : trim("$prodname"));
43 
44 $reportList = GetReportList($Cu, $prodname);
45 $MC = new hcu_talk_base("en_US");
46 
47 /**
48  * If there is an operation parameter set, then go do that data call. If there is a report then get the report. Otherwise, give a list of reports.
49  */
50 if ($operation != "") {
51  switch ($operation) {
52  case "readReport":
53  ReadReport($Cu, $dbh, $reportList, GetDefaultDateFormat());
54  break;
55  case "generateCSV":
56  GenerateCSV($Cu, $dbh, $reportList, GetDefaultDateFormat());
57  break;
58  default: // Won't get here
59  header('Content-type: application/json');
60  $returnArray = array("error" => array("Operation not specified: '$operation'"), "record" => "", "operation" => "");
61  print HCU_JsonEncode($returnArray);
62  }
63 } else { // first loading of script, present report summary list
64  $reportNotFound = false;
65  $report = trim($report);
66 
67  if (isset($reportList[$report])) {
68  $reportRecord = $reportList[$report];
69  $reportRecord["reportTitle"] = isset($reportRecord["reportTitle"]) ? $reportRecord["reportTitle"] : trim($reportRecord["title"]);
70  $title = isset($reportRecord["reportTitle"]) ? trim($reportRecord["reportTitle"]) : (isset($reportRecord["title"]) ? trim($prodname) . " " . trim($reportRecord["title"]) : trim($report));
71  } else if ($report != "") {
72  $reportNotFound = true;
73  }
74 
75  $printerFriendly = trim($csv) == "print";
76 
77  $report == "" || $reportNotFound ? PrintReportListPage($reportList, $self, $report, $reportNotFound, $live)
78  : ($printerFriendly ? PrintReportPagePrintOnly($reportList, $report, $prodname, $title, $self, $sort, $filter) : PrintReportPage($reportList, $report, $prodname, $title, $self));
79 }
80 
81 // ***********************************************
82 // Configuration
83 // ***********************************************
84 
85 /**
86  * function GetDefaultDateFormat()
87  * @return The default date format for the reports. This is used directly for PHP date formatting. It is translated for kendo date formatting.
88  */
89 function GetDefaultDateFormat() {
90  return "MM/dd/yyyy";
91 }
92 
93 /**
94  * function GetReportList($Cu, $prodname)
95  * This is the bread and butter of the report script. Here, we have all the scripts in this particular format:
96  *
97  * array($reportKey => array("title" => $title, "icon" => $icon, "sql" => $sql, "dateFormat" => $dateFormat, "dateEmpty" => $dateEmpty, "cols" => array($colTitle => $colType, ...),
98  * "groupBy" => $groupBy, "groupDescription" => $groupDescription, "ddl" => array($ddlColumn => array($value => $text, ...), ...),
99  * "href" => $href, "colHeader" => $colHeader, "schemaFunc" => $schemaFunc), ...)
100  *
101  * $reportKey -- The key that is necessary for the report page. It needs to be unique. This will show up on the URL for the report and is referenced in the menu.
102  * $title -- The title of the report. This will show up as the name of the CSV file on download, the title at the top of the page for the report, and the link on the report menu.
103  * If $title is missing, then it uses the key.
104  * $sql -- This is required. It is the base SQL of the report. Aliases are ignored. These come from the $col array. Any sorts are also ignored. Any sorts and filters are added to this
105  * SQL. If there is no sort, then the first column is sorted. Also, the columns are renamed as per the $col array. This is so any sorts and filters can refer to the column name.
106  * $cols -- This is required. This should be in order of the columns mentioned in the SQL. It consists of $colTitle => $colValue pairs.
107  * $colTitle -- This is the title of the column that will appear in the kendo grid. The kendo column name also comes from the camel case version of this string.
108  * If the title starts with a number, the kendo column name will have a "n" prepended. This is because kendo doesn't take kindly to columns starting with a number.
109  * Also, if two columns have the same title, they will still work. If there is a "." in the title, the actual title is everything before the period and the name takes everything.
110  * $colValue -- This is the column type. Column can be one of the following: "number", "string", "date", "boolean", "odata", "currency", "ignore", "list", "hidden"
111  * "number" -- refers to kendo's "number" format. Text aligns right.
112  * "decimal" -- like number but also restrict the number of digits but no need for a $.
113  * "currency" -- like number except also formats the number to $0.00.
114  * "string" -- refers to kendo's "string" format. Filter is case sensitive and trims first.
115  * "list" -- like string but also adds the "values" attribute to the kendo column. Option needs the $ddl option for the values.
116  * "date" -- refers to kendo's "date" format. This will be formatted according to $defaultDateFormat.
117  * "odata" -- refers to kendo's "odata" format. Sort and filter are not well defined for this format.
118  * "boolean" -- refers to kendo's "string" format. (The boolean format doesn't seem to work.) It has a "values" attribute according to what is true in the dataset and what is false.
119  * Assumed trues are true, "true", "Y", "yes", etc. Assumed falses are false, "false", "N", "no", etc. If both the false and true aren't found, then it gets the opposite.
120  * Regardless of what is true and what is false, it will show as "Y" and "N".
121  * "ignore" -- The column shows up in the dataItem of the grid but doesn't show up in the column array. (So then you can still do stuff with it.)
122  * "hidden" -- The columns shows up in the dataItem and the column array but it has the "hidden" attribute. (I added it for grouping on a column.)
123  * "description" -- Like a "string" type but there is no text wrap and there are ellipses.
124  * "unencoded" -- Sets the kendo's column attribute encoded to false. There is no additional formatting at all.
125  * $groupBy -- This is an optional parameter. It refers to the $colTitle of the column to group. If this is set, then the attributes "serverSideGrouping" and "group" are added to dataSource.
126  * The data for the calls are returned in a format grouped by the column. The CSV becomes grouped as well. The attribute "group" is added to the schema to parse the data correctly.
127  * Sorts sort on the groupBy column first before the actual columns being sorted.
128  * $groupDescription -- This is an optional parameter. It doesn't do anything if $groupBy is not set. It refers to the $colTitle of the description column.
129  * It adds the aggregate max to the description column underneath the group attribute. This allows that column's value to be displayed in the group header instead of the $groupBy column.
130  * $groupForceSortBy -- Forces the group sort to be by the $groupBy when both $groupBy and $groupDescription are defined.
131  * $ddl -- This is an optional parameter. This contains all the DDLs of the "list" columns.
132  * $ddlColumn -- This refers to the $colTitle of the group with a DDL. It needs $value => $text pairs in the DDL.
133  * $value -- Refers to the "value" attribute in the "values" attribute for a column.
134  * $text -- Refers to the "text" attribute in the "values" attribute for a column.
135  * $dateFormat -- This is what any dates should be formatted as for the report. If this is not set, then it gets the default date format for all the reports.
136  * $dateEmpty -- This is what the text will be if the date is null. The default is an empty string.
137  * $href -- Optional. If set, this the URL is different (versus the default $self&report=$report).
138  * $colHeader -- Optional. If set, then columns will have another line for the column header.
139  * $schemaFunc -- Optional. If set, then it refers to another function defined in this script. Function looks like addProfileTemplates($colTitle, $colType, $kendoCol, &$columnRecord).
140  * The function is called once per column in the readReportSchema function.
141  * $dataFunc -- Optional. If set, then it referes to another function defined in this script. Function looks like addLinksToOrphanedRecordsReport($dbh, &$dataRecord).
142  * The function is called once per data row in the getData function.
143  * $dataFuncVariables -- Optional array to send to custom function in this script defined by "dataFunc".
144  * $showOnCSV -- Optional. If not set, then "ignore" and "hidden" columns are not shown on the CSV. Other columns are shown. If set, for the columns specified, true means the column
145  * shows up in the CSV.
146  * nonSqlDataFunc -- Either this option or the SQL option needs to be defined. (If both are defined, SQL takes precedence.) This is set to function name. Function needs to be defined here.
147  * Function has one parameter: $dbh which is the database connection. It must return an array with "data" -- array of what should go into the report, "status" -- "000" if successful,
148  * "error" -- "" if successful; otherwise it is the text of the error message, "sql" -- optional array of SQL used (if the query is too complex for the report engine or if it needs functions.)
149  * defaultSort -- If set, then use this instead of the default sort which is the first visible column ascending.
150  *
151  * @param $Cu -- the credit union to get the report list for.
152  * @param $prodname -- the prodname of the credit union
153  * @return $reportList with all the attributes defined above.
154  */
155 function GetReportList($Cu, $prodname) {
156  $monthPivot = new PivotCTE(); // Definition is in reports.i
157 
158  // Apparently the menu items are the titles and the titles of the reports prepend $prodname minus a couple (10-13) thus I am adding a reportTitle attribute only for cu_reports
159  // and these exceptions.
160  $reportList = array(
161  // 1
162  "loginHistory" => array("title" => "Monthly User Login History",
163  "sql" => $monthPivot->GetCTE() . ", " . GetLoginCTE($Cu) . " " .
164  "select user_id, user_name, case when hcucode = 'CMB' then 'OldMBL' when hcucode = 'ADA' then 'Android' when hcucode = 'APP'
165  then 'iPhone' when hcucode = 'MBL' then 'MBLweb' when hcucode = 'DSK' then 'Desktop' else 'Classic' end, " . $monthPivot->GetColumnSQL("logins") . " from loginsCTE a,
166  monthPivotCTE p where a.yearmo = p.yearmo group by user_id, user_name, hcucode",
167  "cols" => $monthPivot->GetColumns(array("User Id" => "ignore", "User Name" => "unencoded", "Product" => "string")),
168  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
169  // 2
170  "loginSummary" => array("title" => "Monthly Login Summary",
171  "sql" => "with " . GetLoginCTE($Cu) . " " .
172  "select substr(yearmo,1,4) || '-' || substr(yearmo,5,2), case when hcucode = 'CMB' then 'OldMBL' when hcucode = 'ADA' then 'Android' when hcucode = 'APP' then 'iPhone'
173  when hcucode = 'MBL' then 'MBLweb' when hcucode = 'DSK' then 'Desktop' else 'Classic' end,
174  sum(logins), count(*), to_char(avg(logins),'999.9') from loginsCTE group by 1,2 ",
175  "cols" => array("Month" => "string", "Product" => "string", "Logins" => "number", "Users" => "number", "Average / Active User" => "number")),
176 
177  // 2b
178  # selecting remote_ip as inet breaks sorting and filtering
179  # casting as varchar adds '/32' subnet notation
180  # so cast as varchar to fix sorting / filtering, use regexp_replace to drop the subnet notation MH
181  "loginTracking" => array("title" => "User Login Tracking", "dateFormat" => "yyyy/MM/dd HH:mm:ss",
182  "sql" => "select user_name, case when hcucode = 'CMB' then 'OldMBL' when hcucode = 'ADA' then 'Android' when hcucode = 'APP' then 'iPhone' when hcucode = 'MBL' then 'MBLweb'
183  when hcucode = 'DSK' then 'Desktop' else 'Classic' end, login_dt,
184  case when status = 0 then 'LOGIN' when coalesce(status, 0) & 4::int4 = 4 then 'F_Email'
185  when coalesce(status, 0) & 8::int4 = 8 then 'F_Challenge' when coalesce(status, 0) & 16::int4 = 16 then 'F_Password' when coalesce(status, 0) & 64::int4 = 64 then 'F_Username'
186  when coalesce(status, 0) & 128::int4 = 128 then 'F_Access' when coalesce(status, 0) & 256::int4 = 256 then 'F_MFA' when coalesce(status, 0) & "
187  . GetUserFlagsValue('MEM_LOGIN_FAILED_BNDL_TIMEOUT') . "::int4 <> 0 then 'F_Timeout'
188  else '' end, regexp_replace(remote_ip::varchar ,'/.*$',''), regexp_replace(metadata::varchar, '\"UA\":', '') from ${Cu}userlogins where user_id is not null",
189  "cols" => array("User Name" => "string", "Product" => "string", "Date" => "datetime", "Status" => "string", "RemoteIP" => "string", "UserAgent" => "string")
190  ),
191  // 3
192  "transactionMonth" => array("title" => "Transaction Report by Month", "dateFormat" => "yyyy-MM",
193  "sql" => "select date_trunc('month', effective_date), count(*) from ${Cu}transhdr where accountnumber is not null and feature_code = 'TRN' group by 1",
194  "cols" => array("Month" => "date", "Total" => "number")),
195  // 4
196  "transactionDay" => array("title" => "Transaction Report by Date", "dateFormat" => "yyyy-MM-dd",
197  "sql" => "select date_trunc('day', effective_date), count(*) from ${Cu}transhdr where accountnumber is not null and feature_code = 'TRN' group by 1",
198  "cols" => array("Date" => "date", "Total" => "number")),
199  // 5
200  "emailStatement" => array("title" => "User Email and Statement Settings",
201  "sql" => "select u.user_id, u.user_name, u.email, case when mama.estmnt > 0 then 'Y' else 'N' end, case when u.egenl_flag = 'Y' then 'Y' else 'N' end,
202  case when cscs.accountnumber is not null then 'Y' else 'N' end, case when coalesce(u.userflags,0) & 32::int4 > 0 then 'Y' else 'N' end, array_to_string(mama.billpay, ', ')
203  from ${Cu}user u
204  left join (select ua.user_id, count(case when estmnt_flag = 'Y' then 1 else 0 end) as estmnt, array_agg(distinct billpayid::text) as billpay
205  from ${Cu}memberacct ma
206  inner join ${Cu}useraccounts ua on ma.accountnumber = ua.accountnumber
207  group by ua.user_id) mama on u.user_id = mama.user_id
208  left join (select distinct ua.user_id, cs.accountnumber
209  from cusms cs
210  inner join ${Cu}useraccounts ua on cs.accountnumber = ua.accountnumber and cs.cu = '$Cu') cscs on u.user_id = cscs.user_id",
211  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Address" => "string", "eStatement" => "boolean", "Opt-in List" => "boolean", "TXT Banking" => "boolean",
212  "Bill Pay" => "boolean", "Alt Bill Pay ID" => "string"),
213  // 6/11/2018: Bill Pay changed from number to string because with multiple values, it is a comma separated. Kendo doesn't display it.
214  // When it is a string, the sort is alphanumeric. (So 11 comes before 9.) Mark felt that display was better than the sort.
215  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
216  // 6
217  "memberLogin" => array("title" => "User Login and Locking Status", "dateFormat" => "yyyy/MM/dd",
218  "sql" => "select u.user_id, u.user_name, lastlogin, failedlogin, coalesce(failedremain, a.retrylimit), case when failedremain < 0 then 'By CU' when failedremain = 0
219  then 'Failed Logins' when (forcechange = 'Y' and forceremain < 1) then 'Expired Pwd' when (userflags & 2::int = 2) and forceremain <= 0 then 'Expired Security' else '' end,
220  case when forcechange = 'Y' or (userflags & 2::int = 2) then coalesce(forceremain, a.gracelimit)::char(2) else '' end, pwchange from {$Cu}user u, cuadmin a where a.cu = '$Cu' ",
221  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Last Login" => "date", "Last Failed" => "date", "Retries Remain" => "number",
222  "Locked" => "string", "Force Chg in" => "string", "Last Pwd Change" => "date"),
223  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
224  // 6.5
225  "lockedRpt" => array("title" => "Banking Locked Out Users",
226  "sql" => "select user_id, trim(user_name), case when forcechange = 'Y' and forceremain <= 0 then 'Expired password' when (userflags & 2::int=2) and forceremain <= 0 then 'Expired security'
227  when failedremain = 0 then 'Too many failed logins' when failedremain < 0 then 'Locked by Credit Union' end, lastlogin, failedlogin from ${Cu}user
228  where (failedremain <= 0 or (forcechange = 'Y' and forceremain <= 0) or ((userflags & 2::int = 2) and forceremain <= 0)) ",
229  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Reason" => "string", "Last Login" => "string", "Last Failed" => "string"),
230  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
231  // 7
232  "crossMonth" => array("title" => "Cross Transfer Transaction Report by Month", "dateFormat" => "yyyy-MM",
233  "sql" => "with temp1 as (select hdr.id, date_trunc('month', hdr.effective_date) as month, dtl.transdata::json->>'acct_source' as source, dtl.transdata::json->>'acct_dest' as dest
234  from ${Cu}transhdr hdr inner join ${Cu}transdtl dtl on hdr.id = dtl.transhdr_id where hdr.accountnumber is not null and hdr.feature_code = 'TRN'),
235  temp2 as (select t1.id, t1.month, sum(case when t1.source <> '' and t1.dest <> '' and split_part(t1.source, '|', 2) = split_part(t1.dest, '|', 2) then 1 else 0 end) as same,
236  sum(case when t1.source <> '' and t1.dest <> '' and split_part(t1.source, '|', 2) = split_part(t1.dest, '|', 2) then 0 else 1 end) as diff from temp1 t1 group by 1,2)
237  select t2.month, count(*), sum(case when t2.diff > 0 then 1 else 0 end), sum(case when t2.same > 0 then 1 else 0 end) from temp2 t2 group by 1",
238  "cols" => array("Month" => "date", "Total" => "number", "Cross Transfer" => "number", "Same Account" => "number")),
239  // 8
240  "crossDay" => array("title" => "Cross Transfer Transaction Report by Day", "dateFormat" => "yyyy-MM-dd",
241  "sql" => "with temp1 as (select hdr.id, date_trunc('day', hdr.effective_date) as month, dtl.transdata::json->>'acct_source' as source, dtl.transdata::json->>'acct_dest' as dest
242  from ${Cu}transhdr hdr inner join ${Cu}transdtl dtl on hdr.id = dtl.transhdr_id where hdr.accountnumber is not null and hdr.feature_code = 'TRN'),
243  temp2 as (select t1.id, t1.month, sum(case when t1.source <> '' and t1.dest <> '' and split_part(t1.source, '|', 2) = split_part(t1.dest, '|', 2) then 1 else 0 end) as same,
244  sum(case when t1.source <> '' and t1.dest <> '' and split_part(t1.source, '|', 2) = split_part(t1.dest, '|', 2) then 0 else 1 end) as diff from temp1 t1 group by 1,2)
245  select t2.month, count(*), sum(case when t2.diff > 0 then 1 else 0 end), sum(case when t2.same > 0 then 1 else 0 end) from temp2 t2 group by 1",
246  "cols" => array("Day" => "date", "Total" => "number", "Cross Transfer" => "number", "Same Account" => "number")),
247  // 9
248  "memberSummary" => array("title" => "User Summary",
249  "sql" => "select count(*), sum( case when (failedremain <= 0 or (forcechange = 'Y' and forceremain <= 0)) then 1 else 0 end), sum( case when egenl_flag = 'Y' then 1 else 0 end)
250  from ${Cu}user where passwd <> 'NULL PASSWORD'",
251  "cols" => array("Total Users" => "number", "Locked Out" => "number", "Opt-In" => "number")),
252  // 10
253  "billpaySettings" => array("title" => "User Bill Pay ID Settings", "reportTitle" => "User Bill Pay ID Settings",
254  "sql" => "select ma.accountnumber, ma.estmnt_flag, ma.billpayid, u.email from ${Cu}memberacct ma inner join ${Cu}user u on ma.primary_user = u.user_id",
255  "cols" => array("Account" => "unencoded", "Bill Pay" => "boolean", "Alt Bill Pay ID" => "number", "Primary Account Email" => "string"),
256  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "Account", "type" => "account"))),
257  // 11
258  "avgTransDay" => array("title" => "Average Transactions per Day", "reportTitle" => "Average Transactions per Day", "dateFormat" => "yyyy-MM-dd",
259  "sql" => "with temp1 as (select min(effective_date)::timestamp, now()::timestamp, age(min(effective_date)), count(*) as count
260  from ${Cu}transhdr where effective_date is not null),
261  temp2 as (select date_part('day', t1.now - t1.min) as num, t1.min, t1.age, t1.count from temp1 t1)
262  select coalesce(nullif(t2.num, 0), 1), t2.min, t2.count, case when t2.num = 0 then t2.count else t2.count / t2.num end from temp2 t2",
263  "cols" => array("Days" => "number", "Since" => "date", "Transactions" => "number", "Daily Average" => "decimal")),
264  // 12
265  "avgTransMonth" => array("title" => "Average Transactions per Month", "reportTitle" => "Average Transactions per Month", "dateFormat" => "yyyy-MM-dd",
266  "sql" => "with temp1 as (select min(effective_date) as min, age(min(effective_date)) as age, count(*) as count from ${Cu}transhdr where effective_date is not null),
267  temp2 as (select date_part('year', t1.age) * 12 + date_part('month', t1.age) + (case when date_part('day', t1.age) > 15 then 1 else 0 end) as num, t1.min, t1.age, t1.count from temp1 t1)
268  select coalesce(nullif(t2.num, 0), 1), t2.min, t2.count, case when t2.num = 0 then t2.count else t2.count::float / t2.num end from temp2 t2",
269  "cols" => array("Months" => "number", "Since" => "date", "Transactions" => "number", "Monthly Average" => "decimal")),
270  // 13
271  "repeatingSummary" => array("title" => "Repeating Transaction Summary", "reportTitle" => "Repeating Transaction Summary",
272  "sql" => "with trans as (select user_id, feature_code, txn_data::json->'txn'->>'transactioncode' as transactioncode, txn_data from cu_scheduledtxn where cu = '$Cu'
273  and feature_code in ('TRN', 'ACHCOL', 'ACHPMT', 'TRNM2M', 'TRNEXT')),
274  expanded as (select user_id, feature_code, transactioncode, txn_data::json->'txn'->>'from' as accountnumber,
275  txn_data::json->'txn'->>'to' as tomember from trans where feature_code = 'TRN'
276  union all select user_id, feature_code, transactioncode, split_part(txn_data::json->'txn'->>'from', '|', 2), txn_data::json->'txn'->>'to'
277  from trans where feature_code in ('TRNM2M', 'TRNEXT', 'ACHCOL', 'ACHPMT') and txn_data::json->'txn'->>'type' in ('L2M', 'L2X', 'L2R')
278  union all select user_id, feature_code, transactioncode, txn_data::json->'txn'->>'from', split_part(txn_data::json->'txn'->>'to', '|', 2)
279  from trans where feature_code in ('TRNM2M', 'TRNEXT', 'ACHCOL', 'ACHPMT') and txn_data::json->'txn'->>'type' in ('X2M', 'X2L', 'R2L'))
280  select count(distinct user_id), count(distinct accountnumber), count(*), coalesce(sum(case when transactioncode = 'AT' then 1 else 0 end), 0),
281  coalesce(sum(case when transactioncode = 'LP' then 1 else 0 end), 0), coalesce(sum(case when accountnumber = tomember then 1 else 0 end), 0),
282  coalesce(sum(case when accountnumber <> tomember then 1 else 0 end), 0) from expanded",
283  "cols" => array("Users" => "number", "Accounts" => "number", "Transactions" => "number", "Transfer" => "number", "Payment" => "number", "Same Account" => "number",
284  "Cross Account" => "number")),
285  // 14
286  "loginFailure" => array("title" => "User Login Failure Status",
287  "sql" => "select u.user_id, trim(u.user_name), lastlogin, failedlogin, case when coalesce(userflags, 0) & 4::int4 = 4 then 'Email'
288  when coalesce(userflags, 0) & 8::int4 = 8 then 'Challenge' when coalesce(userflags, 0) & 16::int4 = 16 then 'Password' when coalesce(userflags, 0) & 64::int4 = 64 then 'Username'
289  else '' end from {$Cu}user u, cuadmin a where a.cu = '$Cu' and coalesce(userflags,0) & 92::int4 > 0",
290  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Last Login" => "date", "Last Failed" => "date", "Reason" => "string"),
291  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
292  // 15
293  "memAlerts" => array("title" => "User Alerts Detail",
294  "sql" => "select u.user_id, trim(u.user_name), coalesce(sq_bal_join.bal_count, 0), coalesce(sq_chk_join.chk_count, 0), coalesce(sq_loan_join.ln_count, 0),
295  coalesce(sq_trnx_join.trnx_count, 0) from ${Cu}user u
296  left join (select accountnumber, count(*) as bal_count FROM cu_alerts WHERE cu = '$Cu' AND (alerttype = 'B') group by accountnumber) as sq_bal_join
297  on u.user_name = sq_bal_join.accountnumber
298  left join (select accountnumber, count(*) as chk_count FROM cu_alerts WHERE cu = '$Cu' AND (alerttype = 'C') group by accountnumber) as sq_chk_join
299  on u.user_name = sq_chk_join.accountnumber
300  left join (select accountnumber, count(*) as ln_count FROM cu_alerts WHERE cu = '$Cu' AND (alerttype = 'L') group by accountnumber) as sq_loan_join
301  on u.user_name = sq_loan_join.accountnumber
302  left join (select accountnumber, count(*) as trnx_count FROM cu_alerts WHERE cu = '$Cu' AND (alerttype = 'T') group by accountnumber) as sq_trnx_join
303  on u.user_name = sq_trnx_join.accountnumber
304  where (coalesce(sq_bal_join.bal_count, 0) > 0 or coalesce(sq_chk_join.chk_count, 0) > 0 or coalesce(sq_loan_join.ln_count, 0) > 0 or coalesce(sq_trnx_join.trnx_count, 0) > 0) ",
305  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Balance Alerts" => "number", "Check Alerts" => "number", "Missed Payment Alerts" => "number", "Transaction Alerts" => "number"),
306  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
307  // 18
308  "memLastLoginBatch" => array("title" => "User Last Login", "dateFormat" => "yyyy/MM/dd HH:mm:ss",
309  "sql" => "select user_id, trim(user_name), lastlogin, case when lastlogin is null or trim(lastlogin) = '' then null else ((extract(epoch from current_timestamp) -
310  extract(epoch from substring(lastlogin, 1, 19)::timestamp)) / 86400)::integer end, substring(yearmo from 1 for 4) || '/' || substring(yearmo from 5 for 2), email from ${Cu}user u
311  left join (select accountnumber, max(yearmo) as yearmo from cusmstrack where cu = '$Cu' group by accountnumber) as s on s.accountnumber = u.user_name where passwd <> 'NULL PASSWORD'",
312  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Last Login" => "date", "Elapsed Days" => "number", "Last TXT" => "string", "Email" => "string"),
313  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
314  // 19
315  "lastLogin2" => array("title" => "User Last Login Summary",
316  "sql" => "select count(*), sum(case when trim(lastlogin) > '' and trim(lastlogin)::timestamp > now() - '30 days'::interval then 1 else 0 end),
317  sum(case when trim(lastlogin) > '' and trim(lastlogin)::timestamp > now() - '60 days'::interval and trim(lastlogin)::timestamp < now() - '30 days'::interval then 1 else 0 end),
318  sum(case when trim(lastlogin) > '' and trim(lastlogin)::timestamp > now() - '90 days'::interval and trim(lastlogin)::timestamp < now() - '60 days'::interval then 1 else 0 end),
319  sum(case when trim(lastlogin) > '' and trim(lastlogin)::timestamp > now() - '120 days'::interval and trim(lastlogin)::timestamp < now() - '90 days'::interval then 1 else 0 end),
320  sum(case when trim(lastlogin) > '' and trim(lastlogin)::timestamp < now() - '120 days'::interval then 1 else 0 end),
321  sum(case when lastlogin is null or trim(lastlogin) = '' then 1 else 0 end) from ${Cu}user where passwd <> 'NULL PASSWORD'",
322  "cols" => array("Users" => "number", "Current" => "number", "30+ Days" => "number", "60+ Days" => "number", "90+ Days" => "number", "120+ Days" => "number", "Never" => "number")),
323  // 20
324  "totalMemAlerts" => array("title" => "Total User Alerts Summary", "reportTitle" => "$prodname Total User Alerts Summary",
325  "sql" => "select sum(b), sum(c), sum(l), sum(t) from (select count(*) as b, 0 as t, 0 as c, 0 as l from cu_alerts where cu = '$Cu' and (alerttype = 'B')
326  union all select 0, count(*), 0, 0 from cu_alerts where cu = '$Cu' and (alerttype = 'T') union all select 0, 0, count(*), 0 from cu_alerts where cu = '$Cu' and (alerttype = 'C')
327  union all select 0, 0, 0, count(*) from cu_alerts where cu = '$Cu' AND (alerttype = 'L')) as summary ",
328  "cols" => array("Balance" => "string", "Check.0" => "number", "Missed Payment" => "number", "Transaction" => "number")),
329  // 23
330 
331  // 1st account number, 2nd email member account primaryuser to userid; estmnt boolean;
332  "estatementMems" => array("title" => "eStatement Users",
333  "sql" => "select m.accountnumber, u.user_id, u.user_name, coalesce(trim(u.email), ''), m.estmnt_flag from ${Cu}memberacct m
334  left join ${Cu}user u on m.primary_user = u.user_id",
335  "cols" => array("Account" => "unencoded", "User Id" => "ignore", "User Name" => "unencoded", "Address" => "string", "eStatement" => "string"),
336  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"),
337  array("name" => "Account", "type" => "account"))),
338  // 26
339  "repeatingTrans" => array("title" => "Repeating Transactions", "dateFormat" => "MM/dd/yyyy", "nonSqlDataFunc" => "GetRepeatingTransReport",
340  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "From.User" => "string", "To.User" => "string", "Type" => "string", "Amount" => "currency", "Next Date" => "date",
341  "Frequency" => "list", "Start Date" => "date", "Stop Date" => "date"),
342  "ddl" => array(
343  "Frequency" => array("OneTime" => "One Time", "FirstDayOfMonth" => "First Day of the Month", "LastDayOfMonth" => "Last Day of the Month",
344  "FirstFifteenth" => "First and Fifteenth Day of the Month", "FifteenthLast" => "Fifteenth and Last Day of the Month", "Weekly" => "Weekly", "BiWeekly" => "Bi-weekly",
345  "WeekDayOfMonth" => "Weekday of the Month - (e.g. 3rd Thursday)", "Monthly" => "Monthly", "Every2Months" => "Every 2 Months", "Quarterly" => "Quarterly",
346  "SemiAnnually" => "Semi-Annually", "Annually" => "Annually"))
347  ),
348  // 27
349  "memAlertsSuffix" => array("title" => "User Alerts Suffix Detail", "dateFormat" => "yyyy/MM/dd",
350  "sql" => "select distinct trim(accountnumber)::bigint, accounttype, 'Balance'::char(18), lastalert, ('Less than '::varchar || to_char(notifyamt, '999999999D99'))::varchar, notifymsg
351  from cu_alerts where cu = '$Cu' and (alerttype = 'B') union all select distinct trim(accountnumber)::bigint, accounttype, case when notifytranstype = 'D' then 'Transaction (Dep)'
352  when notifytranstype = 'W' then 'Transaction (With)' else 'Transaction (All)' end, lastalert, case when notifyrange = '1'
353  then ('Contains ' || notifydesc || ' and $ between ' || trim(to_char(notifyamtmin, '999999D99')) || ' - ' || trim(to_char(notifyamtmax, '999999D99')))::varchar
354  else ('Contains ' || notifydesc)::varchar end, notifymsg from cu_alerts where cu = '$Cu' and (alerttype = 'T')
355  union all select distinct trim(accountnumber)::bigint, accounttype, 'Check'::char(18), lastalert, 'Check ' || notifychknum, notifymsg
356  from cu_alerts where cu = '$Cu' and (alerttype = 'C') union all select distinct trim(accountnumber)::bigint, accounttype, 'Missed Pmt'::char(18),
357  lastalert, ('Days prior ' || to_char(notifyloandaysprior,'99'))::varchar, notifymsg from cu_alerts where cu = '$Cu' AND (alerttype = 'L') ",
358  "cols" => array("Account" => "unencoded", "Suffix" => "string", "Alert Type" => "string", "Last Alert" => "datetime", "When.0" => "string", "Message" => "string"),
359  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "Account", "type" => "account"))),
360  // 30
361  "sincePasswordChange" => array("title" => "Days Since User Password Change Summary",
362  "sql" => "select count(*), sum(case when pwchange is not null and pwchange::timestamp > now() - '30 days'::interval then 1 else 0 end),
363  sum(case when pwchange is not null and pwchange::timestamp > now() - '60 days'::interval and pwchange::timestamp < now() - '30 days'::interval then 1 else 0 end),
364  sum(case when pwchange is not null and pwchange::timestamp > now() - '90 days'::interval and pwchange::timestamp < now() - '60 days'::interval then 1 else 0 end),
365  sum(case when pwchange is not null and pwchange::timestamp > now() - '120 days'::interval and pwchange::timestamp < now() - '90 days'::interval then 1 else 0 end),
366  sum(case when pwchange is not null and pwchange::timestamp > now() - '180 days'::interval and pwchange::timestamp < now() - '120 days'::interval then 1 else 0 end),
367  sum(case when pwchange is not null and pwchange::timestamp > now() - '1 year'::interval and pwchange::timestamp < now() - '180 days'::interval then 1 else 0 end),
368  sum(case when pwchange is not null and pwchange::timestamp > now() - '2 years'::interval and pwchange::timestamp < now() - '1 year'::interval then 1 else 0 end),
369  sum(case when pwchange is not null and pwchange::timestamp < now() - '2 years'::interval then 1 else 0 end) from ${Cu}user where passwd <> 'NULL PASSWORD' ",
370  "cols" => array("Users" => "number", "<30" => "number", "30-60" => "number", "60-90" => "number", "90-120" => "number", "120-180" => "number", "120-180" => "number",
371  "180-1 Yr" => "number","1-2 Yrs" => "number", "2+ Yrs" => "number")),
372  // 35
373  "memEmail" => array("title" => "User Email / Password Changes", "dateFormat" => "yyyy-MM-dd HH:mm:ss",
374  "sql" => "select u.user_id, u.user_name, a.auditdate, a.prevemail, a.afteremail, case when a.prevpass <> a.afterpass and a.prevpass <> '' and a.afterpass <> '' then 'Changed' else '' end,
375  a.name from (select trim(auditsrcuser_name) || (case when auditsrctype = 'A' then ' (admin user)' else '' end) as name,
376  auditdate, user_id, coalesce(auditrecbefore::json->'user'->0->>'passwd', '') as prevpass,
377  coalesce(auditrecafter::json->'user'->0->>'passwd', '') as afterpass, coalesce(auditrecbefore::json->'user'->0->>'email', '') as prevemail,
378  coalesce(auditrecafter::json->'user'->0->>'email', '') as afteremail
379  from ${Cu}audituser where trim(auditrecbefore) like '{\"user\":[{\"%' and trim(auditrecafter) like '{\"user\":[{\"%') a
380  inner join ${Cu}user u on a.user_id = u.user_id
381  where (a.prevpass <> a.afterpass and a.prevpass <> '' and a.afterpass <> '') or (a.prevemail <> a.afteremail and a.prevemail <> '' and a.afteremail <> '')",
382  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Date" => "datetime", "Old Email" => "string", "New Email" => "string", "Password" => "string", "Changed By" => "string"),
383  "dataFunc" => "addLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
384  // 36
385  // thought it'd be cool to include whether or not the member used the service but then what do you do with folks who used it in the past and now have
386  // a zero limit? (if turning this back on, change max orderby to 3)
387  "onlineDepositSettings" => array("title" => "Remote Deposit Settings", "reportTitle" => "$prodname Online Deposit Settings",
388  "sql" => "select accountnumber, rdcsetting from ${Cu}memberacct where rdcsetting > 0",
389  "cols" => array("Member" => "unencoded", "Deposit Limit" => "number"),
390  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "Member", "type" => "account"))),
391  // 37
392  // TODO: might have to put the Last Packet back in. There is currently no equivalent column in Odyssey.
393  "memLastLoginLive" => array("title" => "User Last Login", "dateFormat" => "yyyy/MM/dd",
394  "sql" => "select user_id, trim(user_name), lastlogin, case when lastlogin is null or trim(lastlogin) = '' then null else ((extract(epoch from current_timestamp) -
395  extract(epoch from substring(lastlogin,1,19)::timestamp)) / 86400)::integer end, substring(yearmo from 1 for 4) || '/' || substring(yearmo from 5 for 2), email from ${Cu}user u
396  left join (select accountnumber, max(yearmo) as yearmo from cusmstrack where cu = '$Cu' group by accountnumber) as s on s.accountnumber = u.user_name where passwd <> 'NULL PASSWORD'",
397  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Last Login" => "date", "Elapsed Days" => "number", "Last TXT" => "string", "Email" => "string"),
398  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
399  // 38
400  "emailInitialized" => array("title" => "User Email Initialized Date", "dateFormat" => "yyyy-MM-dd HH:mm:ss",
401  "sql" => "select u.user_id, trim(u.user_name), a.auditdate, a.email
402  from (select row_number() over (partition by user_id order by auditdate) as rown, auditdate, auditrecafter::json->'user'->0->>'email' as email, user_id
403  from ${Cu}audituser where auditaction = 'U_ADD') as a
404  inner join ${Cu}user u on a.user_id = u.user_id and a.rown = 1",
405  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Date" => "datetime", "New Email" => "string"),
406  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
407  // 39
408  "estatementStatus" => array("title" => "eStatement Status Changes", "dateFormat" => "yyyy-MM-dd HH:mm:ss",
409  "sql" => "select accountnumber, auditdate, auditrecbefore::json->'memberacct'->0->>'estmnt_flag', auditrecafter::json->'memberacct'->0->>'estmnt_flag',
410  trim(auditsrcuser_name) || (case when auditsrctype = 'A' then ' (admin user)' else '' end) from ${Cu}audituser
411  where trim(auditrecbefore) like '{\"memberacct\":[{\"%' and trim(auditrecafter) like '{\"memberacct\":[{\"%' and auditrecbefore::json->'memberacct'->0->>'estmnt_flag' is not null
412  and auditrecafter::json->'memberacct'->0->>'estmnt_flag' is not null and auditrecbefore::json->'memberacct'->0->>'estmnt_flag' <> auditrecafter::json->'memberacct'->0->>'estmnt_flag'",
413  "cols" => array("Account" => "number", "Date" => "datetime", "Old Stmt" => "string", "New Stmt" => "string", "Changed By" => "string"),
414  "defaultSort" => array(array("field" => "date", "dir" => "desc"))),
415  // 40
416  "cuDismissible" => array("title" => "CU-Dismissible Promo Exclusions",
417  "sql" => "select user_id, trim(user_name), 'Y' from ${Cu}user where coalesce(msg_tx, 0) & 8192::int4 > 0 ",
418  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Excluded" => "string"),
419  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
420  // 41
421  "deepTarget" => array("title" => "Deep Target Data",
422  "sql" => "select ma.accountnumber, case when coalesce(u.userflags, 0) & 32::int4 > 0 then 'Y' else 'N' end, ma.estmnt_flag,
423  case when exists(select * from cusms where accountnumber = user_name and cu = '$Cu') then 'Y' else 'N' end from ${Cu}memberacct ma inner join ${Cu}user u on ma.primary_user = u.user_id
424  left join cusms s on u.user_id = s.user_id",
425  "cols" => array("Account" => "unencoded", "Bill Pay" => "boolean", "eStatement" => "boolean", "TXT Banking" => "boolean"),
426  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "Account", "type" => "account"))),
427  // 43
428  "overrideMcir" => array("title" => "Override MICR",
429  "sql" => "select trim(om.accountnumber), trim( ab.description) || '-' || trim(om.accounttype), nullif(trim(ab.micraccount), ''), nullif(trim(om.rt), ''),
430  nullif(trim(om.micraccount), ''), om.startcheck
431  from cuovermicr om join {$Cu}accountbalance ab on (om.accountnumber = ab.accountnumber and om.accounttype = ab.accounttype) where cu = '$Cu' ",
432  "cols" => array("Account" => "unencoded", "Description" => "string", "Uploaded" => "number", "R/T" => "number", "Override" => "number", "Start Check" => "number"),
433  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "Account", "type" => "account"))),
434  // 44
435  "termsAccepted" => array("title" => "Terms Accepted", "dateFormat" => "yyyy-MM-dd",
436  "sql" => "select trim(r.accountnumber), r.user_id, u.user_name, d.docsdesc, r.responseon
437  from cucmsdocs d
438  inner join cucmsresponse r on d.docsid = r.docsid and d.docsresponsetype = 'T' and r.cu = '$Cu'
439  left join ${Cu}user u on r.user_id = u.user_id",
440  "cols" => array("Account" => "unencoded", "User Id" => "ignore", "User Name" => "unencoded", "Document" => "string", "Date" => "date"),
441  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "Account", "type" => "account"), array("name" => "User Name", "id" => "User Id", "type" => "user"))),
442  // 45
443  "emailChangedDate" => array("title" => "Email Changed Date", "dateFormat" => "yyyy-MM-dd HH:mm:ss",
444  "sql" => "select u.user_id, u.user_name, a.auditdate, a.afteremail, a.auditsrcuser_name
445  from (select auditsrcuser_name, auditdate, user_id, coalesce(auditrecbefore::json->'user'->0->>'email', '') as prevemail,
446  coalesce(auditrecafter::json->'user'->0->>'email', '') as afteremail
447  from ${Cu}audituser where trim(auditrecbefore) like '{\"user\":[{\"%' and trim(auditrecafter) like '{\"user\":[{\"%') a inner join ${Cu}user u on a.user_id = u.user_id
448  where a.prevemail <> a.afteremail and a.prevemail <> '' and a.afteremail <> ''",
449  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Date" => "datetime", "New Email" => "string", "Changed By" => "string"),
450  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
451  // 53
452  "androidHistory" => array("title" => "Android Monthly User Login History",
453  "sql" => $monthPivot->GetCTE() . ", " . GetLoginCTE($Cu) . " select user_id, user_name, " . $monthPivot->GetColumnSQL("logins") . " from loginsCTE a, monthPivotCTE p
454  where a.yearmo = p.yearmo and hcucode = 'ADA' group by user_id, user_name",
455  "cols" => $monthPivot->GetColumns(array("User Id" => "ignore", "User Name" => "unencoded")),
456  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
457  // 54
458  "androidSummary" => array("title" => "Android Monthly Login Summary", "dateFormat" => "yyyy-MM",
459  "sql" => "with " . GetLoginCTE($Cu) . " select substr(yearmo, 1, 4) || '-' || substr(yearmo, 5, 2) || '-01', sum(logins), count(*), to_char(avg(logins), '999.9')::decimal from loginsCTE
460  where hcucode = 'ADA' group by 1 ",
461  "cols" => array("Month" => "date", "Logins" => "number", "Members" => "number", "Average / Active User" => "number")),
462  // 55
463  "iphoneHistory" => array("title" => "iPhone User Login History",
464  "sql" => $monthPivot->GetCTE() . ", " . GetLoginCTE($Cu) . " select user_id, user_name, " . $monthPivot->GetColumnSQL("logins") . " from loginsCTE a, monthPivotCTE p
465  where a.yearmo = p.yearmo and hcucode = 'APP' group by user_id, user_name",
466  "cols" => $monthPivot->GetColumns(array("User Id" => "ignore", "User Name" => "unencoded")),
467  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
468  // 56
469  "iphoneSummary" => array("title" => "iPhone Monthly Login Summary", "dateFormat" => "yyyy-MM",
470  "sql" => "with " . GetLoginCTE($Cu) . " select substr(yearmo,1,4) || '-' || substr(yearmo,5,2) || '-01', sum(logins), count(*), to_char(avg(logins), '999.9')::decimal
471  from loginsCTE where hcucode = 'APP' group by 1 ",
472  "cols" => array("Month" => "date", "Logins" => "number", "Members" => "number", "Average / Active User" => "number")),
473  "eStatementHistory" => array("title" => "eStatement Processing History", "nonSqlDataFunc" => "GetEstatementHistory", "dateFormat" => "MM/dd/yyyy HH:mm:ss",
474  "cols" => array("Count" => "number", "Load Date" => "date", "Period" => "string", "Past Cutoff" => "boolean")),
475  "orphanedRecords" => array("title" => "Orphaned Users, Accounts, and Groups",
476  "sql" => "select type_id, type, id, name, details from (
477  select 0 as type_id, 'Users Without Member Accounts' as type, u.user_id::varchar as id, u.user_name as name, u.name as details
478  from ${Cu}user u
479  left join ${Cu}useraccounts ua on u.user_id = ua.user_id
480  left join ${Cu}memberacctrights mar on mar.user_id=u.user_id and mar.whichright='ACCESS'
481  where ua.user_id is null and (mar.allowed is null or mar.allowed is false)
482  union all
483  select 1, 'Users Without Groups', u.user_id::varchar, u.user_name, u.name from ${Cu}user u
484  left join ${Cu}group g on u.group_id = g.group_id where g.group_id is null
485  union all
486  select 2, 'Member Accounts Without Users', a.accountnumber, a.accountnumber, null
487  from ${Cu}memberacct a
488  left join ${Cu}useraccounts ua on a.accountnumber = ua.accountnumber
489  left join ${Cu}memberacctrights mar on mar.accountnumber=a.accountnumber and mar.whichright='ACCESS'
490  where ua.accountnumber is null and (mar.allowed is null or mar.allowed is false)
491  union all
492  select 3, 'Groups Without Users', g.group_id::varchar, g.group_name, p.description from ${Cu}group g left join ${Cu}user u on g.group_id = u.group_id
493  left join cu_profile p on g.profile_id = p.profile_id where u.user_id is null) temp",
494  "cols" => array("type id" => "hidden", "Type" => "ignore", "id" => "ignore", "Name" => "unencoded", "&nbsp;" => "string"),
495  "showOnCSV" => array("type id" => false, "Type" => true),
496  "groupBy" => "type id", "groupDescription" => "Type", "groupForceSortBy" => true, "dataFunc" => "addLinksToOrphanedRecordsReport"),
497  "profile" => array("title" => "Banking Profiles", "groupBy" => "Profile Code", "groupDescription" => "Profile Description",
498  "sql" => "select p.profile_code, p.description, f.description, f.limit_type, "
499  . GetProfileCol("pr.amount_per_transaction") . ", ". GetProfileCol("pr.amount_per_day") . ", "
500  . GetProfileCol("pr.amount_per_month") . ", ". GetProfileCol("pr.amount_per_account_per_day") . ", ". GetProfileCol("pr.count_per_day", false) . ", "
501  . GetProfileCol("pr.count_per_month", false) . ", ". GetProfileCol("pr.count_per_account_per_day", false) . ", pr.confirm_required from cu_profile p
502  left join cu_profilerights pr on p.profile_id = pr.profile_id
503  left join cu_feature f on pr.feature_code = f.feature_code
504  where p.cu = '$Cu' and f.enabled",
505  "cols" => array("Profile Code" => "hidden", "Profile Description" => "ignore", "Feature" => "description", "Type" => "list", "/ Trans" => "currency",
506  "/ Day.1" => "currency", "/ Month.1" => "currency", "/ Account / Day.1" => "currency",
507  "/ Day.2" => "number", "/ Month.2" => "number", "/ Account / Day.2" => "number", "Confirm" => "boolean"),
508  "colHeader" => array(".1" => "Type", "Amount" => "/ Account / Day.1", "Count" => "/ Account / Day.2", ".2" => "Confirm"),
509  "ddl" => array("Type" => array("A" => "Access", "B" => "Dollar / Qty", "Q" => "Qty", "D" => "Dollar"))),
510  "group" => array("title" => "User Groups", "groupBy" => "Group Id", "groupDescription" => "Group Title",
511  "sql" => "
512  select concat(g.group_name, ' - ', p.description), g.group_id, p.description, u.user_id, coalesce(u.user_name, '(No Users)'), u.is_group_primary
513  from ${Cu}group g
514  left join ${Cu}user u
515  on u.group_id = g.group_id
516  left join cu_profile p
517  on p.profile_id = g.profile_id",
518  "cols" => array("Group Title" => "hidden", "Group Id" => "hidden", "Profile" => "hidden", "User Id" => "ignore",
519  "User Name" => "unencoded", "Primary Owner" => "boolean"),
520  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
521  "lockedMembers" => array("title" => "Restricted Member Accounts",
522  "sql" => "select m.accountnumber, t.text from ${Cu}memberacct m
523  inner join (values ('Locked', 'L'), ('Read Only', 'R')) as t (text, value) on m.restrictions = t.value",
524  "cols" => array("Account" => "unencoded", "Status" => "string"),
525  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "Account", "type" => "account"))),
526  "pendingMicroDeposits" => array("title" => "Pending and Locked Micro Deposits", "groupBy" => "Type",
527  "sql" => "with expanded as (select remote_info::json->'rdfi'->>'routing' as routing, remote_info::json->'rdfi'->>'account' as account,
528  remote_info::json->'rdfi'->>'type' as type, remote_info::json->'verify'->>'pending_date' as pending_date, remote_info::json->'verify'->>'tries' as tries,
529  remote_info::json->'verify'->>'verified' as verified, display_name, status, id, user_id from ${Cu}extaccount)
530  select t.user_id, u.user_name, t.type, t.display_name, t.account_info, to_timestamp(t.pending_date::numeric)
531  from (select e.user_id::int, 'Locked' as type, e.display_name, e.routing || ' / ' || e.account as account_info, e.pending_date from expanded e
532  where e.status = 'l' and e.tries is not null and e.tries::int = 0 and e.verified is null
533  union all select f.user_id::int, 'Pending', f.display_name, f.routing || ' / ' || f.account, f.pending_date
534  from expanded f where f.status = 'p' and f.tries is not null and f.tries::int > 0 and f.verified is null) t inner join ${Cu}user u on t.user_id = u.user_id",
535  "cols" => array("User Id" => "ignore", "User Name" => "unencoded", "Type" => "hidden", "Name" => "string", "Account Info" => "string", "Pending Date" => "datetime"),
536  "dataFunc" => "AddLinks", "dataFuncVariables" => array(array("name" => "User Name", "id" => "User Id", "type" => "user"))),
537 
538  "extAcctReport" => array(
539  "title" => "External Account Status",
540  "sql" => "select userName, Status, displayName, account, Routing, pendingDate from (
541  with expanded as (
542  select user_id, display_name,
543  case
544  when status = '0' then 'Pending (0)'
545  when status = 'p' then 'Pending'
546  when status = 'a' then 'Available'
547  when status = 'l' then 'Locked'
548  when status = 'i' then 'Inactive'
549  else 'Unknown'
550  end
551  as status,
552  remote_info::json->'rdfi'->>'routing' as routing,
553  remote_info::json->'rdfi'->>'account' as account,
554  remote_info::json->'verify'->>'pending_date' as pending_date
555  from {$Cu}extaccount
556  where type='EXT'
557  )
558  select
559  u.user_name as userName,
560  t.status as Status,
561  t.display_name as displayName,
562  t.account as account,
563  t.routing as Routing,
564  to_timestamp(t.pending_date::numeric) as pendingDate
565  from (select * from expanded e) t
566  inner join {$Cu}user u on t.user_id = u.user_id
567  ) t
568  limit 30 offset 0",
569  "cols" => array (
570  "User Name" => "string",
571  "Status" => "string",
572  "Display Name" => "string",
573  "Account" => "string",
574  "Routing Number" => "string",
575  "Pending Date" => "datetime"
576  )
577  )
578  );
579 
580  return $reportList;
581 }
582 
583 
584 /**
585  * function GetEstatementHistory($Cu, $dbh)
586  * This gets the estatement history from the summary.log file.
587  *
588  * @param $Cu -- the credit union
589  * @param $dbh -- the database connection. This is not used in this callback but it is part of the parameter signature for #1315's benefit.
590  * @return array of sample data to function exactly like if data came from a SQL.
591  */
592 function GetEstatementHistory($Cu, $dbh) {
593  $returnArray = array();
594 
595  try {
596  $SMRYLOG = "/home/" . strtolower($Cu) . "/stmnt/summary.log";
597  $date = (strftime ("%Y:%m:%d", time()));
598  list($year, $month, $day) = explode(":",$date);
599  $timestamp = mktime (0,0,0,$month-25,$day,$year);
600  $CUTOFF = (strftime ("%Y%m%d", $timestamp));
601  $returnData = array();
602 
603  if (is_readable("$SMRYLOG")) {
604  $lines = file ($SMRYLOG);
605 
606  // Date is entered into summary file in Mountain Timezone. Change this to the date of the credit union.
607  $thisTz = "US/Mountain";
608  $cuTz = GetCreditUnionTimezone($dbh, $Cu);
609  $changeTz = false;
610 
611  if ($thisTz !== $cuTz) { // Don't need to do anything if it is already in mountain.
612  $changeTz = true;
613  $thisTz = new DateTimeZone($thisTz);
614  $cuTz = new DateTimeZone($cuTz);
615  }
616 
617  foreach ($lines as $line_num => $line) {
618  $f = explode("\t",$line);
619  $lddate = str_replace("-","",substr($f[0],0,10));
620  $ldcount = (intval($f[5]) - intval($f[6]));
621  $ldstat = $f[4];
622  $ldper = $f[8];
623 
624  if ( $ldcount != 0 && $ldstat != 'Abort') {
625 
626  $strDate = $f[0];
627  if ($changeTz) {
628  $date = DateTime::createFromFormat("Y-m-d H:i:s|", $strDate, $thisTz);
629 
630  if ($date === false) {
631  // Should never get here but if it does, do not break the page. Report the error.
632  global $logger;
633  $logger->error("Estatement report: date isn't valid: $strDate");
634  continue;
635  } else {
636  $date->setTimezone($cuTz);
637  $strDate = $date->format("Y-m-d H:i:s");
638  }
639  }
640 
641  $returnData [] = array($f[2], $f[3], $ldcount, $strDate, $ldper, $lddate >= $CUTOFF ? "Y" : "N");
642  }
643  }
644  }
645 
646  // Sort by the third column (period in YYYYMM) descending and then by the fourth column (period type indicator.)
647  usort($returnData, function($a, $b) {
648  $cmp = strcasecmp($b [0], $a [0]); // Third column descending
649  $cmp = $cmp != 0 ? $cmp : strcasecmp($b [1], $a [1]); // Fourth column descending
650 
651  return $cmp;
652  });
653 
654  // Remove the columns used to sort.
655  for($i = 0, $count = count($returnData); $i != $count; $i++) {
656  $row = $returnData[$i];
657  array_shift($row);
658  array_shift($row);
659  $returnData[$i] = $row;
660  }
661 
662  $returnArray = array("data" => $returnData, "status" => "000", "error" => "");
663  } catch (exception $e) {
664  $returnArray = array("data" => array(), "status" => $e->getCode(), "error" => $e->getMessage());
665  }
666 
667 
668  return $returnArray;
669 }
670 
671 /**
672  * function getLoginCTE($Cu)
673  * Get the SQL for getting the loginCTE that replaces the culogtrack table.
674  *
675  * @return SQL snippet for getting the login CTE.
676  */
677 function GetLoginCTE($Cu) {
678  return "loginsCTE as (select user_id, to_char(login_dt,'YYYYMM') as yearmo, hcucode, max(user_name) as user_name, count(*) as logins from {$Cu}userlogins where status = 0 group by 1,2,3)";
679 }
680 
681 /**
682  * function GetRepeatingTransReport($Cu, $dbh)
683  * Gets the repeating transaction report. Modified slightly from userSupportScheduledTransactions.prg.
684  *
685  * @param $Cu -- the credit union
686  * @param integer $dbh -- the database connection.
687  * @return array -- Array of the columns in the report. Instead of also calling "addLinks", add that to this function.
688  */
689 function GetRepeatingTransReport($Cu, $dbh) {
690  global $MC;
691  try {
692  $intervalList = TxIntervalList($MC);
693  $intervalLookup = array();
694  foreach($intervalList as $intervalRecord) {
695  $intervalLookup[$intervalRecord["value"]] = $intervalRecord["text"];
696  }
697  unset($intervalList);
698 
699  // Then once the list of the accounts is retrieved from the primary user, then the rights for the subaccounts are retrieved from the actual user.
700  $subqueryTXN = "select ss.*, case when strpos(ss.fromkey, '|') = 0 then cast(ss.fromkey as text)
701  when coalesce(strpos(ss.fromsuffix, '@'), '0') <> 0 then trim(split_part(fromkey, '|', 1)) || trim(ss.frommember)
702  || trim(ss.fromsuffix) || coalesce(nullif(split_part(fromkey, '|', 4), ''), '0')
703  else trim(split_part(fromkey, '|', 1)) || trim(split_part(fromkey, '|', 2)) || trim(split_part(fromkey, '|', 3)) || coalesce(nullif(split_part(fromkey, '|', 4), ''), '0') end
704  as fromkeya,
705  case when strpos(ss.tokey, '|') = 0 then cast(ss.tokey as text)
706  when coalesce(strpos(ss.tosuffix, '@'), 0) <> 0 then trim(split_part(tokey, '|', 1)) || trim(ss.tomember) || trim(ss.tosuffix) || coalesce(nullif(split_part(tokey, '|', 4), ''), '0')
707  when ss.code not in ('XA', 'XP') then trim(split_part(tokey, '|', 1)) || trim(split_part(tokey, '|', 2)) || trim(split_part(tokey, '|', 3)) ||
708  coalesce(nullif(split_part(tokey, '|', 4), ''), '0')
709  else case when trim(split_part(tokey, '|', 1)) = 'D' then 'T' else 'P' end || frommember || trim(split_part(tokey, '|', 3)) || '#' || trim(split_part(tokey, '|', 2))
710  || coalesce(nullif(split_part(tokey, '|', 4), ''), '0') end as tokeya
711  from (
712  select s.id, u.user_name, s.feature_code, f.description as feature, s.create_date, s.start_date, s.end_date, s.next_trigger_date, s.status, s.repeating_parameters, s.txn_data,
713  s.failure_count,
714  replace(s.txn_data::json->'txn'->>'from', 'C|', 'L|') as fromkey,
715  replace(s.txn_data::json->'txn'->>'to', 'C|', 'L|') as tokey,
716  s.txn_data::json->'txn'->>'transactioncode' as code,
717  s.txn_data::json->'txn'->>'frommember' as frommember, s.txn_data::json->'txn'->>'fromsuffix' as fromsuffix, s.txn_data::json->'txn'->>'tomember' as tomember,
718  s.txn_data::json->'txn'->>'tosuffix' as tosuffix, s.user_id, s.txn_data::json->'txn'->>'amount' as amount
719  from cu_scheduledtxn s
720  inner join ${Cu}user u on s.user_id = u.user_id
721  inner join cu_feature f on s.feature_code = f.feature_code and s.cu = '$Cu' and f.enabled and approved_status = 10
722  ) ss";
723 
724  $subqueryALXB = "select 'D' || trim(accountnumber) || trim(accounttype) || certnumber as key, may_deposit, may_withdraw,
725  accountnumber, accounttype, certnumber, description
726  from ${Cu}accountbalance
727  union all
728  select 'T' || trim(accountnumber) || trim(accounttype) || '#' || trim(tomember) || '0' as key, true, false, tomember, accounttype, 0, description
729  from ${Cu}crossaccounts where deposittype not in ('L', 'C')
730  union all
731  select 'L' || trim(accountnumber) || trim(loannumber) || '0' as key, may_payment, may_addon, accountnumber, loannumber, 0, description
732  from ${Cu}loanbalance
733  union all
734  select 'P' || trim(accountnumber) || trim(accounttype) || '#' || trim(tomember) || '0' as key, false, false, tomember, accounttype, 0, description
735  from ${Cu}crossaccounts where deposittype in ('L', 'C')";
736 
737  $sql = "select txn.*, alxbfrom.description as from_desc, alxbfrom.accountnumber as from_acct, alxbfrom.accounttype as from_accounttype,
738  alxbfrom.certnumber as from_certnumber, alxbto.description as to_desc, alxbto.accountnumber as to_acct,
739  alxbto.accounttype as to_accounttype, alxbto.certnumber as to_certnumber from ($subqueryTXN) txn
740  left join ($subqueryALXB) as alxbfrom on txn.fromkeya::text = alxbfrom.key::text
741  left join ($subqueryALXB) as alxbto on txn.tokeya::text = alxbto.key::text
742  order by txn.next_trigger_date";
743 
744  $sth = db_query($sql, $dbh);
745  if (!$sth) {
746  throw new exception("Select query failed.", 101);
747  }
748  $repeatingTransfers = array();
749 
750  $achcols = array();
751  $achpmts = array();
752  $exts = array();
753 
754  for($i = 0; $row = db_fetch_assoc($sth, $i); $i++) {
755  switch($row["feature_code"]) {
756  case "ACHCOL":
757  $achcols[] = $row["id"];
758  break;
759  case "ACHPMT":
760  $achpmts[] = $row["id"];
761  break;
762  case "TRNEXT":
763  case "TRNM2M":
764  $exts[] = $row["id"];
765  break;
766  }
767 
768  $repeatingTransfers[] = $row;
769  }
770 
771  $sql = array();
772  $achMap = array();
773  if (count($achcols) > 0) {
774  $sql[] = "select s.id as data_id, ap.ach_name, ap.display_name, ap.address, ap.dfi_data from cu_scheduledtxn s
775  inner join ${Cu}achpartner ap on s.feature_code = 'ACHCOL' and cast(s.txn_data::json->'txn'->>'from' as int) = ap.id where s.id in (" . implode(", ", $achcols) . ")";
776  }
777  if (count($achpmts) > 0) {
778  $sql[] = "select s.id as data_id, ap.ach_name, ap.display_name, ap.address, ap.dfi_data from cu_scheduledtxn s
779  inner join ${Cu}achpartner ap on s.feature_code = 'ACHPMT' and cast(s.txn_data::json->'txn'->>'to' as int) = ap.id where s.id in (" . implode(", ", $achpmts) . ")";
780  }
781 
782  if (count($sql) > 0) {
783  $sql = implode(" union all ", $sql);
784  $sth = db_query($sql, $dbh);
785  if (!$sth) {
786  throw new exception("ach map query failed.", 307);
787  }
788  for($i = 0; $row = db_fetch_assoc($sth, $i); $i++) {
789  $row["dfi_data"] = HCU_JsonDecode($row["dfi_data"]);
790  $row["address"] = HCU_JsonDecode($row["address"]);
791 
792  $achMap[$row["data_id"]] = $row;
793  }
794  }
795 
796  $sql = array();
797  $extMap = array();
798  if (count($exts) > 0) {
799  $sql[] = "select s.id as data_id, 'from' as dir, ea.display_name, ea.remote_info from cu_scheduledtxn s
800  inner join ${Cu}extaccount ea on s.feature_code in ('TRNEXT') and s.txn_data::json->'txn'->>'type' in ('X2L') and cast(s.txn_data::json->'txn'->>'from' as int) = ea.id
801  and ea.type= 'EXT' where s.id in (" . implode(", ", $exts) . ")"; // Member-to-member can only come from the local account.
802  $sql[] = "select s.id as data_id, 'to', ea.display_name, ea.remote_info from cu_scheduledtxn s
803  inner join ${Cu}extaccount ea on s.feature_code in ('TRNEXT', 'TRNM2M') and s.txn_data::json->'txn'->>'type' in ('L2X', 'L2M') and replace(s.feature_code, 'TRN', '') = ea.type
804  and cast(s.txn_data::json->'txn'->>'to' as int) = ea.id where s.id in (" . implode(", ", $exts) . ")";
805  }
806 
807  if (count($sql) > 0) {
808  $sql = implode(" union all ", $sql);
809  $sth = db_query($sql, $dbh);
810  if (!$sth) {
811  throw new exception("ext map query failed.", 308);
812  }
813  for($i = 0; $row = db_fetch_assoc($sth, $i); $i++) {
814  $row["remote_info"] = HCU_JsonDecode($row["remote_info"]);
815 
816  $extMap[$row["data_id"]] = $row;
817  }
818  }
819 
820  $mask = GetMask($dbh, $Cu, false)["data"];
821  for($i = 0, $iCount = count($repeatingTransfers); $i != $iCount; $i++) {
822  $row = $repeatingTransfers[$i];
823 
824  $repeating = isset($row["repeating_parameters"]) ? trim($row["repeating_parameters"]) : "";
825  if ($repeating != "") {
826  $repeating = HCU_JsonDecode($repeating);
827  if (!is_array($repeating)) {
828  throw new exception("Repeating is malformed!", 102);
829  }
830  if (!HCU_array_key_exists("interval", $repeating)) {
831  throw new exception("Repeating is malformed!", 103);
832  }
833  $row["interval"] = $repeating["interval"];
834  $row["intervalText"] = HCU_array_key_exists($row["interval"], $intervalLookup) ? $intervalLookup[$row["interval"]] : "";
835  }
836  unset($row["repeating_parameters"]);
837 
838  $txnData = isset($row["txn_data"]) ? trim($row["txn_data"]) : "";
839  $flagset3 = GetFlagsetValue('CU3_PREPEND_MBR_DESC');
840  if ($txnData != "") {
841  $txnData = HCU_JsonDecode($txnData);
842  if (!is_array($txnData)) {
843  throw new exception("TxnData is malformed.", 104);
844  }
845 
846  // Truncated version of online banking activity templates. Normally, kendo takes care of the entities but I am turning encoded off on the column to allow for <br>.
847  switch($row["feature_code"]) {
848  case "TRN":
849  $from = getAccountDescription($dbh, $Cu, $row["from_acct"], $row["from_desc"], $row["from_accounttype"], "", $flagset3, $row["from_certnumber"]);
850  $to = getAccountDescription($dbh, $Cu, $row["to_acct"], $row["to_desc"], $row["to_accounttype"], "", $flagset3, $row["to_certnumber"]);
851  break;
852  case "ACHCOL":
853  $partnerRow = $achMap[$row["id"]];
854  $routing = trim($partnerRow["dfi_data"]["dfi_routing"]);
855  $account = trim($partnerRow["dfi_data"]["dfi_account"]);
856  $from = $routing . " / " . ApplyMask($account, $mask);
857  $to = getAccountDescription($dbh, $Cu, $row["to_acct"], $row["to_desc"], $row["to_accounttype"], "", $flagset3, $row["to_certnumber"]);
858  break;
859  case "ACHPMT":
860  $partnerRow = $achMap[$row["id"]];
861  $routing = trim($partnerRow["dfi_data"]["dfi_routing"]);
862  $account = trim($partnerRow["dfi_data"]["dfi_account"]);
863  $to = $routing . " / " . ApplyMask($account, $mask);
864  $from = getAccountDescription($dbh, $Cu, $row["from_acct"], $row["from_desc"], $row["from_accounttype"], "", $flagset3, $row["from_certnumber"]);
865  break;
866  case "TRNM2M":
867  $extAccount = $extMap[$row["id"]];
868  $account = trim($extAccount["remote_info"]["rdfi"]["account"]);
869  $type = intval($extAccount["remote_info"]["rdfi"]["type"]);
870  $type = $type == 10 ? "Checking" : "Savings";
871  $to = ApplyMask($account, $mask) . " / $type";
872  $from = getAccountDescription($dbh, $Cu, $row["from_acct"], $row["from_desc"], $row["from_accounttype"], "", $flagset3, $row["from_certnumber"]);
873  break;
874  case "TRNEXT":
875  $extAccount = $extMap[$row["id"]];
876  $fromLabel = $extAccount["dir"] == "from" ? "ACH From Account" : "From";
877  $toLabel = $extAccount["dir"] == "to" ? "ACH To Account" : "To";
878 
879  if ($extAccount["dir"] == "from") {
880  $routing = trim($extAccount["remote_info"]["rdfi"]["routing"]);
881  $account = trim($extAccount["remote_info"]["rdfi"]["account"]);
882  $from = $routing . " / " . ApplyMask($account, $mask);
883  $to = getAccountDescription($dbh, $Cu, $row["to_acct"], $row["to_desc"], $row["to_accounttype"], "", $flagset3, $row["to_certnumber"]);
884  } else {
885  $from = getAccountDescription($dbh, $Cu, $row["from_acct"], $row["from_desc"], $row["from_accounttype"], "", $flagset3, $row["from_certnumber"]);
886  $routing = trim($extAccount["remote_info"]["rdfi"]["routing"]);
887  $account = trim($extAccount["remote_info"]["rdfi"]["account"]);
888  $to = $routing . " / " . ApplyMask($account, $mask);
889  }
890  break;
891 
892  default:
893  }
894  }
895  unset($row["txn_data"]);
896 
897  $payload = readUserSearch($dbh, $Cu, array("a" => array("id" => $row["user_id"])));
898  $userLink = "<a href='main.prg?ft=22&payload=" . urlencode($payload["encryption"]) . "'>" . htmlentities($row["user_name"]) . "</a>";
899  $newRow = array($row["user_id"], $userLink, $from, $to, $row["feature"], $row["amount"], $row["next_trigger_date"], $row["interval"], $row["start_date"], $row["end_date"]);
900  $repeatingTransfers[$i] = $newRow;
901  }
902  $returnArray = array("data" => $repeatingTransfers, "status" => "000", "error" => "");
903  }
904  catch(exception $e) {
905  $returnArray = array("data" => array(), "status" => $e->getCode(), "error" => $e->getMessage());
906  }
907  return $returnArray;
908 }
909 
910 /**
911  * function GetProfileCol($col, $isAmount = true)
912  * This returns the SQL per amount or count column in the SQL.
913  *
914  * @param $col -- the column to encapulate in the SQL.
915  * @param $isAmount -- if true, then use the limit for the amount. If false , use the limit for the count.
916  * @return nullif clause for the column. If the value in there is the limit then set it to null. Essentially don't show the value.
917  */
918 function GetProfileCol($col, $isAmount = true) {
919  return "nullif($col, " . ($isAmount ? FEATURE_LIMIT_MAX_AMOUNT : FEATURE_LIMIT_MAX_COUNT) . ")";
920 }
921 
922 /**
923  * function AddLinksToOrphanedRecordsReport($Cu, $dbh, &$dataRecord, $unused)
924  * This is a callback function specific to the "orphaned records" report. The name of this function is referenced in the "dataFunc" attribute.
925  *
926  * @param $Cu -- the credit union
927  * @param $dbh -- the database connection
928  * @param $dataRecord -- the record returned from the SQL specified above.
929  */
930 function AddLinksToOrphanedRecordsReport($Cu, $dbh, &$dataRecord, $unused) {
931  switch($dataRecord["typeId"]) {
932  case 0:
933  case 1:
934  $payload = readUserSearch($dbh, $Cu, array("a" => array("id" => $dataRecord["id"])));
935  $dataRecord["name"] = "<a href='main.prg?ft=22&payload=" . urlencode($payload["encryption"]) . "'>" . htmlentities($dataRecord["name"]) . "</a>";
936  break;
937  case 2:
938  $payload = MemberSelect(null, $dbh, array("cu_table" => $Cu, "cu_code" => $Cu, "m_account" => $dataRecord["name"]));
939  $payload = MemberEncrypt(null, $Cu, $payload);
940  $dataRecord["name"] = "<a href='main.prg?ft=103101&payload=" . urlencode($payload) . "'>" . htmlentities($dataRecord["name"]) . "</a>";
941  break;
942  // With the current changes, g_id is now needed.
943  case 3:
944  $payload = GroupSelect(null, $dbh, array("cu_table" => $Cu, "cu_code" => $Cu, "g_name" => $dataRecord["name"], "g_id" => $dataRecord["id"]));
945  $payload = GroupEncrypt(null, $Cu, $payload);
946  $dataRecord["name"] = "<a href='main.prg?ft=102101&payload=" . urlencode($payload) . "'>" . htmlentities($dataRecord["name"]) . "</a>";
947  break;
948  }
949 }
950 
951 /**
952  * function addLinks($Cu, $dbh, &$dataRecord, $linkAssoc)
953  * This is a callback function for a lot of reports to add links to the user, account, and group hubs when needed. The name of this function is referenced in the "dataFunc" attribute.
954  *
955  * @param $Cu -- the credit union
956  * @param $dbh -- the database connection
957  * @param $dataRecord -- the data record to modify. (This function is called once per record.)
958  * @param $linkAssoc -- parameters for this function. Defined as array("id" => $id, "name" => $name, "type" => $type). Type is either "user", "account", or "group."
959  */
960 function AddLinks($Cu, $dbh, &$dataRecord, $linkAssoc) {
961  foreach($linkAssoc as $colArray) {
962  $name = GetCamelCase($colArray["name"]);
963  $id = HCU_array_key_exists("id", $colArray) ? GetCamelCase($colArray["id"]) : 0;
964  switch($colArray["type"]) {
965  case "user":
966  if ($dataRecord[$id] != "") {
967  $payload = readUserSearch($dbh, $Cu, array("a" => array("id" => $dataRecord[$id])));
968  $dataRecord[$name] = "<a href='main.prg?ft=22&payload=" . urlencode($payload["encryption"]) . "'>" . htmlentities($dataRecord[$name]) . "</a>";
969  }
970  break;
971  case "account":
972  if ($dataRecord[$name] != "") {
973  $payload = MemberSelect(null, $dbh, array("cu_table" => $Cu, "cu_code" => $Cu, "m_account" => $dataRecord[$name]));
974  $payload = MemberEncrypt(null, $Cu, $payload);
975  $dataRecord[$name] = "<a href='main.prg?ft=103101&payload=" . urlencode($payload) . "'>" . htmlentities($dataRecord[$name]) . "</a>";
976  }
977  break;
978  // Currently, this path is not used but was probably added for completeness.
979  // With the current changes, g_id is now needed.
980  case "group":
981  if ($dataRecord[$name] != "") {
982  $payload = GroupSelect(null, $dbh, array("cu_table" => $Cu, "cu_code" => $Cu, "g_name" => $dataRecord[$name], "g_id" => $dataRecord[$id]));
983  $payload = GroupEncrypt(null, $Cu, $payload);
984  $dataRecord[$name] = "<a href='main.prg?ft=102101&payload=" . urlencode($payload) . "'>" . htmlentities($dataRecord[$name]) . "</a>";
985  }
986  break;
987  }
988  }
989 }
990 
991 /**
992  * function GetReportMenu($live)
993  * This gets the report menu in a format:
994  *
995  * array($menuTitle => array($reportKey, ...), ...)
996  *
997  * $menuTitle -- The text to display at the top of the menu group.
998  * $reportKey -- Refers to the $reportKey in the function getReportList(). The link will show the title in the report record and will go to that report when clicked on.
999  *
1000  * @param $live -- Live CUs have a slightly different report list. @todo: put the switches back in when Odyssey supports batch clients.
1001  * @return @array the report menu with the attributes above.
1002  */
1003 function GetReportMenu($live) {
1004 
1005  $liveBatch = $live == "Y" ? "Live" : "Batch";
1006 
1007  return [
1008  "Android Login Reports" => [
1009  "androidHistory",
1010  "androidSummary"
1011  ],
1012  "iPhone Login Reports" => [
1013  "iphoneHistory",
1014  "iphoneSummary"
1015  ],
1016  "User Login Reports" => [
1017  "loginHistory",
1018  "lockedRpt",
1019  "loginSummary",
1020  "loginTracking",
1021  "memberSummary",
1022  "memLastLogin$liveBatch",
1023  "lastLogin2",
1024  "loginFailure",
1025  "sincePasswordChange"
1026  ],
1027  "User Settings Reports" => [
1028  "emailStatement",
1029  "memberLogin",
1030  "billpaySettings",
1031  "memEmail",
1032  "emailInitialized",
1033  "emailChangedDate",
1034  "estatementStatus",
1035  "overrideMcir",
1036  "cuDismissible",
1037  "deepTarget",
1038  "termsAccepted",
1039  "onlineDepositSettings"
1040  ],
1041  "Repeating Transaction Reports" => [
1042  "repeatingTrans",
1043  "repeatingSummary"
1044  ],
1045  "Online Transaction Reports" => [
1046  "transactionMonth",
1047  "transactionDay",
1048  "crossMonth",
1049  "crossDay",
1050  "avgTransMonth",
1051  "avgTransDay"
1052  ],
1053  "User Alerts" => [
1054  "totalMemAlerts",
1055  "memAlerts",
1056  "memAlertsSuffix"
1057  ],
1058  "eStatement Reports" => [
1059  "eStatementHistory",
1060  "estatementMems"
1061  ],
1062  "Profile, Group, and User Reports" => [
1063  "profile",
1064  "group",
1065  "orphanedRecords",
1066  "pendingMicroDeposits",
1067  "lockedMembers",
1068  "extAcctReport"
1069  ]
1070  ];
1071 
1072 }
1073 
1074 // Data layer
1075 // **************************************
1076 
1077 /**
1078  * function ReadReport($Cu, $dbh, $reportList, $defaultDateFormat)
1079  * Returns the data needed from the parameters for the report:
1080  *
1081  * "report" -- the report to read. Refers to the $reportKey in the function getReportList(). The frontend sends the variable down as a javascript literal.
1082  * "initial" -- {true/false} If initial, then also figure out the boolean DDLs and send those to the frontend to recreate the grid with those parameters. (There is no re-read.)
1083  * "limit" -- How many records to return.
1084  * "offset" -- Where to start in the resultset.
1085  * "newFilter" -- {true/false} If newFilter, then also find out the count of the resultset. Also, ignore the offset. It will get the first page.
1086  * "sort" -- This is a json encoded string of the current sort in the grid.
1087  * "filter" -- This is a json encoded string of the current filter in the grid.
1088  * "printerFriendly" -- printer friendly doesn't have paging.
1089  *
1090  * @param $Cu -- the credit union
1091  * @param $dbh -- the database connection
1092  * @param $reportList -- the reportList from the getReportList() above.
1093  * @param $defaultDateFormat -- This is what the date will be formatted as.
1094  */
1095 function ReadReport($Cu, $dbh, $reportList, $defaultDateFormat) {
1096  $parameters = array("a" => array("report" => "", "initial" => "", "limit" => "", "offset" => "", "newFilter" => "", "sort" => "", "filter" => "", "printerFriendly" => ""));
1097  $string = array("filter" => HCUFILTER_INPUT_STRING);
1098  HCU_ImportVars($parameters, "a", array("report" => $string, "initial" => $string, "limit" => $string, "offset" => $string, "newFilter" => $string, "sort" => $string,
1099  "filter" => $string, "printerFriendly" => $string));
1100 
1101  $report = HCU_array_key_exists("report", $parameters["a"]) ? trim($parameters["a"]["report"]) : "";
1102  $initial = HCU_array_key_exists("initial", $parameters["a"]) ? trim($parameters["a"]["initial"]) == "true" : false;
1103  $limit = HCU_array_key_exists("limit", $parameters["a"]) ? intval($parameters["a"]["limit"]) : -1;
1104  $offset = HCU_array_key_exists("offset", $parameters["a"]) ? intval($parameters["a"]["offset"]) : 0;
1105  $newFilter = HCU_array_key_exists("newFilter", $parameters["a"]) ? trim($parameters["a"]["newFilter"]) == "true" : false;
1106  $sort = HCU_array_key_exists("sort", $parameters["a"]) ? trim($parameters["a"]["sort"]) : "";
1107  $filter = HCU_array_key_exists("filter", $parameters["a"]) ? trim($parameters["a"]["filter"]) : "";
1108  $printerFriendly = HCU_array_key_exists("printerFriendly", $parameters["a"]) ? trim($parameters["a"]["printerFriendly"]) == "true" : false;
1109 
1110  $includeCount = !$printerFriendly && $newFilter;
1111  $limit = $printerFriendly ? -1 : $limit;
1112  $offset = $printerFriendly || $newFilter ? 0 : $offset;
1113 
1114  $returnArray = ReadReportData($Cu, $reportList[$report], $dbh, $limit, $offset, $includeCount, false, $initial, false, $sort, $filter, $report, "cu_reports");
1115 
1116  header('Content-type: application/json');
1117  print HCU_JsonEncode($returnArray);
1118 }
1119 
1120 /**
1121  * function GenerateCSV($dbh, $reportList, $defaultDateFormat)
1122  * Returns the CSV needed from the parameters for the report:
1123  *
1124  * "report" -- the report to read. Refers to the $reportKey in the function getReportList(). The frontend sends the variable down as a javascript literal.
1125  * "sort" -- This is a json encoded string of the current sort in the grid.
1126  * "filter" -- This is a json encoded string of the current filter in the grid.
1127  * "title" -- This is an overwrite for the filename returned. Otherwise, it is the $title attribute in the getReportList() function.
1128  *
1129  * @param $Cu -- the credit union
1130  * @param $dbh -- the database connection
1131  * @param $reportList -- the reportList from the getReportList() above.
1132  * @param $defaultDateFormat -- This is what the date will be formatted as.
1133  */
1134 function GenerateCSV($Cu, $dbh, $reportList, $defaultDateFormat) {
1135  $parameters = array("a" => array("report" => "", "sort" => "", "filter" => "", "title" => ""));
1136  $string = array("filter" => HCUFILTER_INPUT_STRING);
1137  HCU_ImportVars($parameters, "a", array("report" => $string, "sort" => $string, "filter" => $string, "title" => $string));
1138 
1139  $report = HCU_array_key_exists("report", $parameters["a"]) ? trim($parameters["a"]["report"]) : "";
1140  $sort = HCU_array_key_exists("sort", $parameters["a"]) ? trim($parameters["a"]["sort"]) : "";
1141  $filter = HCU_array_key_exists("filter", $parameters["a"]) ? trim($parameters["a"]["filter"]) : "";
1142  $title = HCU_array_key_exists("title", $parameters["a"]) ? trim($parameters["a"]["title"]) : "";
1143 
1144  $reportValues = ReadReportData($Cu, $reportList[$report], $dbh, -1, 0, true, true, false, false, $sort, $filter, $report, "cu_reports");
1145  $reportData = $reportValues["reportData"]["data"];
1146 
1147  printCSVData($reportData, $report, $title, $reportList, $defaultDateFormat);
1148 }
1149 
1150 /**
1151  * function printReportListPage($reportList, $self, $report, $reportNotFound)
1152  * Prints out the page for the report list.
1153  *
1154  * @param $reportList -- the reportList in the function getReportList().
1155  * @param $self -- the URL of this script.
1156  * @param $report -- the $reportKey in the function getReportList().
1157  * @param $reportNotFound -- {true/false} If the user typed in the wrong report in the URL, then there will be a info message and the report list will still show up.
1158  * @param $live -- Live CUs have a slightly different report list than batch CUs. Odyssey supports only live CUs so far.
1159  */
1160 function printReportListPage($reportList, $self, $report, $reportNotFound, $live) { ?>
1161  <div class="container-fluid">
1162  <?php if ($reportNotFound) { ?>
1163  <div class=" k-info-colored">
1164  Report "<?php echo $report; ?>" not found. Please select from the list below.
1165  </div>
1166  <?php } ?>
1167 
1168  <div id="cuReportList">
1169 
1170  <?php echo compileMenu(getReportMenu($live), $reportList, $self); ?>
1171 
1172  <?php if (false) { // PHIL SAYS NO #996 ?>
1173  <div>
1174  <h2 class='primary'>Web Statistic Reports</h2>
1175  <div class="row">
1176  <div class="col-xs-6">
1177  <a href='/admbin/main.prg/WEBLOG?ft=84' target="_blank"><span class='reportTitle'>Statistics <i class="fa fa-external-link"></i></span></a>
1178  </div>
1179 
1180  <div class="col-xs-6">
1181  <a href='/admbin/main.prg/WEBLOG?ft=84&report=C' target="_blank"><span class='reportTitle'>Current Month MTD Statistics <i class="fa fa-external-link"></i></span></a>
1182  </div>
1183 
1184  <div class="col-xs-6">
1185  <a href='/admbin/main.prg/WEBLOG?ft=84&report=P' target="_blank"><span class='reportTitle'>Prior Month Statistics <i class="fa fa-external-link"></i></span></a>
1186  </div>
1187  </div>
1188  </div>
1189  <?php } ?>
1190 
1191  </div>
1192  </div>
1193 <?php }
1194 
1195 /**
1196  * function PrintReportPage($reportList, $csv, $report, $prodname, $title, $self)
1197  * Prints out the page for a report that is found.
1198  *
1199  * @param $reportList -- the reportList in getReportList().
1200  * @param $csv -- If it is print, then do a printerFriendly version.
1201  * @param $report -- the $reportKey in the function getReportList().
1202  * @param $prodname -- the prodname
1203  * @param $title -- the title of the report.
1204  * @param $self -- the URL of this script. (&report=$report is appended for all calls.)
1205  */
1206 function PrintReportPage($reportList, $report, $prodname, $title, $self) {
1207  $reportSelf = "$self&report=$report";
1208  $printURL = "$reportSelf&csv=print";
1209  ?>
1210 
1211  <style>
1212  /* Page Template for the exported PDF */
1213  .pageTemplate {
1214  font-family: "DejaVu Sans", "Arial", sans-serif;
1215  position: absolute;
1216  width: 100%;
1217  height: 100%;
1218  top: 0;
1219  left: 0;
1220  }
1221  .pageTemplate .header {
1222  position: absolute;
1223  top: 30px;
1224  left: 30px;
1225  right: 30px;
1226  border-bottom: 1px solid #888;
1227  color: #888;
1228  }
1229  .pageTemplate .footer {
1230  position: absolute;
1231  bottom: 30px;
1232  left: 30px;
1233  right: 30px;
1234  border-top: 1px solid #888;
1235  text-align: center;
1236  color: #888;
1237  }
1238  .pageTemplate .watermark {
1239  font-weight: bold;
1240  font-size: 400%;
1241  text-align: center;
1242  margin-top: 30%;
1243  color: #aaaaaa;
1244  opacity: 0.1;
1245  transform: rotate(-35deg) scale(1.7, 1.5);
1246  }
1247  </style>
1248 
1249  <script type="text/javascript">
1250 
1251  <?php
1252  getShowWaitFunctions();
1253  GetIsFilterChanged();
1254 
1255  $pageSize = 30;
1256  $pagesToShow = 10;
1257 
1258  InitReport($reportSelf, $report, $reportList, getDefaultDateFormat(), "true", $pageSize, false, "", "", "cu_reports"); ?>
1259 
1260  var pageVariables = { <?php echo "skip: 0, page: 1, count: -1, numPages: -1, pageSize: " . $pageSize . ", pagesToShow: " . $pagesToShow ?> };
1261 
1262  function ShowNote() {
1263  var notification = $("#notification").kendoNotification({
1264  position: {
1265  pinned: true,
1266  top: 118,
1267  left: 128
1268  },
1269  show: function () {
1270  notificationIsShown = true;
1271  },
1272  hide: function () {
1273  notificationIsShown = false;
1274  }
1275  }).data("kendoNotification");
1276 
1277  var notifificationIsShown = false;
1278  var infoLine = "";
1279  notification.info(infoLine);
1280  }
1281 
1282  $(document).ready(function () {
1283  $.homecuValidator.setup({formValidate:'cuReportingDiv', formStatusField: 'formValidateMainDiv'});
1284  InitReport();
1285  });
1286 </script>
1287 
1288 <script type="x/kendo-template" id="pageTemplate">
1289  <div class="pageTemplate">
1290  <div class="header">
1291  <div style="float: right">Page #: pageNum # of #: totalPages #</div>
1292  <?php echo $title; ?>
1293  </div>
1294  <div class="watermark"><?php echo hcu_displayHtml($prodname); ?></div>
1295  <div class="footer">
1296  Page #: pageNum # of #: totalPages #
1297  </div>
1298  </div>
1299 </script>
1300 
1301  <div class="cuReportingDiv container hcu-all-100 vsgPrimary" id="cuReportingDiv">
1302  <div class="row reportHeader hcuSpacer">
1303  <span class="hcu-breadcrumb col-xs-6"><a href="<?php echo $self; ?>"> Reports </a> / <?php echo $title ?></span>
1304  <div class="exports col-xs-6">
1305  <div class="floatRight">
1306  <span id="csv">
1307  <a href="#" id="downloadCSVBtn" alt="Download CSV" title="Download CSV">
1308  <i class='fa fa-download' aria-hidden='true'></i></a></span>
1309 
1310  <span id="pdf">
1311  <a href="#" id="downloadPDFBtn" alt="Download PDF" title="Download PDF">
1312  <i class='fa fa-file-pdf-o' aria-hidden='true'></i></a></span>
1313 
1314  <span id="print">
1315  <a href="#" id="printBtn" alt="Printer Friendly" title="Printer Friendly">
1316  <i class='fa fa-print' aria-hidden='true'></i></a></span>
1317  </div>
1318  </div>
1319  </div>
1320 
1321  <div id="notification"></div>
1322 
1323  <div class="row hcuSpacer">
1324  <div class="col-xs-12">
1325  <div id="formValidateMainDiv" class="k-block k-error-colored formValidateDiv" style="display:none;"></div>
1326  </div>
1327  </div>
1328 
1329  <div class="row hcuSpacer">
1330  <div class="col-xs-12">
1331  <div class="reportGridOverflow">
1332  <div id="reportGrid"></div>
1333  </div>
1334  </div>
1335  </div>
1336 
1337  <iframe name="hiddenFrame" class="hide"></iframe>
1338  <form id="downloadCSVForm" action="<?php echo $reportSelf; ?>&operation=generateCSV" method="post" target="hiddenFrame" style="display:none;">
1339  <input name="title" value="<?php echo $title; ?>">
1340  </form>
1341  <form id="printForm" target="_blank" action="<?php echo $reportSelf; ?>&csv=print&title=<?php echo $title; ?>" method="post" style="display:none;">
1342  <input name="report" value="<?php echo $report; ?>">
1343  </form>
1344  </div>
1345 
1346 <?php }
1347 
1348 /**
1349  * function PrintReportPagePrintOnly($reportList, $report, $prodname, $title, $self, $sort, $filter)
1350  * Prints out the page for a report that is found.
1351  *
1352  * @param $reportList -- the reportList in getReportList().
1353  * @param $report -- the $reportKey in the function getReportList().
1354  * @param $prodname -- the prodname
1355  * @param $title -- the title of the report.
1356  * @param $self -- the URL of this script. (&report=$report is appended for all calls.)
1357  * @param $sort -- the sort for the page.
1358  * @param $filter -- the filter for the page.
1359  */
1360 function PrintReportPagePrintOnly($reportList, $report, $prodname, $title, $self, $sort, $filter) {
1361  $reportSelf = "$self&report=$report";
1362  ?>
1363 
1364  <script type="text/javascript">
1365 
1366  <?php
1367  getShowWaitFunctions();
1368  GetIsFilterChanged();
1369 
1370  $pageSize = 30;
1371  $pagesToShow = 10;
1372 
1373  InitReport($reportSelf, $report, $reportList, getDefaultDateFormat(), "true", $pageSize, true, $sort, $filter, "cu_reports"); ?>
1374 
1375  $(document).ready(function () {
1376  $.homecuValidator.setup({formValidate:'cuReportingDiv', formStatusField: 'formValidateMainDiv'});
1377  InitReport();
1378  $("#closeWindow").click(function () {
1379  window.close();
1380  return false;
1381  });
1382  $("#printWindow").click(function () {
1383  window.print();
1384  return false;
1385  });
1386  });
1387 </script>
1388  <div class="cuReportingDiv container hcu-all-100 vsgPrimary" id="cuReportingDiv">
1389 
1390  <div class="row hcuSpacer">
1391  <h2 class="col-xs-12"><?php echo $title; ?></h2>
1392  </div>
1393  <div id="formValidateMainDiv" class="k-block k-error-colored formValidateDiv" style="display:none;"></div>
1394 
1395  <div class="row hcuSpacer hidden-print">
1396  <div class="col-xs-12">
1397  <a id="closeWindow" href="#" ><i class='fa fa-times' aria-hidden='true'></i>&nbsp;Close Window</a>
1398  &nbsp;&nbsp;
1399  <a id="printWindow" href="#" ><i class='fa fa-print' aria-hidden='true'></i>&nbsp;Print</a>
1400  </div>
1401  </div>
1402 
1403  <div class="row hcuSpacer">
1404  <div class="col-xs-12">
1405  <div id="reportGrid" class="hcu-all-100 printableGrid"></div>
1406  </div>
1407  </div>
1408  </div>
1409 <?php }