[freeside-commits] branch FREESIDE_4_BRANCH updated. 5aa5b25d6149129a676daadfa39c878a1f5823bf

Mitch Jackson mitch at freeside.biz
Wed Sep 19 09:41:54 PDT 2018


The branch, FREESIDE_4_BRANCH has been updated
       via  5aa5b25d6149129a676daadfa39c878a1f5823bf (commit)
       via  fa32644857a444c489ff0d7389b0d6d61790b229 (commit)
       via  d727ecee34916db4c21ae09d258faa243ab7faa9 (commit)
       via  fe14c0ff8a609d9488c40e81eb25babfa691b34c (commit)
       via  2f330afab567fda7679bfe24588598e3e5537467 (commit)
       via  94f0030bae0ce3e493b99860901158e30e9651fd (commit)
       via  b9bb2b3bc596c18245199cd799c5f50d3e02ea59 (commit)
       via  d1dfa92834944079595def7f1ba2d62b2f30243b (commit)
       via  7b326a5bd5550034f98086ef7df1884c9cf72bb7 (commit)
       via  32292936eacc92ddd6edb5071a7ca027dc249e8d (commit)
       via  cb06cc9e1aec923326a38d06a17bc1a23cee7246 (commit)
       via  c6c2d3beb9d4fc572132e1328546649c2b10697d (commit)
       via  70b42b53630d363ac0db942f6b1a12dd56a092ea (commit)
       via  845c334636c4f4e3fbe1ffdf880d3ad837746823 (commit)
       via  e2c2e1c179265ee06b78b4fd3d3aa058a09270db (commit)
       via  c6ac0d4705ef01f2cca9340c7089bae1908cae27 (commit)
       via  c616cf2095e79a6dec2f16cd9ffaa139841ee0ee (commit)
       via  5fb44923f3f979769629b9b05f1c156566f33d7d (commit)
       via  7be116339beee253f9bf8805076c766cf0f8e318 (commit)
       via  5a8c96bc2c4cd9dfe4540ee39fba0fd203689890 (commit)
       via  18ef89c91d6ecb57d7f2c1814e60b8434ae84bed (commit)
       via  c6251a1dcd226056780bf28f8ca79f078f8c78bd (commit)
      from  094d5f56920ef066ff10d856399e7baa28d0fa56 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 5aa5b25d6149129a676daadfa39c878a1f5823bf
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Wed Sep 19 02:31:52 2018 -0400

    RT# 78547 Future autobill report - sql bugfix

diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm
index 9d8be120a..4e9f04f51 100644
--- a/FS/FS/cust_payby.pm
+++ b/FS/FS/cust_payby.pm
@@ -931,7 +931,7 @@ sub has_autobill_cards {
         weight  => { op => '>',  value => 0 },
     },
     extra_sql =>
-      "AND payby IN ('CARD', 'DCRD') ".
+      "AND cust_payby.payby IN ('CARD', 'DCRD') ".
       'AND '.
       $FS::CurrentUser::CurrentUser->agentnums_sql( table => 'cust_main' ),
   });
@@ -952,7 +952,7 @@ sub has_autobill_checks {
         weight  => { op => '>',  value => 0 },
     },
     extra_sql =>
-      "AND payby IN ('CHEK','DCHEK','DCHK') ".
+      "AND cust_payby.payby IN ('CHEK','DCHEK','DCHK') ".
       'AND '.
       $FS::CurrentUser::CurrentUser->agentnums_sql( table => 'cust_main' ),
   });
diff --git a/httemplate/search/future_autobill.html b/httemplate/search/future_autobill.html
index 1f3862fbc..3385dd880 100644
--- a/httemplate/search/future_autobill.html
+++ b/httemplate/search/future_autobill.html
@@ -121,8 +121,8 @@ there will be 1,400 billing and payment cycles simulated
     order_by  => " ORDER BY weight DESC ",
     extra_sql =>
       "AND (
-        payby IN ('CHEK','DCHK','DCHEK')
-        OR ( paydate > '".$target_dt->ymd."')
+        cust_payby.payby IN ('CHEK','DCHK','DCHEK')
+        OR ( cust_payby.paydate > '".$target_dt->ymd."')
       )
       AND " . $FS::CurrentUser::CurrentUser->agentnums_sql
       . ($agentnum ? "AND cust_main.agentnum = $agentnum" : ''),

commit fa32644857a444c489ff0d7389b0d6d61790b229
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Mon Sep 17 21:30:15 2018 -0400

    RT# 78547 Future autobill report - report runs in job queue

diff --git a/FS/FS/Report/Queued/FutureAutobill.pm b/FS/FS/Report/Queued/FutureAutobill.pm
new file mode 100644
index 000000000..82c902172
--- /dev/null
+++ b/FS/FS/Report/Queued/FutureAutobill.pm
@@ -0,0 +1,132 @@
+package FS::Report::Queued::FutureAutobill;
+use strict;
+use warnings;
+use vars qw( $job );
+
+use FS::Conf;
+use FS::cust_main;
+use FS::cust_main::Location;
+use FS::cust_payby;
+use FS::CurrentUser;
+use FS::Log;
+use FS::Mason qw(mason_interps);
+use FS::Record qw( qsearch );
+use FS::UI::Web;
+use FS::UID qw( dbh );
+
+use DateTime;
+use File::Temp;
+use Data::Dumper;
+use HTML::Entities qw( encode_entities );
+
+=head1 NAME
+
+FS::Report::Queued::FutureAutobill - Future Auto-Bill Transactions Report
+
+=head1 DESCRIPTION
+
+Future Autobill report generated within the job queue.
+
+Report results are saved to temp storage as a Mason fragment
+that is rendered by the queued report viewer.
+
+For every customer with a valid auto-bill payment method,
+report runs bill_and_collect() for each day, from today through
+the report target date.  After recording the results, all
+operations are rolled back.
+
+This report relies on the ability to safely run bill_and_collect(),
+with all exports and messaging disabled, and then to roll back the
+results.
+
+=head1 PARAMETERS
+
+C<agentnum>, C<target_date>
+
+=cut
+
+sub make_report {
+  $job = shift;
+  my $param = shift;
+  my $outbuf;
+  my $DEBUG = 0;
+
+  my $time_begin = time();
+
+  my $report_fh = File::Temp->new(
+    TEMPLATE => 'report.future_autobill.XXXXXXXX',
+    DIR      => sprintf( '%s/cache.%s', $FS::Conf::base_dir, $FS::UID::datasrc ),
+    UNLINK   => 0
+  ) or die "Cannot create report file: $!";
+
+  if ( $DEBUG ) {
+    warn Dumper( $job );
+    warn Dumper( $param );
+    warn $report_fh;
+    warn $report_fh->filename;
+  }
+
+  my $curuser = FS::CurrentUser->load_user( $param->{CurrentUser} )
+    or die 'Unable to set report user';
+
+  my ( $fs_interp ) = FS::Mason::mason_interps(
+    'standalone',
+    outbuf => \$outbuf,
+  );
+  $fs_interp->error_mode('fatal');
+  $fs_interp->error_format('text');
+
+  $FS::Mason::Request::QUERY_STRING = sprintf(
+    'target_date=%s&agentnum=%s',
+    encode_entities( $param->{target_date} ),
+    encode_entities( $param->{agentnum} || '' ),
+  );
+  $FS::Mason::Request::FSURL = $param->{RootURL};
+
+  my $mason_request = $fs_interp->make_request(
+    comp => '/search/future_autobill.html'
+  );
+
+  {
+    local $@;
+    eval{ $mason_request->exec() };
+    if ( $@ ) {
+      my $error = ref $@ eq 'HTML::Mason::Exception' ? $@->error : $@;
+
+      my $log = FS::Log->new('FS::Report::Queued::FutureAutobill');
+      $log->error(
+        "Error generating report: $FS::Mason::Request::QUERY_STRING $error"
+      );
+      die $error;
+    }
+  }
+
+  my $report_fn;
+  if ( $report_fh->filename =~ /report\.(future_autobill.+)$/ ) {
+      $report_fn = $1
+  } else {
+    die 'Error parsing report filename '.$report_fh->filename;
+  }
+
+  my $report_title = FS::cust_payby->future_autobill_report_title();
+  my $time_rendered = time() - $time_begin;
+
+  if ( $DEBUG ) {
+    warn "Generated content:\n";
+    warn $outbuf;
+    warn $report_fn;
+    warn $report_title;
+  }
+
+  print $report_fh qq{<% include("/elements/header.html", '$report_title') %>\n};
+  print $report_fh $outbuf;
+  print $report_fh qq{<!-- Time to render report $time_rendered seconds -->};
+  print $report_fh qq{<% include("/elements/footer.html") %>\n};
+
+  die sprintf
+    "<a href=%s/misc/queued_report.html?report=%s>view</a>\n",
+    $param->{RootURL},
+    $report_fn;
+}
+
+1;
diff --git a/FS/FS/UI/Web.pm b/FS/FS/UI/Web.pm
index 6cc04b9de..54128682e 100644
--- a/FS/FS/UI/Web.pm
+++ b/FS/FS/UI/Web.pm
@@ -743,6 +743,7 @@ use FS::CurrentUser;
 use FS::Record qw(qsearchs);
 use FS::queue;
 use FS::CGI qw(rooturl);
+use FS::Report::Queued::FutureAutobill;
 
 $DEBUG = 0;
 
diff --git a/httemplate/search/elements/grid-report.html b/httemplate/search/elements/grid-report.html
index b1e543012..efc009725 100644
--- a/httemplate/search/elements/grid-report.html
+++ b/httemplate/search/elements/grid-report.html
@@ -141,13 +141,17 @@ Usage:
   $m->print($output);
 </%perl>
 % } else {
+% unless ( $suppress_header ) {
 <& /elements/header.html, $title &>
+% }
 <% $head %>
 % my $myself = $cgi->self_url;
+% unless ( $suppress_header ) {
 <P ALIGN="right" CLASS="noprint">
 Download full reports<BR>
 as <A HREF="<% "$myself;_type=xls" %>">Excel spreadsheet</A><BR>
 </P>
+% }
 <style type="text/css">
 .report * {
   background-color: #f8f8f8;
@@ -169,8 +173,10 @@ as <A HREF="<% "$myself;_type=xls" %>">Excel spreadsheet</A><BR>
 %     next if !ref($cell); # placeholders
 %     my $td = $cell->{header} ? 'th' : 'td';
 %     my $style = '';
-%     $style .= " rowspan=".$cell->{rowspan} if $cell->{rowspan} > 1;
-%     $style .= " colspan=".$cell->{colspan} if $cell->{colspan} > 1;
+%     $style .= " rowspan=".$cell->{rowspan}
+%       if exists $cell->{rowspan} && $cell->{rowspan} > 1;
+%     $style .= " colspan=".$cell->{colspan}
+%       if exists $cell->{colspan} && $cell->{colspan} > 1;
 %     $style .= ' class="' . $cell->{class} . '"' if $cell->{class};
 % if ($cell->{bypass_filter}) {
       <<%$td%><%$style%>><% $cell->{value} %></<%$td%>>
@@ -182,8 +188,10 @@ as <A HREF="<% "$myself;_type=xls" %>">Excel spreadsheet</A><BR>
 % }
 </table>
 <% $foot %>
+% unless ( $suppress_footer ) {
 <& /elements/footer.html &>
 % }
+% }
 <%args>
 $title
 @rows
@@ -192,4 +200,6 @@ $head => ''
 $foot => ''
 $table_width => "100%"
 $table_class => "report"
+$suppress_header => undef
+$suppress_footer => undef
 </%args>
diff --git a/httemplate/search/future_autobill.html b/httemplate/search/future_autobill.html
index d4ad8e524..1f3862fbc 100644
--- a/httemplate/search/future_autobill.html
+++ b/httemplate/search/future_autobill.html
@@ -11,7 +11,12 @@ This report relies on the ability to safely run bill_and_collect(),
 with all exports and messaging disabled, and then to roll back the
 results.
 
+This report takes time.  If 200 customers have automatic
+payment methods, and requester is looking one week ahead,
+there will be 1,400 billing and payment cycles simulated
+
 </%doc>
+<h4><% $report_subtitle %></h4>
 <& elements/grid-report.html,
   title => $report_title,
   rows => \@rows,
@@ -27,17 +32,25 @@ results.
       td.gridreport { margin: 0 .2em; padding: 0 .4em; }
     </style>
   ',
+  suppress_header => $job ? 1 : 0,
+  suppress_footer => $job ? 1 : 0,
 &>
 
 <%init>
+  use DateTime;
+  use FS::Misc::Savepoint;
+  use FS::Report::Queued::FutureAutobill;
   use FS::UID qw( dbh );
 
   die "access denied"
     unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
+  my $job = $FS::Report::Queued::FutureAutobill::job;
+
+  $job->update_statustext('0,Finding customers') if $job;
+
   my $DEBUG = $cgi->param('DEBUG') || 0;
 
-  my $report_title = FS::cust_payby->future_autobill_report_title;
   my $agentnum = $cgi->param('agentnum')
     if $cgi->param('agentnum') =~ /^\d+/;
 
@@ -60,16 +73,20 @@ results.
 
   # Get target date from form
   if ($cgi->param('target_date')) {
+    # DateTime::Format::DateParse would be better
     my ($mm, $dd, $yy) = split /[\-\/]/,$cgi->param('target_date');
+    ( $yy, $mm, $dd ) = ( $mm, $dd, $yy ) if $mm > 1900;
+
     $target_dt = DateTime->new(
       month  => $mm,
       day    => $dd,
       year   => $yy,
       %noon,
-    ) if $mm && $dd & $yy;
+    ) if $mm && $dd && $yy;
 
     # Catch a date from the past: time only travels in one direction
-    $target_dt = undef if $target_dt->epoch < $now_dt->epoch;
+    $target_dt = undef
+      unless $target_dt && $now_dt && $now_dt <=  $target_dt;
   }
 
   # without a target date, default to tomorrow
@@ -77,6 +94,13 @@ results.
     $target_dt = $now_dt->clone->add( days => 1 );
   }
 
+  my $report_title = FS::cust_payby->future_autobill_report_title;
+  my $report_subtitle = sprintf(
+    '(%s through %s)',
+    $now_dt->mdy('/'),
+    $target_dt->mdy('/'),
+  );
+
   # Create a range of dates from today until the given report date
   #   (leaving the probably useless 'quick-report' mode, but disabled)
   if ( 1 || $cgi->param('multiple_billing_dates')) {
@@ -104,6 +128,9 @@ results.
       . ($agentnum ? "AND cust_main.agentnum = $agentnum" : ''),
   });
 
+  my $completion_target = scalar(keys %cust_payby) * scalar( @target_dates );
+  my $completion_progress = 0;
+
   my $fakebill_time = time();
   my %abreport;
   my @rows;
@@ -125,6 +152,9 @@ results.
     local $FS::cust_main::Billing_Realtime::BOP_TESTING = 1;
     local $FS::cust_main::Billing_Realtime::BOP_TESTING_SUCCESS = 1;
 
+    my $savepoint_label = 'future_autobill';
+    savepoint_create( $savepoint_label );
+
     warn sprintf "Report involves %s customers", scalar keys %cust_payby
       if $DEBUG;
 
@@ -153,8 +183,18 @@ results.
         );
 
         warn "!!! $error (simulating future billing)\n" if $error;
+
+        my $statustext = sprintf(
+            '%s,Simulating upcoming invoices and payments',
+            int( ( ++$completion_progress / $completion_target ) * 100 )
+        );
+        $job->update_statustext( $statustext ) if $job;
+        warn "[ $completion_progress / $completion_target ] $statustext\n"
+          if $DEBUG;
+
       }
 
+
       # Generate report rows from recorded payments in cust_pay
       for my $cust_pay (
         qsearch( cust_pay => {
@@ -206,6 +246,7 @@ results.
       #   locked at a time
 
       warn "-- custnum $custnum -- rollback()\n" if $DEBUG;
+      savepoint_rollback( $savepoint_label );
       dbh->rollback if $oldAutoCommit;
 
     } # /foreach $custnum
diff --git a/httemplate/search/report_future_autobill-queued_job.html b/httemplate/search/report_future_autobill-queued_job.html
new file mode 100644
index 000000000..d23efb5b1
--- /dev/null
+++ b/httemplate/search/report_future_autobill-queued_job.html
@@ -0,0 +1,11 @@
+<% $server->process %>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+my $server = new FS::UI::Web::JSRPC
+  'FS::Report::Queued::FutureAutobill::make_report',
+  $cgi;
+
+</%init>
diff --git a/httemplate/search/report_future_autobill.html b/httemplate/search/report_future_autobill.html
index ccde299e9..28f589ee7 100644
--- a/httemplate/search/report_future_autobill.html
+++ b/httemplate/search/report_future_autobill.html
@@ -1,6 +1,9 @@
 <%doc>
 
-Display date selector for the future_autobill.html report
+Display pre-report page for the Future Auto Bill Transactions report
+
+Report runs in the queue.  Once the report is generated, user is
+redirected to the report results.
 
 </%doc>
 <% include('/elements/header.html', $report_title ) %>
@@ -14,30 +17,43 @@ Display date selector for the future_autobill.html report
 
 % } else {
 
-  <FORM ACTION="future_autobill.html" METHOD="GET">
-  <TABLE>
-  <& /elements/tr-input-date-field.html,
-    {
-      name     => 'target_date',
-      value    => $target_date,
-      label    => emt('Target billing date').': ',
-      required => 1
-    }
-  &>
-
-  <% include('/elements/tr-select-agent.html',
-              'label'         => 'For agent: ',
-              'disable_empty' => 0,
+  <FORM NAME="future_autobill" ID="future_autobill">
+    <TABLE>
+    <& /elements/tr-input-date-field.html,
+      {
+        name     => 'target_date',
+        value    => $target_date,
+        label    => emt('Target billing date').': ',
+        required => 1
+      }
+    &>
+
+    <% include('/elements/tr-select-agent.html',
+                'label'         => 'For agent: ',
+                'disable_empty' => 0,
+              )
+    %>
+    </TABLE>
+    <BR>
+
+    <INPUT ID="future_autobill_submit" TYPE="submit" VALUE="<% mt('Get Report') |h %>">
+  </FORM>
+
+  <% include( '/elements/progress-init.html',
+              'future_autobill',
+              [ qw( agentnum target_date ) ],
+              'report_future_autobill-queued_job.html',
             )
   %>
 
-  </TABLE>
-
-  <BR>
-
-  <INPUT TYPE="submit" VALUE="<% mt('Get Report') |h %>">
-
-  </FORM>
+  <script type="text/javascript">
+    $('#future_autobill').submit( function( event ) {
+      $('#future_autobill').prop( 'disabled', true );
+      $('#future_autobill_submit').prop( 'disabled', true );
+      event.preventDefault();
+      process();
+    });
+  </script>
 
 % }
 

commit d727ecee34916db4c21ae09d258faa243ab7faa9
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Tue Sep 11 06:06:33 2018 -0400

    RT# 78547 Future autobill report - dynamic navigation

diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html
index 02a506772..7ab5edcfc 100644
--- a/httemplate/elements/menu.html
+++ b/httemplate/elements/menu.html
@@ -418,7 +418,9 @@ if( $curuser->access_right('Financial reports') ) {
 
   $report_financial{'Customer Accounting Summary'} = [ $fsurl.'search/report_customer_accounting_summary.html', 'Customer accounting summary report' ];
 
-  $report_financial{'Upcoming Auto-Bill Transactions'} = [ $fsurl.'search/report_future_autobill.html', 'Upcoming auto-bill transactions' ];
+  if ( my $report_title = FS::cust_payby->future_autobill_report_title ) {
+    $report_financial{$report_title} = [ $fsurl.'search/report_future_autobill.html', "$report_title for customers with automatic payment methods (by date)" ];
+  }
 
 } elsif($curuser->access_right('Receivables report')) {
 

commit fe14c0ff8a609d9488c40e81eb25babfa691b34c
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Tue Sep 11 05:51:11 2018 -0400

    RT# 78547 Future autobill report - agent virt, dynamic title

diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm
index 301eb6106..9d8be120a 100644
--- a/FS/FS/cust_payby.pm
+++ b/FS/FS/cust_payby.pm
@@ -1,5 +1,6 @@
 package FS::cust_payby;
 use base qw( FS::payinfo_Mixin FS::cust_main_Mixin FS::Record );
+use feature 'state';
 
 use strict;
 use Scalar::Util qw( blessed );
@@ -914,31 +915,79 @@ sub search_sql {
 
 =back
 
-=item count_autobill_cards
+=item has_autobill_cards
 
 Returns the number of unexpired cards configured for autobill
 
 =cut
 
-sub count_autobill_cards {
-  shift->count("
-    weight > 0
-    AND payby IN ('CARD','DCRD')
-    AND paydate > '".DateTime->now->ymd."'
-  ");
+sub has_autobill_cards {
+  scalar FS::Record::qsearch({
+    table     => 'cust_payby',
+    addl_from => 'JOIN cust_main USING (custnum)',
+    order_by  => 'LIMIT 1',
+    hashref   => {
+        paydate => { op => '>', value => DateTime->now->ymd },
+        weight  => { op => '>',  value => 0 },
+    },
+    extra_sql =>
+      "AND payby IN ('CARD', 'DCRD') ".
+      'AND '.
+      $FS::CurrentUser::CurrentUser->agentnums_sql( table => 'cust_main' ),
+  });
 }
 
-=item count_autobill_checks
+=item has_autobill_checks
 
 Returns the number of check accounts configured for autobill
 
 =cut
 
-sub count_autobill_checks {
-  shift->count("
-    weight > 0
-    AND payby IN ('CHEK','DCHEK')
-  ");
+sub has_autobill_checks {
+  scalar FS::Record::qsearch({
+    table     => 'cust_payby',
+    addl_from => 'JOIN cust_main USING (custnum)',
+    order_by  => 'LIMIT 1',
+    hashref   => {
+        weight  => { op => '>',  value => 0 },
+    },
+    extra_sql =>
+      "AND payby IN ('CHEK','DCHEK','DCHK') ".
+      'AND '.
+      $FS::CurrentUser::CurrentUser->agentnums_sql( table => 'cust_main' ),
+  });
+}
+
+=item future_autobill_report_title
+
+Determine if the future_autobill report should be available.
+If so, return a dynamic title for it
+
+=cut
+
+sub future_autobill_report_title {
+  # Perhaps this function belongs somewhere else
+  state $title;
+  return $title if defined $title;
+
+  # Report incompatible with tax engines
+  return $title = '' if FS::TaxEngine->new->info->{batch};
+
+  my $has_cards  = has_autobill_cards();
+  my $has_checks = has_autobill_checks();
+  my $_title = 'Future %s transactions';
+
+  if ( $has_cards && $has_checks ) {
+    $title = sprintf $_title, 'credit card and electronic check';
+  } elsif ( $has_cards ) {
+    $title = sprintf $_title, 'credit card';
+  } elsif ( $has_checks ) {
+    $title = sprintf $_title, 'electronic check';
+  } else {
+    $title = '';
+  }
+
+  $title;
 }
 
 sub _upgrade_data {
diff --git a/httemplate/search/future_autobill.html b/httemplate/search/future_autobill.html
index d6438d9dc..d4ad8e524 100644
--- a/httemplate/search/future_autobill.html
+++ b/httemplate/search/future_autobill.html
@@ -30,13 +30,17 @@ results.
 &>
 
 <%init>
-  use FS::UID qw( dbh myconnect );
+  use FS::UID qw( dbh );
 
   die "access denied"
     unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
   my $DEBUG = $cgi->param('DEBUG') || 0;
 
+  my $report_title = FS::cust_payby->future_autobill_report_title;
+  my $agentnum = $cgi->param('agentnum')
+    if $cgi->param('agentnum') =~ /^\d+/;
+
   my $target_dt;
   my @target_dates;
 
@@ -87,17 +91,17 @@ results.
 
   # List all customers with an auto-bill method that's not expired
   my %cust_payby = map {$_->custnum => $_} qsearch({
-    table => 'cust_payby',
-    hashref => {
-      weight  => { op => '>', value => '0' },
-    },
-    order_by => " ORDER BY weight DESC ",
-    extra_sql => "
-      AND (
-        payby IN ('CHEK','DCHK')
+    table     => 'cust_payby',
+    addl_from => 'JOIN cust_main USING (custnum)',
+    hashref   => {  weight  => { op => '>', value => '0' }},
+    order_by  => " ORDER BY weight DESC ",
+    extra_sql =>
+      "AND (
+        payby IN ('CHEK','DCHK','DCHEK')
         OR ( paydate > '".$target_dt->ymd."')
       )
-    ",
+      AND " . $FS::CurrentUser::CurrentUser->agentnums_sql
+      . ($agentnum ? "AND cust_main.agentnum = $agentnum" : ''),
   });
 
   my $fakebill_time = time();
@@ -109,7 +113,7 @@ results.
 
   eval { # Sandbox
 
-    # Create new database handle and supress all COMMIT statements
+    # Supress COMMIT statements
     my $oldAutoCommit = $FS::UID::AutoCommit;
     local $FS::UID::AutoCommit = 0;
     local $FS::UID::ForceObeyAutoCommit = 1;
@@ -201,7 +205,7 @@ results.
       # Makes the report slighly slower, but ensures only one customer row
       #   locked at a time
 
-      warn "-- custnum $custnum -- rollback()\n";
+      warn "-- custnum $custnum -- rollback()\n" if $DEBUG;
       dbh->rollback if $oldAutoCommit;
 
     } # /foreach $custnum
@@ -226,21 +230,4 @@ results.
   # grid-report.html requires a parallel @rows parameter to accompany @cells
   @rows = map { {class => 'gridreport'} } 1..scalar(@cells);
 
-  # Dynamic report title
-  my $title_types = '';
-  my $card_count = FS::cust_payby->count_autobill_cards;
-  my $check_count = FS::cust_payby->count_autobill_checks;
-  if ( $card_count && $check_count ) {
-    $title_types = 'Card and Check';
-  } elsif ( $card_count ) {
-    $title_types = 'Card';
-  } elsif ( $check_count ) {
-    $title_types = 'Check';
-  }
-
-  my $report_title = sprintf(
-    'Upcoming Auto Bill %s Transactions',
-    $title_types,
-  );
-
 </%init>
diff --git a/httemplate/search/report_future_autobill.html b/httemplate/search/report_future_autobill.html
index ff2f85715..ccde299e9 100644
--- a/httemplate/search/report_future_autobill.html
+++ b/httemplate/search/report_future_autobill.html
@@ -25,6 +25,12 @@ Display date selector for the future_autobill.html report
     }
   &>
 
+  <% include('/elements/tr-select-agent.html',
+              'label'         => 'For agent: ',
+              'disable_empty' => 0,
+            )
+  %>
+
   </TABLE>
 
   <BR>
@@ -39,28 +45,13 @@ Display date selector for the future_autobill.html report
 
 <%init>
 use FS::cust_payby;
+use FS::CurrentUser;
 
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
 my $target_date = DateTime->now->add(days => 1)->mdy('/');
-
-# Dynamic report title
-my $title_types = '';
-my $card_count = FS::cust_payby->count_autobill_cards;
-my $check_count = FS::cust_payby->count_autobill_checks;
-if ( $card_count && $check_count ) {
-  $title_types = 'Card and Check';
-} elsif ( $card_count ) {
-  $title_types = 'Card';
-} elsif ( $check_count ) {
-  $title_types = 'Check';
-}
-
-my $report_title = sprintf(
-  'Upcoming Auto Bill %s Transactions',
-  $title_types,
-);
+my $report_title = FS::cust_payby->future_autobill_report_title;
 
 </%init>
 

commit 2f330afab567fda7679bfe24588598e3e5537467
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Tue Sep 11 03:33:33 2018 -0400

    RT# 78547 Upcoming Auto-Bill Transaction Report

diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm
index c497059fa..301eb6106 100644
--- a/FS/FS/cust_payby.pm
+++ b/FS/FS/cust_payby.pm
@@ -914,8 +914,33 @@ sub search_sql {
 
 =back
 
+=item count_autobill_cards
+
+Returns the number of unexpired cards configured for autobill
+
+=cut
+
+sub count_autobill_cards {
+  shift->count("
+    weight > 0
+    AND payby IN ('CARD','DCRD')
+    AND paydate > '".DateTime->now->ymd."'
+  ");
+}
+
+=item count_autobill_checks
+
+Returns the number of check accounts configured for autobill
+
 =cut
 
+sub count_autobill_checks {
+  shift->count("
+    weight > 0
+    AND payby IN ('CHEK','DCHEK')
+  ");
+}
+
 sub _upgrade_data {
 
   my $class = shift;
diff --git a/httemplate/search/future_autobill.html b/httemplate/search/future_autobill.html
index 711a25f82..d6438d9dc 100644
--- a/httemplate/search/future_autobill.html
+++ b/httemplate/search/future_autobill.html
@@ -2,20 +2,18 @@
 
 Report listing upcoming auto-bill transactions
 
-Spec requested the ability to run this report with a longer date range,
-and see which charges will process on which day.  Checkbox multiple_billing_dates
-enables this functionality.
+For every customer with a valid auto-bill payment method,
+report runs bill_and_collect() for each customer, for each
+day, from today through the report target date.  After
+recording the results, all operations are rolled back.
 
-Performance:
-This is a dynamically generated report.  The time this report takes to run
-will depends on the number of customers.  Installations with a high number
-of auto-bill customers may find themselves unable to run this report
-because of browser timeout.  Report could be implemented as a queued job if
-necessary, to solve the performance problem.
+This report relies on the ability to safely run bill_and_collect(),
+with all exports and messaging disabled, and then to roll back the
+results.
 
 </%doc>
 <& elements/grid-report.html,
-  title => 'Upcoming auto-bill transactions',
+  title => $report_title,
   rows => \@rows,
   cells => \@cells,
   table_width => "",
@@ -32,11 +30,12 @@ necessary, to solve the performance problem.
 &>
 
 <%init>
+  use FS::UID qw( dbh myconnect );
 
-use FS::UID qw( dbh myconnect );
+  die "access denied"
+    unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
-die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+  my $DEBUG = $cgi->param('DEBUG') || 0;
 
   my $target_dt;
   my @target_dates;
@@ -45,14 +44,13 @@ die "access denied"
   my %noon = (
     hour   => 12,
     minute => 0,
-    second => 0
+    second => 0,
   );
-
   my $now_dt = DateTime->now;
   $now_dt = DateTime->new(
-    month => $now_dt->month,
-    day   => $now_dt->day,
-    year  => $now_dt->year,
+    month  => $now_dt->month,
+    day    => $now_dt->day,
+    year   => $now_dt->year,
     %noon,
   );
 
@@ -60,9 +58,9 @@ die "access denied"
   if ($cgi->param('target_date')) {
     my ($mm, $dd, $yy) = split /[\-\/]/,$cgi->param('target_date');
     $target_dt = DateTime->new(
-      month => $mm,
-      day   => $dd,
-      year  => $yy,
+      month  => $mm,
+      day    => $dd,
+      year   => $yy,
       %noon,
     ) if $mm && $dd & $yy;
 
@@ -72,18 +70,12 @@ die "access denied"
 
   # without a target date, default to tomorrow
   unless ($target_dt) {
-    $target_dt = DateTime->from_epoch( epoch => time() + 86400) ;
-    $target_dt = DateTime->new(
-      month => $target_dt->month,
-      day   => $target_dt->day,
-      year  => $target_dt->year,
-      %noon
-    );
+    $target_dt = $now_dt->clone->add( days => 1 );
   }
 
-  # If multiple_billing_dates checkbox selected, create a range of dates
-  # from today until the given report date.  Otherwise, use target date only.
-  if ($cgi->param('multiple_billing_dates')) {
+  # Create a range of dates from today until the given report date
+  #   (leaving the probably useless 'quick-report' mode, but disabled)
+  if ( 1 || $cgi->param('multiple_billing_dates')) {
     my $walking_dt = DateTime->from_epoch(epoch => $now_dt->epoch);
     until ($walking_dt->epoch > $target_dt->epoch) {
      push @target_dates, $walking_dt->epoch;
@@ -93,17 +85,6 @@ die "access denied"
     push @target_dates, $target_dt->epoch;
   }
 
-  # List all customers with an auto-bill method
-  #
-  # my %cust_payby = map {$_->custnum => $_} qsearch({
-  #   table => 'cust_payby',
-  #   hashref => {
-  #     weight  => { op => '>', value => '0' },
-  #     paydate => { op => '>', value => $target_dt->ymd },
-  #   },
-  #   order_by => " ORDER BY weight DESC ",
-  # });
-
   # List all customers with an auto-bill method that's not expired
   my %cust_payby = map {$_->custnum => $_} qsearch({
     table => 'cust_payby',
@@ -111,62 +92,121 @@ die "access denied"
       weight  => { op => '>', value => '0' },
     },
     order_by => " ORDER BY weight DESC ",
-    extra_sql => " AND ( payby = 'CHEK' OR ( paydate > '".$target_dt->ymd."')) ",
+    extra_sql => "
+      AND (
+        payby IN ('CHEK','DCHK')
+        OR ( paydate > '".$target_dt->ymd."')
+      )
+    ",
   });
 
+  my $fakebill_time = time();
   my %abreport;
   my @rows;
 
   local $@;
   local $SIG{__DIE__};
-  my $temp_dbh = myconnect();
-  eval { # Creating sandbox dbh where all connections are to be rolled back
-    local $FS::UID::dbh = $temp_dbh;
+
+  eval { # Sandbox
+
+    # Create new database handle and supress all COMMIT statements
+    my $oldAutoCommit = $FS::UID::AutoCommit;
     local $FS::UID::AutoCommit = 0;
+    local $FS::UID::ForceObeyAutoCommit = 1;
+
+    # Suppress notices generated by billing events
+    local $FS::Misc::DISABLE_ALL_NOTICES = 1;
 
-    # Generate report data into @rows
+    # Bypass payment processing, recording a fake payment
+    local $FS::cust_main::Billing_Realtime::BOP_TESTING = 1;
+    local $FS::cust_main::Billing_Realtime::BOP_TESTING_SUCCESS = 1;
+
+    warn sprintf "Report involves %s customers", scalar keys %cust_payby
+      if $DEBUG;
+
+    # Run bill_and_collect(), for each customer with an autobill payment method,
+    # for each day represented in the report
     for my $custnum (keys %cust_payby) {
       my $cust_main = qsearchs('cust_main', {custnum => $custnum});
 
+      warn "-- Processing custnum $custnum\n"
+        if $DEBUG;
+
       # walk forward through billing dates
       for my $query_epoch (@target_dates) {
+        $FS::cust_main::Billing_Realtime::BOP_TESTING_TIMESTAMP = $query_epoch;
         my $return_bill = [];
 
-        eval { # Don't let an error on one customer crash the report
-          my $error = $cust_main->bill(
-            time           => $query_epoch,
-            return_bill    => $return_bill,
-            no_usage_reset => 1,
-          );
-          die "$error (simulating future billing)" if $error;
-        };
-        warn ("$@: (future_autobill custnum:$custnum)");
-
-        if (@{$return_bill}) {
-          my $inv = $return_bill->[0];
-          push @rows,{
-            name => $cust_main->name,
-            _date => $inv->_date,
-            cells => [
-              { class => 'gridreport', value => $custnum },
-              { class => 'gridreport',
-                value => '<a href="/view/cust_main.cgi?"'.$custnum.'">'.$cust_main->name.'</a>',
-                bypass_filter => 1,
-              },
-              { class => 'gridreport', value => $inv->charged, format => 'money' },
-              { class => 'gridreport', value => DateTime->from_epoch(epoch=>$inv->_date)->ymd },
-              { class => 'gridreport', value => ($cust_payby{$custnum}->payby || $cust_payby{$custnum}->paytype) },
-              { class => 'gridreport', value => $cust_payby{$custnum}->paymask },
-            ]
-          };
-        }
+        warn "---- Set billtime to ".
+             DateTime->from_epoch( epoch => $query_epoch )."\n"
+                if $DEBUG;
+
+        my $error = $cust_main->bill_and_collect(
+          time           => $query_epoch,
+          return_bill    => $return_bill,
+          no_usage_reset => 1,
+          fake           => 1,
+        );
 
+        warn "!!! $error (simulating future billing)\n" if $error;
       }
-      $temp_dbh->rollback;
-    } # /foreach $custnum
 
+      # Generate report rows from recorded payments in cust_pay
+      for my $cust_pay (
+        qsearch( cust_pay => {
+          custnum => $custnum,
+          _date   => { op => '>=', value => $fakebill_time },
+        })
+      ) {
+        push @rows,{
+          name  => $cust_main->name,
+          _date => $cust_pay->_date,
+          cells => [
+
+            # Customer number
+            { class => 'gridreport', value => $custnum },
+
+            # Customer name / customer link
+            { class => 'gridreport',
+              value =>  qq{<a href="${fsurl}view/cust_main.cgi?${custnum}">} . encode_entities( $cust_main->name ). '</a>',
+              bypass_filter => 1
+            },
+
+            # Amount
+            { class => 'gridreport',
+              value => $cust_pay->paid,
+              format => 'money'
+            },
+
+            # Transaction Date
+            { class => 'gridreport',
+              value => DateTime->from_epoch( epoch => $cust_pay->_date )->ymd
+            },
+
+            # Payment Method
+            { class => 'gridreport',
+              value => encode_entities( $cust_pay->paycardtype || $cust_pay->payby ),
+            },
+
+            # Masked Payment Instrument
+            { class => 'gridreport',
+              value => encode_entities( $cust_pay->paymask ),
+            },
+          ]
+        };
+
+      } # /foreach payment
+
+      # Roll back database at the end of each customer
+      # Makes the report slighly slower, but ensures only one customer row
+      #   locked at a time
+
+      warn "-- custnum $custnum -- rollback()\n";
+      dbh->rollback if $oldAutoCommit;
+
+    } # /foreach $custnum
   }; # /eval
-  warn("$@") if $@;
+  warn("future_autobill.html report generated error $@") if $@;
 
   # Sort output by date, and format for output to grid-report.html
   my @cells = [
@@ -186,4 +226,21 @@ die "access denied"
   # grid-report.html requires a parallel @rows parameter to accompany @cells
   @rows = map { {class => 'gridreport'} } 1..scalar(@cells);
 
+  # Dynamic report title
+  my $title_types = '';
+  my $card_count = FS::cust_payby->count_autobill_cards;
+  my $check_count = FS::cust_payby->count_autobill_checks;
+  if ( $card_count && $check_count ) {
+    $title_types = 'Card and Check';
+  } elsif ( $card_count ) {
+    $title_types = 'Card';
+  } elsif ( $check_count ) {
+    $title_types = 'Check';
+  }
+
+  my $report_title = sprintf(
+    'Upcoming Auto Bill %s Transactions',
+    $title_types,
+  );
+
 </%init>
diff --git a/httemplate/search/report_future_autobill.html b/httemplate/search/report_future_autobill.html
index 1a0c9f48a..ff2f85715 100644
--- a/httemplate/search/report_future_autobill.html
+++ b/httemplate/search/report_future_autobill.html
@@ -3,40 +3,64 @@
 Display date selector for the future_autobill.html report
 
 </%doc>
-<% include('/elements/header.html', 'Future Auto-Bill Transactions' ) %>
+<% include('/elements/header.html', $report_title ) %>
 
 
-<FORM ACTION="future_autobill.html" METHOD="GET">
-<TABLE>
-<& /elements/tr-input-date-field.html,
-  {
-    name     => 'target_date',
-    value    => $target_date,
-    label    => emt('Target billing date').': ',
-    required => 1
-  }
-&>
+% if ( FS::TaxEngine->new->info->{batch} ) {
 
-<& /elements/tr-checkbox.html,
-     'label' => emt('Multiple billing dates (slow)').': ',
-     'field' => 'multiple_billing_dates',
-     'value' => '1',
-&>
+  <div style="font-color: red">
+  NOTE: This report is disabled due to tax engine configuration
+  </div>
 
-</TABLE>
+% } else {
 
-<BR>
-<INPUT TYPE="submit" VALUE="<% mt('Get Report') |h %>">
+  <FORM ACTION="future_autobill.html" METHOD="GET">
+  <TABLE>
+  <& /elements/tr-input-date-field.html,
+    {
+      name     => 'target_date',
+      value    => $target_date,
+      label    => emt('Target billing date').': ',
+      required => 1
+    }
+  &>
 
-</FORM>
+  </TABLE>
+
+  <BR>
+
+  <INPUT TYPE="submit" VALUE="<% mt('Get Report') |h %>">
+
+  </FORM>
+
+% }
 
 <% include('/elements/footer.html') %>
 
 <%init>
+use FS::cust_payby;
 
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
-my $target_date = DateTime->from_epoch(epoch=>(time()+86400))->mdy('/');
+my $target_date = DateTime->now->add(days => 1)->mdy('/');
+
+# Dynamic report title
+my $title_types = '';
+my $card_count = FS::cust_payby->count_autobill_cards;
+my $check_count = FS::cust_payby->count_autobill_checks;
+if ( $card_count && $check_count ) {
+  $title_types = 'Card and Check';
+} elsif ( $card_count ) {
+  $title_types = 'Card';
+} elsif ( $check_count ) {
+  $title_types = 'Check';
+}
+
+my $report_title = sprintf(
+  'Upcoming Auto Bill %s Transactions',
+  $title_types,
+);
 
 </%init>
+

commit 94f0030bae0ce3e493b99860901158e30e9651fd
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Tue Sep 11 03:23:52 2018 -0400

    RT# 78547 Allow for simulated billing within a transaction

diff --git a/FS/FS/Misc/Savepoint.pm b/FS/FS/Misc/Savepoint.pm
index b15b36ded..f8e2c5ff5 100644
--- a/FS/FS/Misc/Savepoint.pm
+++ b/FS/FS/Misc/Savepoint.pm
@@ -55,7 +55,7 @@ Savepoints cannot work while AutoCommit is enabled.
 Savepoint labels must be valid sql identifiers.  If your choice of label
 would not make a valid column name, it probably will not make a valid label.
 
-Savepint labels must be unique within the transaction.
+Savepoint labels must be unique within the transaction.
 
 =cut
 
diff --git a/FS/FS/UID.pm b/FS/FS/UID.pm
index 50a917895..693e5d952 100644
--- a/FS/FS/UID.pm
+++ b/FS/FS/UID.pm
@@ -5,7 +5,7 @@ use strict;
 use vars qw(
   @EXPORT_OK $DEBUG $me $cgi $freeside_uid $conf_dir $cache_dir
   $secrets $datasrc $db_user $db_pass $schema $dbh $driver_name
-  $AutoCommit %callback @callback $callback_hack
+  $AutoCommit $ForceObeyAutoCommit %callback @callback $callback_hack
 );
 use subs qw( getsecrets );
 use Carp qw( carp croak cluck confess );
@@ -26,7 +26,17 @@ $freeside_uid = scalar(getpwnam('freeside'));
 $conf_dir  = "%%%FREESIDE_CONF%%%";
 $cache_dir = "%%%FREESIDE_CACHE%%%";
 
+# Code wanting to issue a COMMIT statement to the database is expected to
+# obey the convention of checking this flag first.  Setting $AutoCommit = 0
+# should (usually) suppress COMMIT statements.
 $AutoCommit = 1; #ours, not DBI
+
+# Not all methods obey $AutoCommit, by design choice.  Setting
+# $ForceObeyAutoCommit = 1 will override that design choice for:
+#   &FS::cust_main::Billing::collect
+#   &FS::cust_main::Billing::do_cust_event
+$ForceObeyAutoCommit = 0;
+
 $callback_hack = 0;
 
 =head1 NAME
diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm
index dc63fdb88..84487d2ce 100644
--- a/FS/FS/cust_bill.pm
+++ b/FS/FS/cust_bill.pm
@@ -41,6 +41,7 @@ use FS::cust_bill_void;
 use FS::reason;
 use FS::reason_type;
 use FS::L10N;
+use FS::Misc::Savepoint;
 
 $DEBUG = 0;
 $me = '[FS::cust_bill]';
@@ -971,6 +972,9 @@ sub apply_payments_and_credits {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  my $savepoint_label = 'cust_bill__apply_payments_and_credits';
+  savepoint_create( $savepoint_label );
+
   $self->select_for_update; #mutex
 
   my @payments = grep { $_->unapplied > 0 } 
@@ -1059,6 +1063,7 @@ sub apply_payments_and_credits {
 
     my $error = $app->insert(%options);
     if ( $error ) {
+      savepoint_rollback_and_release( $savepoint_label );
       $dbh->rollback if $oldAutoCommit;
       return "Error inserting ". $app->table. " record: $error";
     }
@@ -1066,6 +1071,7 @@ sub apply_payments_and_credits {
 
   }
 
+  savepoint_release( $savepoint_label );
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   ''; #no error
 
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 64b002ba1..aa8ae2a6a 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -79,6 +79,7 @@ use FS::sales;
 use FS::cust_payby;
 use FS::contact;
 use FS::reason;
+use FS::Misc::Savepoint;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -2441,11 +2442,15 @@ sub cancel_pkgs {
   my( $self, %opt ) = @_;
 
   # we're going to cancel services, which is not reversible
+  #   unless exports are suppressed
   die "cancel_pkgs cannot be run inside a transaction"
-    if $FS::UID::AutoCommit == 0;
+    if !$FS::UID::AutoCommit && !$FS::svc_Common::noexport_hack;
 
+  my $oldAutoCommit = $FS::UID::AutoCommit;
   local $FS::UID::AutoCommit = 0;
 
+  savepoint_create('cancel_pkgs');
+
   return ( 'access denied' )
     unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
 
@@ -2462,7 +2467,8 @@ sub cancel_pkgs {
       my $ban = new FS::banned_pay $cust_payby->_new_banned_pay_hashref;
       my $error = $ban->insert;
       if ($error) {
-        dbh->rollback;
+        savepoint_rollback_and_release('cancel_pkgs');
+        dbh->rollback if $oldAutoCommit;
         return ( $error );
       }
 
@@ -2482,13 +2488,14 @@ sub cancel_pkgs {
                              'time'     => $cancel_time );
     if ($error) {
       warn "Error billing during cancel, custnum ". $self->custnum. ": $error";
-      dbh->rollback;
+      savepoint_rollback_and_release('cancel_pkgs');
+      dbh->rollback if $oldAutoCommit;
       return ( "Error billing during cancellation: $error" );
     }
   }
-  dbh->commit;
+  savepoint_release('cancel_pkgs');
+  dbh->commit if $oldAutoCommit;
 
-  $FS::UID::AutoCommit = 1;
   my @errors;
   # now cancel all services, the same way we would for individual packages.
   # if any of them fail, cancel the rest anyway.
@@ -2501,11 +2508,23 @@ sub cancel_pkgs {
   warn "$me removing ".scalar(@sorted_cust_svc)." service(s) for customer ".
     $self->custnum."\n"
     if $DEBUG;
+  my $i = 0;
   foreach my $cust_svc (@sorted_cust_svc) {
+    my $savepoint = 'cancel_pkgs_'.$i++;
+    savepoint_create( $savepoint );
     my $part_svc = $cust_svc->part_svc;
     next if ( defined($part_svc) and $part_svc->preserve );
-    my $error = $cust_svc->cancel; # immediate cancel, no date option
-    push @errors, $error if $error;
+    # immediate cancel, no date option
+    # transactionize individually
+    my $error = try { $cust_svc->cancel } catch { $_ };
+    if ( $error ) {
+      savepoint_rollback_and_release( $savepoint );
+      dbh->rollback if $oldAutoCommit;
+      push @errors, $error;
+    } else {
+      savepoint_release( $savepoint );
+      dbh->commit if $oldAutoCommit;
+    }
   }
   if (@errors) {
     return @errors;
@@ -2520,8 +2539,11 @@ sub cancel_pkgs {
     @cprs = @{ delete $opt{'cust_pkg_reason'} };
   }
   my $null_reason;
+  $i = 0;
   foreach (@pkgs) {
     my %lopt = %opt;
+    my $savepoint = 'cancel_pkgs_'.$i++;
+    savepoint_create( $savepoint );
     if (@cprs) {
       my $cpr = shift @cprs;
       if ( $cpr ) {
@@ -2541,7 +2563,14 @@ sub cancel_pkgs {
       }
     }
     my $error = $_->cancel(%lopt);
-    push @errors, 'pkgnum '.$_->pkgnum.': '.$error if $error;
+    if ( $error ) {
+      savepoint_rollback_and_release( $savepoint );
+      dbh->rollback if $oldAutoCommit;
+      push @errors, 'pkgnum '.$_->pkgnum.': '.$error;
+    } else {
+      savepoint_release( $savepoint );
+      dbh->commit if $oldAutoCommit;
+    }
   }
 
   return @errors;
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index 71d5c9b81..1be7d39f9 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -26,6 +26,7 @@ use FS::pkg_category;
 use FS::FeeOrigin_Mixin;
 use FS::Log;
 use FS::TaxEngine;
+use FS::Misc::Savepoint;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -1753,7 +1754,10 @@ sub collect {
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   #never want to roll back an event just because it returned an error
-  local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
+  # unless $FS::UID::ForceObeyAutoCommit is set
+  local $FS::UID::AutoCommit = 1
+    unless !$oldAutoCommit
+        && $FS::UID::ForceObeyAutoCommit;
 
   $self->do_cust_event(
     'debug'      => ( $options{'debug'} || 0 ),
@@ -1961,9 +1965,13 @@ sub do_cust_event {
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
   #never want to roll back an event just because it or a different one
   # returned an error
-  local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
+  # unless $FS::UID::ForceObeyAutoCommit is set
+  local $FS::UID::AutoCommit = 1
+    unless !$oldAutoCommit
+        && $FS::UID::ForceObeyAutoCommit;
 
   foreach my $cust_event ( @$due_cust_event ) {
 
@@ -2288,16 +2296,21 @@ sub apply_payments_and_credits {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  my $savepoint_label = 'Billing__apply_payments_and_credits';
+  savepoint_create( $savepoint_label );
+
   $self->select_for_update; #mutex
 
   foreach my $cust_bill ( $self->open_cust_bill ) {
     my $error = $cust_bill->apply_payments_and_credits(%options);
     if ( $error ) {
+      savepoint_rollback_and_release( $savepoint_label );
       $dbh->rollback if $oldAutoCommit;
       return "Error applying: $error";
     }
   }
 
+  savepoint_release( $savepoint_label );
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   ''; #no error
 
diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm
index 70dc9d1c0..64b551cab 100644
--- a/FS/FS/cust_main/Billing_Realtime.pm
+++ b/FS/FS/cust_main/Billing_Realtime.pm
@@ -16,6 +16,7 @@ use FS::cust_bill_pay;
 use FS::cust_refund;
 use FS::banned_pay;
 use FS::payment_gateway;
+use FS::Misc::Savepoint;
 
 $realtime_bop_decline_quiet = 0;
 
@@ -27,6 +28,7 @@ $me = '[FS::cust_main::Billing_Realtime]';
 
 our $BOP_TESTING = 0;
 our $BOP_TESTING_SUCCESS = 1;
+our $BOP_TESTING_TIMESTAMP = '';
 
 install_callback FS::UID sub { 
   $conf = new FS::Conf;
@@ -405,7 +407,7 @@ sub realtime_bop {
 
   confess "Can't call realtime_bop within another transaction ".
           '($FS::UID::AutoCommit is false)'
-    unless $FS::UID::AutoCommit;
+    unless $FS::UID::AutoCommit || $BOP_TESTING;
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
@@ -682,7 +684,7 @@ sub realtime_bop {
   my $cust_pay_pending = new FS::cust_pay_pending {
     'custnum'           => $self->custnum,
     'paid'              => $options{amount},
-    '_date'             => '',
+    '_date'             => $BOP_TESTING ? $BOP_TESTING_TIMESTAMP : '',
     'payby'             => $bop_method2payby{$options{method}},
     'payinfo'           => $options{payinfo},
     'paymask'           => $options{paymask},
@@ -757,7 +759,7 @@ sub realtime_bop {
     return { reference => $cust_pay_pending->paypendingnum,
              map { $_ => $transaction->$_ } qw ( popup_url collectitems ) };
 
-  } elsif ( $transaction->is_success() && $action2 ) {
+  } elsif ( !$BOP_TESTING && $transaction->is_success() && $action2 ) {
 
     $cust_pay_pending->status('authorized');
     my $cpp_authorized_err = $cust_pay_pending->replace;
@@ -946,7 +948,7 @@ sub _realtime_bop_result {
        'custnum'  => $self->custnum,
        'invnum'   => $options{'invnum'},
        'paid'     => $cust_pay_pending->paid,
-       '_date'    => '',
+       '_date'    => $BOP_TESTING ? $BOP_TESTING_TIMESTAMP : '',
        'payby'    => $cust_pay_pending->payby,
        'payinfo'  => $options{'payinfo'},
        'paymask'  => $options{'paymask'} || $cust_pay_pending->paymask,
@@ -967,12 +969,16 @@ sub _realtime_bop_result {
     local $FS::UID::AutoCommit = 0;
     my $dbh = dbh;
 
+    my $savepoint_label = '_realtime_bop_result';
+    savepoint_create( $savepoint_label );
+
     #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
 
     my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
 
     if ( $error ) {
-      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+      savepoint_rollback( $savepoint_label );
+
       $cust_pay->invnum(''); #try again with no specific invnum
       $cust_pay->paynum('');
       my $error2 = $cust_pay->insert( $options{'manual'} ?
@@ -981,7 +987,8 @@ sub _realtime_bop_result {
       if ( $error2 ) {
         # gah.  but at least we have a record of the state we had to abort in
         # from cust_pay_pending now.
-        $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+        savepoint_rollback_and_release( $savepoint_label );
+
         my $e = "WARNING: $options{method} captured but payment not recorded -".
                 " error inserting payment (". $payment_gateway->gateway_module.
                 "): $error2".
@@ -996,9 +1003,10 @@ sub _realtime_bop_result {
     my $jobnum = $cust_pay_pending->jobnum;
     if ( $jobnum ) {
        my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
-      
+
        unless ( $placeholder ) {
-         $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+         savepoint_rollback_and_release( $savepoint_label );
+
          my $e = "WARNING: $options{method} captured but job $jobnum not ".
              "found for paypendingnum ". $cust_pay_pending->paypendingnum. "\n";
          warn $e;
@@ -1008,7 +1016,8 @@ sub _realtime_bop_result {
        $error = $placeholder->delete;
 
        if ( $error ) {
-         $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+        savepoint_rollback_and_release( $savepoint_label );
+
          my $e = "WARNING: $options{method} captured but could not delete ".
               "job $jobnum for paypendingnum ".
               $cust_pay_pending->paypendingnum. ": $error\n";
@@ -1030,8 +1039,8 @@ sub _realtime_bop_result {
     my $cpp_done_err = $cust_pay_pending->replace;
 
     if ( $cpp_done_err ) {
+      savepoint_rollback_and_release( $savepoint_label );
 
-      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
       my $e = "WARNING: $options{method} captured but payment not recorded - ".
               "error updating status for paypendingnum ".
               $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
@@ -1039,7 +1048,7 @@ sub _realtime_bop_result {
       return $e;
 
     } else {
-
+      savepoint_release( $savepoint_label );
       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
       if ( $options{'apply'} ) {
diff --git a/FS/FS/part_export/nena2.pm b/FS/FS/part_export/nena2.pm
index f6a730ebc..cc4069c72 100644
--- a/FS/FS/part_export/nena2.pm
+++ b/FS/FS/part_export/nena2.pm
@@ -10,6 +10,7 @@ use Date::Format qw(time2str);
 use Parse::FixedLength;
 use File::Temp qw(tempfile);
 use vars qw(%info %options $initial_load_hack $DEBUG);
+use Carp qw( carp );
 
 my %upload_targets;
 
@@ -396,6 +397,13 @@ sub process {
   my $self = shift;
   my $batch = shift;
   local $DEBUG = $self->option('debug');
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'FS::part_export::nena2::process() suppressed by noexport_hack'
+      if $DEBUG;
+    return;
+  }
+
   local $FS::UID::AutoCommit = 0;
   my $error;
 

commit b9bb2b3bc596c18245199cd799c5f50d3e02ea59
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Mon Aug 20 23:23:16 2018 -0400

    RT# 78547 Library for SQL savepoints

diff --git a/FS/FS/Misc/Savepoint.pm b/FS/FS/Misc/Savepoint.pm
new file mode 100644
index 000000000..b15b36ded
--- /dev/null
+++ b/FS/FS/Misc/Savepoint.pm
@@ -0,0 +1,160 @@
+package FS::Misc::Savepoint;
+
+use strict;
+use warnings;
+
+use Exporter;
+use vars qw( @ISA @EXPORT @EXPORT_OK );
+ at ISA = qw( Exporter );
+ at EXPORT = qw( savepoint_create savepoint_release savepoint_rollback );
+
+use FS::UID qw( dbh );
+use Carp qw( croak );
+
+=head1 NAME
+
+FS::Misc::Savepoint - Provides methods for SQL Savepoints
+
+=head1 SYNOPSIS
+
+  use FS::Misc::Savepoint;
+  
+  # Only valid within a transaction
+  local $FS::UID::AutoCommit = 0;
+  
+  savepoint_create( 'savepoint_label' );
+  
+  my $error_msg = do_some_things();
+  
+  if ( $error_msg ) {
+    savepoint_rollback_and_release( 'savepoint_label' );
+  } else {
+    savepoint_release( 'savepoint_label' );
+  }
+
+
+=head1 DESCRIPTION
+
+Provides methods for SQL Savepoints
+
+Using a savepoint allows for a partial roll-back of SQL statements without
+forcing a rollback of the entire enclosing transaction.
+
+=head1 METHODS
+
+=over 4
+
+=item savepoint_create LABEL
+
+=item savepoint_create { label => LABEL, dbh => DBH }
+
+Executes SQL to create a savepoint named LABEL.
+
+Savepoints cannot work while AutoCommit is enabled.
+
+Savepoint labels must be valid sql identifiers.  If your choice of label
+would not make a valid column name, it probably will not make a valid label.
+
+Savepint labels must be unique within the transaction.
+
+=cut
+
+sub savepoint_create {
+  my %param = _parse_params( @_ );
+
+  $param{dbh}->do("SAVEPOINT $param{label}")
+    or die $param{dbh}->errstr;
+}
+
+=item savepoint_release LABEL
+
+=item savepoint_release { label => LABEL, dbh => DBH }
+
+Release the savepoint - preserves the SQL statements issued since the
+savepoint was created, but does not commit the transaction.
+
+The savepoint label is freed for future use.
+
+=cut
+
+sub savepoint_release {
+  my %param = _parse_params( @_ );
+
+  $param{dbh}->do("RELEASE SAVEPOINT $param{label}")
+    or die $param{dbh}->errstr;
+}
+
+=item savepoint_rollback LABEL
+
+=item savepoint_rollback { label => LABEL, dbh => DBH }
+
+Roll back the savepoint - forgets all SQL statements issues since the
+savepoint was created, but does not commit or roll back the transaction.
+
+The savepoint still exists.  Additional statements may be executed,
+and savepoint_rollback called again.
+
+=cut
+
+sub savepoint_rollback {
+  my %param = _parse_params( @_ );
+
+  $param{dbh}->do("ROLLBACK TO SAVEPOINT $param{label}")
+    or die $param{dbh}->errstr;
+}
+
+=item savepoint_rollback_and_release LABEL
+
+=item savepoint_rollback_and_release { label => LABEL, dbh => DBH }
+
+Rollback and release the savepoint
+
+=cut
+
+sub savepoint_rollback_and_release {
+  savepoint_rollback( @_ );
+  savepoint_release( @_ );
+}
+
+=back
+
+=head1 METHODS - Internal
+
+=over 4
+
+=item _parse_params
+
+Create %params from function input
+
+Basic savepoint label validation
+
+Complain when trying to use savepoints without disabling AutoCommit
+
+=cut
+
+sub _parse_params {
+  my %param = ref $_[0] ? %{ $_[0] } : ( label => $_[0] );
+  $param{dbh} ||= dbh;
+
+  # Savepoints may be any valid SQL identifier up to 64 characters
+  $param{label} =~ /^\w+$/
+    or croak sprintf(
+      'Invalid savepont label(%s) - use only numbers, letters, _',
+      $param{label}
+    );
+
+  croak sprintf( 'Savepoint(%s) failed - AutoCommit=1', $param{label} )
+    if $FS::UID::AutoCommit;
+
+  %param;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+=cut
+
+1;
\ No newline at end of file

commit d1dfa92834944079595def7f1ba2d62b2f30243b
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Thu Aug 9 14:55:44 2018 -0400

    RT# 78547 bill_and_collect() small optimization

diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 22d0dcc42..64b002ba1 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -5694,6 +5694,16 @@ sub process_bill_and_collect {
   $cust_main->bill_and_collect( %$param );
 }
 
+=item pending_invoice_count
+
+Return number of cust_bill with pending=Y for this customer
+
+=cut
+
+sub pending_invoice_count {
+  FS::cust_bill->count( 'custnum = '.shift->custnum."AND pending = 'Y'" );
+}
+
 #starting to take quite a while for big dbs
 #   (JRNL: journaled so it only happens once per database)
 # - seq scan of h_cust_main (yuck), but not going to index paycvv, so
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index 9cf9b56c6..71d5c9b81 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -1,6 +1,7 @@
 package FS::cust_main::Billing;
 
 use strict;
+use feature 'state';
 use vars qw( $conf $DEBUG $me );
 use Carp;
 use Data::Dumper;
@@ -170,11 +171,8 @@ sub bill_and_collect {
 
   # In a batch tax environment, do not run collection if any pending 
   # invoices were created.  Collection will run after the next tax batch.
-  my $tax = FS::TaxEngine->new;
-  if ( $tax->info->{batch} and 
-       qsearch('cust_bill', { custnum => $self->custnum, pending => 'Y' })
-     )
-  {
+  state $is_batch_tax = FS::TaxEngine->new->info->{batch} ? 1 : 0;
+  if ( $is_batch_tax && $self->pending_invoice_count ) {
     warn "skipped collection for custnum ".$self->custnum.
          " due to pending invoices\n" if $DEBUG;
   } elsif ( $conf->exists('cancelled_cust-noevents')

commit 7b326a5bd5550034f98086ef7df1884c9cf72bb7
Author: Mitch Jackson <mitch at mitchjacksontech.com>
Date:   Tue Aug 14 20:11:23 2018 -0400

    RT# 78547 noexport_hack part_svc::sqlradius

diff --git a/FS/FS/part_export/sqlradius.pm b/FS/FS/part_export/sqlradius.pm
index 9e65e51a6..926e36fdb 100644
--- a/FS/FS/part_export/sqlradius.pm
+++ b/FS/FS/part_export/sqlradius.pm
@@ -8,7 +8,7 @@ use FS::Record qw( dbh qsearch qsearchs str2time_sql str2time_sql_closing );
 use FS::part_export;
 use FS::svc_acct;
 use FS::export_svc;
-use Carp qw( cluck );
+use Carp qw( carp cluck );
 use NEXT;
 use Net::OpenSSH;
 
@@ -489,6 +489,12 @@ sub suspended_usergroups {
 }
 
 sub sqlradius_insert { #subroutine, not method
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_insert() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my( $table, $username, %attributes ) = @_;
 
@@ -527,6 +533,12 @@ sub sqlradius_insert { #subroutine, not method
 }
 
 sub sqlradius_usergroup_insert { #subroutine, not method
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_usergroup_insert() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my $username = shift;
   my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
@@ -565,6 +577,12 @@ sub sqlradius_usergroup_insert { #subroutine, not method
 }
 
 sub sqlradius_usergroup_delete { #subroutine, not method
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_usergroup_delete() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my $username = shift;
   my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
@@ -582,6 +600,12 @@ sub sqlradius_usergroup_delete { #subroutine, not method
 }
 
 sub sqlradius_rename { #subroutine, not method
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_rename() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my($new_username, $old_username) = (shift, shift);
   my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
@@ -595,6 +619,12 @@ sub sqlradius_rename { #subroutine, not method
 }
 
 sub sqlradius_attrib_delete { #subroutine, not method
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_attrib_delete() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my( $table, $username, @attrib ) = @_;
 
@@ -609,6 +639,12 @@ sub sqlradius_attrib_delete { #subroutine, not method
 }
 
 sub sqlradius_delete { #subroutine, not method
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_delete() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my $username = shift;
   my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
@@ -883,6 +919,12 @@ sub usage_sessions {
 sub update_svc {
   my $self = shift;
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'update_svc() suppressed by noexport_hack'
+      if $self->option('debug') || $DEBUG;
+    return;
+  }
+
   my $conf = new FS::Conf;
 
   my $fdbh = dbh;
@@ -1048,6 +1090,13 @@ sub export_nas_replace { shift->export_nas_action('replace', @_); }
 sub export_nas_action {
   my $self = shift;
   my ($action, $new, $old) = @_;
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp "export_nas_action($action) suppressed by noexport_hack"
+      if $self->option('debug') || $DEBUG;
+    return;
+  }
+
   # find the NAS in the target table by its name
   my $nasname = ($action eq 'replace') ? $old->nasname : $new->nasname;
   my $nasnum = $new->nasnum;
@@ -1061,6 +1110,12 @@ sub export_nas_action {
 }
 
 sub sqlradius_nas_insert {
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_nas_insert() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my %opt = @_;
   my $nas = qsearchs('nas', { nasnum => $opt{'nasnum'} })
@@ -1075,6 +1130,12 @@ VALUES (?, ?, ?, ?, ?, ?, ?)');
 }
 
 sub sqlradius_nas_delete {
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_nas_delete() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my %opt = @_;
   my $sth = $dbh->prepare('DELETE FROM nas WHERE nasname = ?');
@@ -1082,6 +1143,12 @@ sub sqlradius_nas_delete {
 }
 
 sub sqlradius_nas_replace {
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_nas_replace() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my %opt = @_;
   my $nas = qsearchs('nas', { nasnum => $opt{'nasnum'} })
@@ -1157,6 +1224,12 @@ sub export_attr_action {
 }
 
 sub sqlradius_attr_insert {
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_attr_insert() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my %opt = @_;
 
@@ -1180,6 +1253,12 @@ sub sqlradius_attr_insert {
 }
 
 sub sqlradius_attr_delete {
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_attr_delete() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my %opt = @_;
 
@@ -1231,6 +1310,12 @@ sub export_group_replace {
 }
 
 sub sqlradius_group_replace {
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_group_replace() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my $usergroup = shift;
   $usergroup =~ /^(rad)?usergroup$/
@@ -1271,6 +1356,12 @@ Note this is NOT the opposite of sqlradius_connect.
 =cut
 
 sub sqlradius_user_disconnect {
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'sqlradius_user_disconnect() suppressed by noexport_hack' if $DEBUG;
+    return;
+  }
+
   my $dbh = sqlradius_connect(shift, shift, shift);
   my %opt = @_;
   # get list of nas

commit 32292936eacc92ddd6edb5071a7ca027dc249e8d
Author: Mitch Jackson <mitch at mitchjacksontech.com>
Date:   Tue Aug 14 19:57:38 2018 -0400

    RT# 78547 noexport_hack part_svc::sipwise

diff --git a/FS/FS/part_export/sipwise.pm b/FS/FS/part_export/sipwise.pm
index 9d4e3366e..287e604bd 100644
--- a/FS/FS/part_export/sipwise.pm
+++ b/FS/FS/part_export/sipwise.pm
@@ -14,6 +14,7 @@ use FS::Misc::DateTime qw(parse_datetime);
 use DateTime;
 use Number::Phone;
 use Try::Tiny;
+use Carp qw(carp);
 
 our $me = '[sipwise]';
 our $DEBUG = 0;
@@ -67,7 +68,7 @@ our %info = (
 END
 );
 
-sub export_insert {
+sub _export_insert {
   my($self, $svc_x) = (shift, shift);
 
   local $SIG{__DIE__};
@@ -88,7 +89,7 @@ sub export_insert {
   '';
 }
 
-sub export_replace {
+sub _export_replace {
   my ($self, $svc_new, $svc_old) = @_;
   local $SIG{__DIE__};
 
@@ -110,7 +111,7 @@ sub export_replace {
   '';
 }
 
-sub export_delete {
+sub _export_delete {
   my ($self, $svc_x) = (shift, shift);
   local $SIG{__DIE__};
 
@@ -135,7 +136,7 @@ sub export_delete {
 
 # logic to set subscribers to locked/active is in replace_subscriber
 
-sub export_suspend {
+sub _export_suspend {
   my $self = shift;
   my $svc_x = shift;
   my $role = $self->svc_role($svc_x);
@@ -148,7 +149,7 @@ sub export_suspend {
   '';
 }
 
-sub export_unsuspend {
+sub _export_unsuspend {
   my $self = shift;
   my $svc_x = shift;
   my $role = $self->svc_role($svc_x);
@@ -295,6 +296,13 @@ previously, and the one it's linked to now.
 sub export_did {
   my $self = shift;
   my ($new, $old) = @_;
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_did() suppressed by noexport_hack'
+      if $self->option('debug') || $DEBUG;
+    return;
+  }
+
   if ( $old and $new->forward_svcnum ne $old->forward_svcnum ) {
     my $old_svc_acct = $self->acct_for_did($old);
     $self->replace_subscriber( $old_svc_acct ) if $old_svc_acct;

commit cb06cc9e1aec923326a38d06a17bc1a23cee7246
Author: Mitch Jackson <mitch at mitchjacksontech.com>
Date:   Tue Aug 14 19:53:23 2018 -0400

    RT# 78547 noexport_hack part_export::shellcommands

diff --git a/FS/FS/part_export/shellcommands.pm b/FS/FS/part_export/shellcommands.pm
index 7c280e5f8..fb7d166f5 100644
--- a/FS/FS/part_export/shellcommands.pm
+++ b/FS/FS/part_export/shellcommands.pm
@@ -7,6 +7,7 @@ use String::ShellQuote;
 use Net::OpenSSH;
 use FS::part_export;
 use FS::Record qw( qsearch qsearchs );
+use Carp qw(carp);
 
 @ISA = qw(FS::part_export);
 
@@ -267,6 +268,12 @@ sub _export_unsuspend {
 sub export_pkg_change {
   my( $self, $svc_acct, $new_cust_pkg, $old_cust_pkg ) = @_;
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_pkg_change() suppressed by noexport_hack'
+      if $self->option('debug');
+    return;
+  }
+
   my @fields = qw( pkgnum pkgpart agent_pkgid ); #others?
   my @date_fields = qw( order_date start_date setup bill last_bill susp adjourn
                         resume cancel uncancel expire contract_end );
@@ -291,6 +298,13 @@ sub export_pkg_change {
 
 sub _export_command_or_super {
   my($self, $action) = (shift, shift);
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp "_export_command_or_super($action) suppressed by noexport_hack"
+      if $self->option('debug');
+    return;
+  }
+
   if ( $self->option($action) =~ /^\s*$/ ) {
     my $method = "SUPER::_export_$action";
     $self->$method(@_);
@@ -303,6 +317,12 @@ sub _export_command {
   my ( $self, $action, $svc_acct) = (shift, shift, shift);
   my $command = $self->option($action);
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp "_export_command($action) suppressed by noexport_hack"
+      if $self->option('debug');
+    return;
+  }
+
   return '' if $command =~ /^\s*$/;
   my $stdin = $self->option($action."_stdin");
 

commit c6c2d3beb9d4fc572132e1328546649c2b10697d
Author: Mitch Jackson <mitch at mitchjacksontech.com>
Date:   Tue Aug 14 19:45:53 2018 -0400

    RT# 78547 noexport_hack part_svc::saisei

diff --git a/FS/FS/part_export/saisei.pm b/FS/FS/part_export/saisei.pm
index 3af3c9d9e..6db43c11d 100644
--- a/FS/FS/part_export/saisei.pm
+++ b/FS/FS/part_export/saisei.pm
@@ -9,6 +9,7 @@ use MIME::Base64;
 use REST::Client;
 use Data::Dumper;
 use FS::Conf;
+use Carp qw(carp);
 
 =pod
 
@@ -261,6 +262,12 @@ sub _export_unsuspend {
 sub export_partsvc {
   my ($self, $svc_part) = @_;
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_partsvc() suppressed by noexport_hack'
+      if $self->option('debug');
+    return;
+  }
+
   my $fcc_477_speeds;
   if ($svc_part->{Hash}->{svc_broadband__speed_down} eq "down" || $svc_part->{Hash}->{svc_broadband__speed_up} eq "up") {
     for my $type (qw( down up )) {
@@ -312,6 +319,12 @@ sub export_partsvc {
 sub export_tower_sector {
   my ($self, $tower) = @_;
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_tower_sector() suppressed by noexport_hack'
+      if $self->option('debug');
+    return;
+  }
+
   #modify tower or create it.
   my $tower_name = $tower->{Hash}->{towername};
   $tower_name =~ s/\s/_/g;

commit 70b42b53630d363ac0db942f6b1a12dd56a092ea
Author: Mitch Jackson <mitch at mitchjacksontech.com>
Date:   Tue Aug 14 19:43:41 2018 -0400

    RT# 78547 noexport_hack part_export::phone_shellcommands

diff --git a/FS/FS/part_export/phone_shellcommands.pm b/FS/FS/part_export/phone_shellcommands.pm
index 71445bf27..3f01de36b 100644
--- a/FS/FS/part_export/phone_shellcommands.pm
+++ b/FS/FS/part_export/phone_shellcommands.pm
@@ -5,6 +5,7 @@ use vars qw(@ISA %info);
 use Tie::IxHash;
 use String::ShellQuote;
 use FS::part_export;
+use Carp qw(carp);
 
 @ISA = qw(FS::part_export);
 
@@ -103,6 +104,12 @@ sub _export_command {
   my $command = $self->option($action);
   return '' if $command =~ /^\s*$/;
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp "_export_command($action) suppressed by noexport_hack"
+      if $self->option('debug');
+    return;
+  }
+
   #set variable for the command
   no strict 'vars';
   {

commit 845c334636c4f4e3fbe1ffdf880d3ad837746823
Author: Mitch Jackson <mitch at mitchjacksontech.com>
Date:   Tue Aug 14 19:41:57 2018 -0400

    RT# 78547 noexport_hack part_svc::netsapiens

diff --git a/FS/FS/part_export/netsapiens.pm b/FS/FS/part_export/netsapiens.pm
index ac78dbca5..c6110f5ac 100644
--- a/FS/FS/part_export/netsapiens.pm
+++ b/FS/FS/part_export/netsapiens.pm
@@ -7,6 +7,7 @@ use Tie::IxHash;
 use Date::Format qw( time2str );
 use Regexp::Common qw( URI );
 use REST::Client;
+use Carp qw(carp);
 
 $me = '[FS::part_export::netsapiens]';
 
@@ -392,6 +393,12 @@ sub _export_unsuspend {
 sub export_device_insert {
   my( $self, $svc_phone, $phone_device ) = (shift, shift, shift);
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_device_insert() suppressed by noexport_hack'
+      if $self->option('debug');
+    return;
+  }
+
   my $domain = $self->ns_domain($svc_phone);
   my $countrycode = $svc_phone->countrycode;
   my $phonenum    = $svc_phone->phonenum;
@@ -426,6 +433,12 @@ sub export_device_insert {
 sub export_device_delete {
   my( $self, $svc_phone, $phone_device ) = (shift, shift, shift);
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_device_delete() suppressed by noexport_hack'
+      if $self->option('debug');
+    return;
+  }
+
   my $ns = $self->ns_device_command(
     'DELETE', $self->ns_device($svc_phone, $phone_device),
   );

commit e2c2e1c179265ee06b78b4fd3d3aa058a09270db
Author: Mitch Jackson <mitch at mitchjacksontech.com>
Date:   Tue Aug 14 19:37:07 2018 -0400

    RT# 78547 noexport_hack part_export::ikano

diff --git a/FS/FS/part_export/ikano.pm b/FS/FS/part_export/ikano.pm
index 23917bf9e..68b1a9fde 100644
--- a/FS/FS/part_export/ikano.pm
+++ b/FS/FS/part_export/ikano.pm
@@ -10,6 +10,7 @@ use FS::Record qw(qsearch qsearchs dbh);
 use FS::part_export;
 use FS::svc_dsl;
 use Data::Dumper;
+use Carp qw(carp);
 
 @ISA = qw(FS::part_export);
 $me= '[' .  __PACKAGE__ . ']';
@@ -678,7 +679,13 @@ sub _export_delete {
 
 sub export_expire {
   my($self, $svc_dsl, $date) = (shift, shift, shift);
-  
+
+  if ( $FS::svc_Common::noexport_hack ) {
+      carp 'export_expire() suppressed by noexport_hack'
+        if $self->option('debug');
+      return;
+  }
+
   return 'Invalid operation - Import Mode is enabled' if $self->import_mode;
 
   my $result = $self->valid_order($svc_dsl,'expire');

commit c6ac0d4705ef01f2cca9340c7089bae1908cae27
Author: Mitch Jackson <mitch at mitchjacksontech.com>
Date:   Tue Aug 14 19:34:50 2018 -0400

    RT# 78547 noexport_hack part_svc::http_status

diff --git a/FS/FS/part_export/http_status.pm b/FS/FS/part_export/http_status.pm
index 5c4a8d074..3e182d347 100644
--- a/FS/FS/part_export/http_status.pm
+++ b/FS/FS/part_export/http_status.pm
@@ -8,6 +8,7 @@ use URI::Escape;
 use LWP::UserAgent;
 use HTTP::Request::Common;
 use Email::Valid;
+use Carp qw(carp);
 
 tie my %options, 'Tie::IxHash',
   'url' => { label => 'URL', },
@@ -53,6 +54,12 @@ sub _export_delete  { '' };
 sub export_getstatus {
   my( $self, $svc_x, $htmlref, $hashref ) = @_;
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_getstatus() suppressed by noexport_hack'
+      if $self->option('debug') || $DEBUG;
+    return;
+  }
+
   my $url;
   my $urlopt = $self->option('url');
   no strict 'vars';
@@ -131,6 +138,12 @@ sub export_setstatus_listdel {
 sub export_setstatus_listX {
   my( $self, $svc_x, $action, $list, $address_item ) = @_;
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_setstatus_listX() suppressed by noexport_hack'
+      if $self->option('debug') || $DEBUG;
+    return;
+  }
+
   my $option;
   if ( $list =~ /^[WA]/i ) { #Whitelist/Allow
     $option = 'whitelist_';
@@ -182,6 +195,12 @@ sub export_setstatus_vacationdel {
 sub export_setstatus_vacationX {
   my( $self, $svc_x, $action, $hr ) = @_;
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_setstatus_vacationX() suppressed by noexport_hack'
+      if $self->option('debug') || $DEBUG;
+    return;
+  }
+
   my $option = 'vacation_'. $action. '_url';
 
   my $subject = uri_escape($hr->{subject});
@@ -216,5 +235,3 @@ sub export_setstatus_vacationX {
 }
 
 1;
-
-1;

commit c616cf2095e79a6dec2f16cd9ffaa139841ee0ee
Author: Mitch Jackson <mitch at mitchjacksontech.com>
Date:   Tue Aug 14 19:27:57 2018 -0400

    RT# 78547 noexport_hack - part_svc::grandstream

diff --git a/FS/FS/part_export/grandstream.pm b/FS/FS/part_export/grandstream.pm
index 5c6f1ed8d..981eb1969 100644
--- a/FS/FS/part_export/grandstream.pm
+++ b/FS/FS/part_export/grandstream.pm
@@ -7,6 +7,7 @@ use MIME::Base64;
 use Tie::IxHash;
 use IPC::Run qw(run);
 use FS::CGI qw(rooturl);
+use Carp qw(carp);
 
 $DEBUG = 0;
 
@@ -50,6 +51,12 @@ sub rebless { shift; }
 sub gs_create_config {
   my($self, $mac, %opt) = (@_);
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'gs_create_config() suppressed by noexport_hack'
+      if $self->option('debug') || $DEBUG;
+    return;
+  }
+
   eval "use Net::SCP;";
   die $@ if $@;
 
@@ -131,6 +138,12 @@ sub gs_create {
 sub gs_delete {
   my($self, $mac) = (shift, shift);
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'gs_delete() suppressed by noexport_hack'
+      if $self->option('debug') || $DEBUG;
+    return;
+  }
+
   $mac = sprintf('%012s', lc($mac));
 
   ssh_cmd( user => $self->option('user'),

commit 5fb44923f3f979769629b9b05f1c156566f33d7d
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Tue Aug 14 19:15:41 2018 -0400

    RT# 78547 noexport_hack - part_svc::broadworks

diff --git a/FS/FS/part_export/broadworks.pm b/FS/FS/part_export/broadworks.pm
index a04a70e9b..611bd00ec 100644
--- a/FS/FS/part_export/broadworks.pm
+++ b/FS/FS/part_export/broadworks.pm
@@ -6,6 +6,7 @@ use strict;
 use Tie::IxHash;
 use FS::Record qw(dbh qsearch qsearchs);
 use Locale::SubCountry;
+use Carp qw(carp);
 
 our $me = '[broadworks]';
 our %client; # exportnum => client object
@@ -46,7 +47,7 @@ Until then, authentication will be denied.</P>
 END
 );
 
-sub export_insert {
+sub _export_insert {
   my($self, $svc_x) = (shift, shift);
 
   my $cust_main = $svc_x->cust_main;
@@ -68,7 +69,7 @@ sub export_insert {
   '';
 }
 
-sub export_replace {
+sub _export_replace {
   my($self, $svc_new, $svc_old) = @_;
 
   my $cust_main = $svc_new->cust_main;
@@ -121,7 +122,7 @@ sub export_replace {
   '';
 }
 
-sub export_delete {
+sub _export_delete {
   my ($self, $svc_x) = @_;
 
   my $cust_main = $svc_x->cust_main;
@@ -162,6 +163,12 @@ sub export_delete {
 sub export_device_insert {
   my ($self, $svc_x, $device) = @_;
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_device_insert() suppressed by noexport_hack'
+      if $self->option('debug');
+    return;
+  }
+
   if ( $device->count('svcnum = ?', $svc_x->svcnum) > 1 ) {
     return "This service already has a device.";
   }
@@ -181,6 +188,13 @@ sub export_device_insert {
 
 sub export_device_replace {
   my ($self, $svc_x, $new_device, $old_device) = @_;
+
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_device_replace() suppressed by noexport_hack'
+      if $self->option('debug');
+    return;
+  }
+
   my $cust_main = $svc_x->cust_main;
   my $groupId = $self->groupId($cust_main);
 
@@ -205,6 +219,12 @@ sub export_device_replace {
 sub export_device_delete {
   my ($self, $svc_x, $device) = @_;
 
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp 'export_device_delete() suppressed by noexport_hack'
+      if $self->option('debug');
+    return;
+  }
+
   if ( $device->isa('FS::phone_device') ) {
     my $error = $self->set_endpoint( $self->userId($svc_x), '' );
     return $error if $error;

commit 7be116339beee253f9bf8805076c766cf0f8e318
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Tue Aug 14 19:11:00 2018 -0400

    RT# 78547 - consolidate noexport_hack

diff --git a/FS/FS/part_export/a2billing.pm b/FS/FS/part_export/a2billing.pm
index 15410aebf..dbbd1bef8 100644
--- a/FS/FS/part_export/a2billing.pm
+++ b/FS/FS/part_export/a2billing.pm
@@ -105,7 +105,7 @@ sub replace {
   '';
 }
 
-sub export_insert {
+sub _export_insert {
   my $self = shift;
   my $svc = shift;
   my $cust_pkg = $svc->cust_svc->cust_pkg;
@@ -290,7 +290,7 @@ sub export_insert {
   '';
 }
 
-sub export_delete {
+sub _export_delete {
   my $self = shift;
   my $svc = shift;
 
@@ -376,7 +376,7 @@ sub export_delete {
   '';
 }
 
-sub export_replace {
+sub _export_replace {
   my $self = shift;
   my $new = shift;
   my $old = shift || $self->replace_old;
@@ -421,7 +421,7 @@ sub export_replace {
   '';
 }
 
-sub export_suspend {
+sub _export_suspend {
   my $self = shift;
   my $svc = shift;
 
@@ -446,7 +446,7 @@ sub export_suspend {
   $error || '';
 }
 
-sub export_unsuspend {
+sub _export_unsuspend {
   my $self = shift;
   my $svc = shift;
 
diff --git a/FS/FS/part_export/acct_opensrs.pm b/FS/FS/part_export/acct_opensrs.pm
index 51cee97a3..c131900d3 100644
--- a/FS/FS/part_export/acct_opensrs.pm
+++ b/FS/FS/part_export/acct_opensrs.pm
@@ -87,7 +87,7 @@ sub app {
   return;
 }
 
-sub export_insert {
+sub _export_insert {
   my $self = shift;
   my $new = shift;
   my $app = $self->app;
@@ -134,7 +134,7 @@ sub export_insert {
   }
 }
 
-sub export_delete {
+sub _export_delete {
   my $self = shift;
   my $old = shift;
   my $app = $self->app;
@@ -160,7 +160,7 @@ sub export_delete {
   }
 }
 
-sub export_replace {
+sub _export_replace {
   my $self = shift;
   my ($new, $old) = @_;
   my $app = $self->app;
@@ -222,7 +222,7 @@ sub export_replace {
   }
 }
 
-sub export_suspend {
+sub _export_suspend {
   my $self = shift;
   my $svc = shift;
   my $unsuspend = shift || 0;
@@ -243,7 +243,7 @@ sub export_suspend {
   return;
 }
 
-sub export_unsuspend {
+sub _export_unsuspend {
   my ($self, $svc) = @_;
   $self->export_suspend($svc, 1);
 }
diff --git a/FS/FS/part_export/aradial.pm b/FS/FS/part_export/aradial.pm
index c7356bf39..c5c55452c 100644
--- a/FS/FS/part_export/aradial.pm
+++ b/FS/FS/part_export/aradial.pm
@@ -41,7 +41,7 @@ service types, create another export instance.</p>
 '
 );
 
-sub export_insert {
+sub _export_insert {
   my ($self, $svc) = @_;
   my $result = $self->request_user_edit(
     'Add'   => 1,
@@ -54,7 +54,7 @@ sub export_insert {
   $result;
 }
 
-sub export_replace {
+sub _export_replace {
   my ($self, $new, $old) = @_;
   if ($new->email ne $old->email) {
     return $old->export_delete || $new->export_insert;
@@ -70,7 +70,7 @@ sub export_replace {
   );
 }
 
-sub export_suspend {
+sub _export_suspend {
   my ($self, $svc) = @_;
   $self->request_user_edit(
     'Modify'  => 1,
@@ -79,7 +79,7 @@ sub export_suspend {
   );
 }
 
-sub export_unsuspend {
+sub _export_unsuspend {
   my ($self, $svc) = @_;
   $self->request_user_edit(
     'Modify'  => 1,
@@ -88,7 +88,7 @@ sub export_unsuspend {
   );
 }
 
-sub export_delete {
+sub _export_delete {
   my ($self, $svc) = @_;
   $self->request_user_edit(
     'ConfirmDelete' => 1,
diff --git a/FS/FS/part_export/bandwidth_com.pm b/FS/FS/part_export/bandwidth_com.pm
index 6d868e640..b39bffb69 100644
--- a/FS/FS/part_export/bandwidth_com.pm
+++ b/FS/FS/part_export/bandwidth_com.pm
@@ -69,7 +69,7 @@ value, or a list of fixed values, for the sip_server field.</P>
 END
 );
 
-sub export_insert {
+sub _export_insert {
   my($self, $svc_phone) = (shift, shift);
   local $SIG{__DIE__};
   try {
@@ -100,7 +100,7 @@ sub export_insert {
   };
 }
 
-sub export_replace {
+sub _export_replace {
   my ($self, $new, $old) = @_;
   # we only export the IP address and the phone number,
   # neither of which we can change in place.
@@ -111,7 +111,7 @@ sub export_replace {
   '';
 }
 
-sub export_delete {
+sub _export_delete {
   my ($self, $svc_phone) = (shift, shift);
   local $SIG{__DIE__};
   try {
diff --git a/FS/FS/part_export/broadband_nas.pm b/FS/FS/part_export/broadband_nas.pm
index 8c152be45..d52ccae88 100644
--- a/FS/FS/part_export/broadband_nas.pm
+++ b/FS/FS/part_export/broadband_nas.pm
@@ -69,7 +69,7 @@ will be applied to the attached NAS record.
 
 =cut
 
-sub export_insert {
+sub _export_insert {
   my $self = shift;
   my $svc_broadband = shift;
   my %hash = (
@@ -103,7 +103,7 @@ sub export_insert {
   return;
 }
 
-sub export_delete {
+sub _export_delete {
   my $self = shift;
   my $svc_broadband = shift;
   my $svcnum = $svc_broadband->svcnum;
@@ -118,7 +118,7 @@ sub export_delete {
   return;
 }
 
-sub export_replace {
+sub _export_replace {
   my $self = shift;
   my ($new_svc, $old_svc) = (shift, shift);
 
diff --git a/FS/FS/part_export/broadband_snmp.pm b/FS/FS/part_export/broadband_snmp.pm
index 56d7816b2..8ebc716e7 100644
--- a/FS/FS/part_export/broadband_snmp.pm
+++ b/FS/FS/part_export/broadband_snmp.pm
@@ -62,27 +62,27 @@ svc_broadband fields may be prefixed with <b>$new_</b> and <b>$old_</b>
 END
 );
 
-sub export_insert {
+sub _export_insert {
   my $self = shift;
   $self->export_command('insert', @_);
 }
 
-sub export_delete {
+sub _export_delete {
   my $self = shift;
   $self->export_command('delete', @_);
 }
 
-sub export_replace {
+sub _export_replace {
   my $self = shift;
   $self->export_command('replace', @_);
 }
 
-sub export_suspend {
+sub _export_suspend {
   my $self = shift;
   $self->export_command('suspend', @_);
 }
 
-sub export_unsuspend {
+sub _export_unsuspend {
   my $self = shift;
   $self->export_command('unsuspend', @_);
 }
diff --git a/FS/FS/part_export/northern_911.pm b/FS/FS/part_export/northern_911.pm
index 027a52d21..679f5daf6 100644
--- a/FS/FS/part_export/northern_911.pm
+++ b/FS/FS/part_export/northern_911.pm
@@ -47,7 +47,7 @@ sub client {
   return $self->get('client');
 }
 
-sub export_insert {
+sub _export_insert {
   my( $self, $svc_phone ) = (shift, shift);
 
   my %location_hash = $svc_phone->location_hash;
@@ -98,7 +98,7 @@ sub export_insert {
   '';
 }
 
-sub export_replace {
+sub _export_replace {
   my( $self, $new, $old ) = (shift, shift, shift);
 
   # except when changing the phone number, exactly like export_insert;
@@ -109,7 +109,7 @@ sub export_replace {
   $self->export_insert($new);
 }
 
-sub export_delete {
+sub _export_delete {
   my ($self, $svc_phone) = (shift, shift);
 
   if ($self->option('debug')) {
diff --git a/FS/FS/part_export/thinktel.pm b/FS/FS/part_export/thinktel.pm
index 67cf2b0da..9ab645539 100644
--- a/FS/FS/part_export/thinktel.pm
+++ b/FS/FS/part_export/thinktel.pm
@@ -131,7 +131,7 @@ sub check_svc { # check the service for validity
   '';
 }
 
-sub export_insert {
+sub _export_insert {
   my($self, $svc_x) = (shift, shift);
 
   my $error = $self->check_svc($svc_x);
@@ -294,7 +294,7 @@ sub insert_trunk {
   }
 }
 
-sub export_replace {
+sub _export_replace {
   my ($self, $svc_new, $svc_old) = @_;
 
   my $error = $self->check_svc($svc_new);
@@ -412,7 +412,7 @@ sub replace_gateway {
   }
 }
 
-sub export_delete {
+sub _export_delete {
   my ($self, $svc_x) = (shift, shift);
 
   my $role = $self->svc_role($svc_x)
diff --git a/FS/FS/part_export/voip_ms.pm b/FS/FS/part_export/voip_ms.pm
index 251988485..1eedd66ac 100644
--- a/FS/FS/part_export/voip_ms.pm
+++ b/FS/FS/part_export/voip_ms.pm
@@ -133,7 +133,7 @@ our %info = (
 END
 );
 
-sub export_insert {
+sub _export_insert {
   my($self, $svc_x) = (shift, shift);
 
   my $role = $self->svc_role($svc_x);
@@ -162,7 +162,7 @@ sub export_insert {
   '';
 }
 
-sub export_replace {
+sub _export_replace {
   my ($self, $svc_new, $svc_old) = @_;
   my $role = $self->svc_role($svc_new);
   my $error;
@@ -175,7 +175,7 @@ sub export_replace {
   '';
 }
 
-sub export_delete {
+sub _export_delete {
   my ($self, $svc_x) = (shift, shift);
   my $role = $self->svc_role($svc_x);
   if ( $role eq 'subacct' ) {
@@ -204,7 +204,7 @@ sub export_delete {
   '';
 }
 
-sub export_suspend {
+sub _export_suspend {
   my $self = shift;
   my $svc_x = shift;
   my $role = $self->svc_role($svc_x);
@@ -215,7 +215,7 @@ sub export_suspend {
   '';
 }
 
-sub export_unsuspend {
+sub _export_unsuspend {
   my $self = shift;
   my $svc_x = shift;
   my $role = $self->svc_role($svc_x);

commit 5a8c96bc2c4cd9dfe4540ee39fba0fd203689890
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Tue Aug 14 15:47:35 2018 -0400

    RT# 78547 - consolidate noexport_hack

diff --git a/FS/FS/part_export.pm b/FS/FS/part_export.pm
index 572a1b684..1a8f43de1 100644
--- a/FS/FS/part_export.pm
+++ b/FS/FS/part_export.pm
@@ -554,15 +554,19 @@ sub default_export_machine {
   die "no default export hostname for export ".$self->exportnum;
 }
 
-#these should probably all go away, just let the subclasses define em
-
 =item export_insert SVC_OBJECT
 
 =cut
 
+# Do not overload!  Overload _export_insert instead
+
 sub export_insert {
   my $self = shift;
   #$self->rebless;
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp "export_insert() suppressed by noexport_hack" if $DEBUG;
+    return;
+  }
   $self->_export_insert(@_);
 }
 
@@ -579,9 +583,15 @@ sub export_insert {
 
 =cut
 
+# Do not overload!  Overload _export_replace instead
+
 sub export_replace {
   my $self = shift;
   #$self->rebless;
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp "export_replace() suppressed by noexport_hack" if $DEBUG;
+    return;
+  }
   $self->_export_replace(@_);
 }
 
@@ -589,9 +599,15 @@ sub export_replace {
 
 =cut
 
+# Do not overload!  Overload _export_delete instead
+
 sub export_delete {
   my $self = shift;
   #$self->rebless;
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp "export_delete() suppressed by noexport_hack" if $DEBUG;
+    return;
+  }
   $self->_export_delete(@_);
 }
 
@@ -599,9 +615,15 @@ sub export_delete {
 
 =cut
 
+# Do not overload!  Overload _export_suspend instead
+
 sub export_suspend {
   my $self = shift;
   #$self->rebless;
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp "export_suspend() suppressed by noexport_hack" if $DEBUG;
+    return;
+  }
   $self->_export_suspend(@_);
 }
 
@@ -609,9 +631,15 @@ sub export_suspend {
 
 =cut
 
+# Do not overload!  Overload _export_unsuspend instead
+
 sub export_unsuspend {
   my $self = shift;
   #$self->rebless;
+  if ( $FS::svc_Common::noexport_hack ) {
+    carp "export_unsuspend() suppressed by noexport_hack" if $DEBUG;
+    return;
+  }
   $self->_export_unsuspend(@_);
 }
 

commit 18ef89c91d6ecb57d7f2c1814e60b8434ae84bed
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Sat Jul 28 14:46:38 2018 -0500

    RT# 78547 - Flag to disable email/print/fax/etc during tests or reports

diff --git a/FS/FS/Misc.pm b/FS/FS/Misc.pm
index d84aaced5..fd2c32513 100644
--- a/FS/FS/Misc.pm
+++ b/FS/FS/Misc.pm
@@ -1,7 +1,7 @@
 package FS::Misc;
 
 use strict;
-use vars qw ( @ISA @EXPORT_OK $DEBUG );
+use vars qw ( @ISA @EXPORT_OK $DEBUG $DISABLE_ALL_NOTICES );
 use Exporter;
 use Carp;
 use Data::Dumper;
@@ -43,6 +43,32 @@ Miscellaneous subroutines.  This module contains miscellaneous subroutines
 called from multiple other modules.  These are not OO or necessarily related,
 but are collected here to eliminate code duplication.
 
+=head1 DISABLE ALL NOTICES
+
+Set $FS::Misc::DISABLE_ALL_NOTICES to suppress:
+
+=over 4
+
+=item FS::cust_bill::send_csv
+
+=item FS::cust_bill::spool_csv
+
+=item FS::msg_template::email::send_prepared
+
+=item FS::Misc::send_email
+
+=item FS::Misc::do_print
+
+=item FS::Misc::send_fax
+
+=item FS::Template_Mixin::postal_mail_fsinc
+
+=back
+
+=cut
+
+$DISABLE_ALL_NOTICES = 0;
+
 =head1 SUBROUTINES
 
 =over 4
@@ -118,6 +144,12 @@ FS::UID->install_callback( sub {
 
 sub send_email {
   my(%options) = @_;
+
+  if ( $DISABLE_ALL_NOTICES ) {
+    warn 'send_email() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
+    return;
+  }
+
   if ( $DEBUG ) {
     my %doptions = %options;
     $doptions{'body'} = '(full body not shown in debug)';
@@ -450,6 +482,11 @@ sub send_fax {
   die 'HylaFAX support has not been configured.'
     unless $conf->exists('hylafax');
 
+  if ( $DISABLE_ALL_NOTICES ) {
+    warn 'send_fax() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
+    return;
+  }
+
   eval {
     require Fax::Hylafax::Client;
   };
@@ -869,6 +906,11 @@ global value and agentnum).
 sub do_print {
   my( $data, %opt ) = @_;
 
+  if ( $DISABLE_ALL_NOTICES ) {
+    warn 'do_print() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
+    return;
+  }
+
   my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} )
               ? $opt{'lpr'}
               : $conf->config('lpr', $opt{'agentnum'} );
diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm
index 38c506692..1411019b0 100644
--- a/FS/FS/Template_Mixin.pm
+++ b/FS/FS/Template_Mixin.pm
@@ -2514,6 +2514,11 @@ use MIME::Base64;
 sub postal_mail_fsinc {
   my ( $self, %opt ) = @_;
 
+  if ( $FS::Misc::DISABLE_PRINT ) {
+    warn 'postal_mail_fsinc() disabled by $FS::Misc::DISABLE_PRINT' if $DEBUG;
+    return;
+  }
+
   my $url = 'https://ws.freeside.biz/print';
 
   my $cust_main = $self->cust_main;
diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm
index 52c62af2d..dc63fdb88 100644
--- a/FS/FS/cust_bill.pm
+++ b/FS/FS/cust_bill.pm
@@ -1402,6 +1402,11 @@ See L</print_csv> for a description of the output format.
 sub send_csv {
   my($self, %opt) = @_;
 
+  if ( $FS::Misc::DISABLE_ALL_NOTICES ) {
+    warn 'send_csv() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
+    return;
+  }
+
   #create file(s)
 
   my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
@@ -1478,6 +1483,11 @@ in the ICS format.
 sub spool_csv {
   my($self, %opt) = @_;
 
+  if ( $FS::Misc::DISABLE_ALL_NOTICES ) {
+    warn 'spool_csv() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
+    return;
+  }
+
   my $time = $opt{'time'} || time;
   my $cust_main = $self->cust_main;
 
diff --git a/FS/FS/msg_template/email.pm b/FS/FS/msg_template/email.pm
index 63c860f98..3850edeb9 100644
--- a/FS/FS/msg_template/email.pm
+++ b/FS/FS/msg_template/email.pm
@@ -505,6 +505,11 @@ sub send_prepared {
   my $self = shift;
   my $cust_msg = shift or die "cust_msg required";
 
+  if ( $FS::Misc::DISABLE_ALL_NOTICES ) {
+    warn 'send_prepared() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
+    return;
+  }
+
   my $domain = 'example.com';
   if ( $cust_msg->env_from =~ /\@([\w\.\-]+)/ ) {
     $domain = $1;

commit c6251a1dcd226056780bf28f8ca79f078f8c78bd
Author: Mitch Jackson <mitch at freeside.biz>
Date:   Fri Jan 19 01:34:48 2018 -0600

    rt# 78547 Implement report listing future auto-bill charges

diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html
index 66e419af0..02a506772 100644
--- a/httemplate/elements/menu.html
+++ b/httemplate/elements/menu.html
@@ -418,6 +418,8 @@ if( $curuser->access_right('Financial reports') ) {
 
   $report_financial{'Customer Accounting Summary'} = [ $fsurl.'search/report_customer_accounting_summary.html', 'Customer accounting summary report' ];
 
+  $report_financial{'Upcoming Auto-Bill Transactions'} = [ $fsurl.'search/report_future_autobill.html', 'Upcoming auto-bill transactions' ];
+
 } elsif($curuser->access_right('Receivables report')) {
 
   $report_financial{'A/R Aging'} = [ $fsurl.'search/report_receivables.html', 'Accounts Receivable Aging report' ];
diff --git a/httemplate/search/elements/grid-report.html b/httemplate/search/elements/grid-report.html
index 98e81785f..b1e543012 100644
--- a/httemplate/search/elements/grid-report.html
+++ b/httemplate/search/elements/grid-report.html
@@ -161,7 +161,7 @@ as <A HREF="<% "$myself;_type=xls" %>">Excel spreadsheet</A><BR>
 .shaded { background-color: #c8c8c8; }
 .totalshaded { background-color: #bfc094; }
 </style>
-<table class="report" width="100%" cellspacing=0>
+<table class="<% $table_class %>" width="<% $table_width %>" cellspacing=0>
 % foreach my $rowinfo (@rows) {
   <tr<% $rowinfo->{class} ? ' class="'.$rowinfo->{class}.'"' : ''%>>
 %   my $thisrow = shift @cells;
@@ -172,7 +172,11 @@ as <A HREF="<% "$myself;_type=xls" %>">Excel spreadsheet</A><BR>
 %     $style .= " rowspan=".$cell->{rowspan} if $cell->{rowspan} > 1;
 %     $style .= " colspan=".$cell->{colspan} if $cell->{colspan} > 1;
 %     $style .= ' class="' . $cell->{class} . '"' if $cell->{class};
+% if ($cell->{bypass_filter}) {
+      <<%$td%><%$style%>><% $cell->{value} %></<%$td%>>
+% } else {
       <<%$td%><%$style%>><% $cell->{value} |h %></<%$td%>>
+% }
 %   }
   </tr>
 % }
@@ -186,4 +190,6 @@ $title
 @cells
 $head => ''
 $foot => ''
+$table_width => "100%"
+$table_class => "report"
 </%args>
diff --git a/httemplate/search/future_autobill.html b/httemplate/search/future_autobill.html
new file mode 100644
index 000000000..711a25f82
--- /dev/null
+++ b/httemplate/search/future_autobill.html
@@ -0,0 +1,189 @@
+<%doc>
+
+Report listing upcoming auto-bill transactions
+
+Spec requested the ability to run this report with a longer date range,
+and see which charges will process on which day.  Checkbox multiple_billing_dates
+enables this functionality.
+
+Performance:
+This is a dynamically generated report.  The time this report takes to run
+will depends on the number of customers.  Installations with a high number
+of auto-bill customers may find themselves unable to run this report
+because of browser timeout.  Report could be implemented as a queued job if
+necessary, to solve the performance problem.
+
+</%doc>
+<& elements/grid-report.html,
+  title => 'Upcoming auto-bill transactions',
+  rows => \@rows,
+  cells => \@cells,
+  table_width => "",
+  table_class => 'gridreport',
+  head => '
+    <style type="text/css">
+      table.gridreport { margin: .5em; border: solid 1px #aaa; }
+      th.gridreport { background-color: #ccc; }
+      tr.gridreport:nth-child(even) { background-color: #eee; }
+      tr.gridreport:nth-child(odd)  { background-color: #fff; }
+      td.gridreport { margin: 0 .2em; padding: 0 .4em; }
+    </style>
+  ',
+&>
+
+<%init>
+
+use FS::UID qw( dbh myconnect );
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+  my $target_dt;
+  my @target_dates;
+
+  # Work with all date/time operations @ 12 noon
+  my %noon = (
+    hour   => 12,
+    minute => 0,
+    second => 0
+  );
+
+  my $now_dt = DateTime->now;
+  $now_dt = DateTime->new(
+    month => $now_dt->month,
+    day   => $now_dt->day,
+    year  => $now_dt->year,
+    %noon,
+  );
+
+  # Get target date from form
+  if ($cgi->param('target_date')) {
+    my ($mm, $dd, $yy) = split /[\-\/]/,$cgi->param('target_date');
+    $target_dt = DateTime->new(
+      month => $mm,
+      day   => $dd,
+      year  => $yy,
+      %noon,
+    ) if $mm && $dd & $yy;
+
+    # Catch a date from the past: time only travels in one direction
+    $target_dt = undef if $target_dt->epoch < $now_dt->epoch;
+  }
+
+  # without a target date, default to tomorrow
+  unless ($target_dt) {
+    $target_dt = DateTime->from_epoch( epoch => time() + 86400) ;
+    $target_dt = DateTime->new(
+      month => $target_dt->month,
+      day   => $target_dt->day,
+      year  => $target_dt->year,
+      %noon
+    );
+  }
+
+  # If multiple_billing_dates checkbox selected, create a range of dates
+  # from today until the given report date.  Otherwise, use target date only.
+  if ($cgi->param('multiple_billing_dates')) {
+    my $walking_dt = DateTime->from_epoch(epoch => $now_dt->epoch);
+    until ($walking_dt->epoch > $target_dt->epoch) {
+     push @target_dates, $walking_dt->epoch;
+     $walking_dt->add(days => 1);
+    }
+  } else {
+    push @target_dates, $target_dt->epoch;
+  }
+
+  # List all customers with an auto-bill method
+  #
+  # my %cust_payby = map {$_->custnum => $_} qsearch({
+  #   table => 'cust_payby',
+  #   hashref => {
+  #     weight  => { op => '>', value => '0' },
+  #     paydate => { op => '>', value => $target_dt->ymd },
+  #   },
+  #   order_by => " ORDER BY weight DESC ",
+  # });
+
+  # List all customers with an auto-bill method that's not expired
+  my %cust_payby = map {$_->custnum => $_} qsearch({
+    table => 'cust_payby',
+    hashref => {
+      weight  => { op => '>', value => '0' },
+    },
+    order_by => " ORDER BY weight DESC ",
+    extra_sql => " AND ( payby = 'CHEK' OR ( paydate > '".$target_dt->ymd."')) ",
+  });
+
+  my %abreport;
+  my @rows;
+
+  local $@;
+  local $SIG{__DIE__};
+  my $temp_dbh = myconnect();
+  eval { # Creating sandbox dbh where all connections are to be rolled back
+    local $FS::UID::dbh = $temp_dbh;
+    local $FS::UID::AutoCommit = 0;
+
+    # Generate report data into @rows
+    for my $custnum (keys %cust_payby) {
+      my $cust_main = qsearchs('cust_main', {custnum => $custnum});
+
+      # walk forward through billing dates
+      for my $query_epoch (@target_dates) {
+        my $return_bill = [];
+
+        eval { # Don't let an error on one customer crash the report
+          my $error = $cust_main->bill(
+            time           => $query_epoch,
+            return_bill    => $return_bill,
+            no_usage_reset => 1,
+          );
+          die "$error (simulating future billing)" if $error;
+        };
+        warn ("$@: (future_autobill custnum:$custnum)");
+
+        if (@{$return_bill}) {
+          my $inv = $return_bill->[0];
+          push @rows,{
+            name => $cust_main->name,
+            _date => $inv->_date,
+            cells => [
+              { class => 'gridreport', value => $custnum },
+              { class => 'gridreport',
+                value => '<a href="/view/cust_main.cgi?"'.$custnum.'">'.$cust_main->name.'</a>',
+                bypass_filter => 1,
+              },
+              { class => 'gridreport', value => $inv->charged, format => 'money' },
+              { class => 'gridreport', value => DateTime->from_epoch(epoch=>$inv->_date)->ymd },
+              { class => 'gridreport', value => ($cust_payby{$custnum}->payby || $cust_payby{$custnum}->paytype) },
+              { class => 'gridreport', value => $cust_payby{$custnum}->paymask },
+            ]
+          };
+        }
+
+      }
+      $temp_dbh->rollback;
+    } # /foreach $custnum
+
+  }; # /eval
+  warn("$@") if $@;
+
+  # Sort output by date, and format for output to grid-report.html
+  my @cells = [
+      # header row
+      { class => 'gridreport', value => '#',       header => 1 },
+      { class => 'gridreport', value => 'Name',    header => 1 },
+      { class => 'gridreport', value => 'Amount',  header => 1 },
+      { class => 'gridreport', value => 'Date',    header => 1 },
+      { class => 'gridreport', value => 'Type',    header => 1 },
+      { class => 'gridreport', value => 'Account', header => 1 },
+    ];
+  push @cells,
+    map  { $_->{cells} }
+    sort { $a->{_date} <=> $b->{_date} || $a->{name} cmp $b->{name} }
+    @rows;
+
+  # grid-report.html requires a parallel @rows parameter to accompany @cells
+  @rows = map { {class => 'gridreport'} } 1..scalar(@cells);
+
+</%init>
diff --git a/httemplate/search/report_future_autobill.html b/httemplate/search/report_future_autobill.html
new file mode 100644
index 000000000..1a0c9f48a
--- /dev/null
+++ b/httemplate/search/report_future_autobill.html
@@ -0,0 +1,42 @@
+<%doc>
+
+Display date selector for the future_autobill.html report
+
+</%doc>
+<% include('/elements/header.html', 'Future Auto-Bill Transactions' ) %>
+
+
+<FORM ACTION="future_autobill.html" METHOD="GET">
+<TABLE>
+<& /elements/tr-input-date-field.html,
+  {
+    name     => 'target_date',
+    value    => $target_date,
+    label    => emt('Target billing date').': ',
+    required => 1
+  }
+&>
+
+<& /elements/tr-checkbox.html,
+     'label' => emt('Multiple billing dates (slow)').': ',
+     'field' => 'multiple_billing_dates',
+     'value' => '1',
+&>
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="<% mt('Get Report') |h %>">
+
+</FORM>
+
+<% include('/elements/footer.html') %>
+
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+my $target_date = DateTime->from_epoch(epoch=>(time()+86400))->mdy('/');
+
+</%init>

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/Misc.pm                                      |  44 +++-
 FS/FS/Misc/Savepoint.pm                            | 160 ++++++++++++
 FS/FS/Report/Queued/FutureAutobill.pm              | 132 ++++++++++
 FS/FS/Template_Mixin.pm                            |   5 +
 FS/FS/UI/Web.pm                                    |   1 +
 FS/FS/UID.pm                                       |  12 +-
 FS/FS/cust_bill.pm                                 |  16 ++
 FS/FS/cust_main.pm                                 |  55 ++++-
 FS/FS/cust_main/Billing.pm                         |  25 +-
 FS/FS/cust_main/Billing_Realtime.pm                |  31 ++-
 FS/FS/cust_payby.pm                                |  74 ++++++
 FS/FS/msg_template/email.pm                        |   5 +
 FS/FS/part_export.pm                               |  32 ++-
 FS/FS/part_export/a2billing.pm                     |  10 +-
 FS/FS/part_export/acct_opensrs.pm                  |  10 +-
 FS/FS/part_export/aradial.pm                       |  10 +-
 FS/FS/part_export/bandwidth_com.pm                 |   6 +-
 FS/FS/part_export/broadband_nas.pm                 |   6 +-
 FS/FS/part_export/broadband_snmp.pm                |  10 +-
 FS/FS/part_export/broadworks.pm                    |  26 +-
 FS/FS/part_export/grandstream.pm                   |  13 +
 FS/FS/part_export/http_status.pm                   |  21 +-
 FS/FS/part_export/ikano.pm                         |   9 +-
 FS/FS/part_export/nena2.pm                         |   8 +
 FS/FS/part_export/netsapiens.pm                    |  13 +
 FS/FS/part_export/northern_911.pm                  |   6 +-
 FS/FS/part_export/phone_shellcommands.pm           |   7 +
 FS/FS/part_export/saisei.pm                        |  13 +
 FS/FS/part_export/shellcommands.pm                 |  20 ++
 FS/FS/part_export/sipwise.pm                       |  18 +-
 FS/FS/part_export/sqlradius.pm                     |  93 ++++++-
 FS/FS/part_export/thinktel.pm                      |   6 +-
 FS/FS/part_export/voip_ms.pm                       |  10 +-
 httemplate/elements/menu.html                      |   4 +
 httemplate/search/elements/grid-report.html        |  22 +-
 httemplate/search/future_autobill.html             | 274 +++++++++++++++++++++
 ....cgi => report_future_autobill-queued_job.html} |   5 +-
 httemplate/search/report_future_autobill.html      |  73 ++++++
 38 files changed, 1201 insertions(+), 84 deletions(-)
 create mode 100644 FS/FS/Misc/Savepoint.pm
 create mode 100644 FS/FS/Report/Queued/FutureAutobill.pm
 create mode 100644 httemplate/search/future_autobill.html
 copy httemplate/search/{report_queued_newtax.cgi => report_future_autobill-queued_job.html} (58%)
 mode change 100755 => 100644
 create mode 100644 httemplate/search/report_future_autobill.html




More information about the freeside-commits mailing list