per-agent invoice PDF batches, RT#13727
[freeside.git] / FS / FS / cust_bill.pm
index a7eb9f6..7669479 100644 (file)
@@ -1,9 +1,11 @@
 package FS::cust_bill;
 
 use strict;
-use vars qw( @ISA $DEBUG $me $conf $money_char $date_format $rdate_format );
+use vars qw( @ISA $DEBUG $me $conf
+             $money_char $date_format $rdate_format $date_format_long );
 use vars qw( $invoice_lines @buf ); #yuck
 use Fcntl qw(:flock); #for spool_csv
+use Cwd;
 use List::Util qw(min max);
 use Date::Format;
 use Text::Template 1.20;
@@ -12,6 +14,7 @@ use String::ShellQuote;
 use HTML::Entities;
 use Locale::Country;
 use Storable qw( freeze thaw );
+use GD::Barcode;
 use FS::UID qw( datasrc );
 use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print );
 use FS::Record qw( qsearch qsearchs dbh );
@@ -36,6 +39,8 @@ use FS::part_bill_event;
 use FS::payby;
 use FS::bill_batch;
 use FS::cust_bill_batch;
+use FS::cust_bill_pay_pkg;
+use FS::cust_credit_bill_pkg;
 
 @ISA = qw( FS::cust_main_Mixin FS::Record );
 
@@ -45,9 +50,10 @@ $me = '[FS::cust_bill]';
 #ask FS::UID to run this stuff for us later
 FS::UID->install_callback( sub { 
   $conf = new FS::Conf;
-  $money_char   = $conf->config('money_char')  || '$';  
-  $date_format  = $conf->config('date_format') || '%x';  
-  $rdate_format = $conf->config('date_format') || '%m/%d/%Y';  
+  $money_char       = $conf->config('money_char')       || '$';  
+  $date_format      = $conf->config('date_format')      || '%x'; #/YY
+  $rdate_format     = $conf->config('date_format')      || '%m/%d/%Y';  #/YYYY
+  $date_format_long = $conf->config('date_format_long') || '%b %o, %Y';
 } );
 
 =head1 NAME
@@ -239,6 +245,7 @@ sub delete {
     cust_pay_batch
     cust_bill_pay_batch
     cust_bill_pkg
+    cust_bill_batch
   )) {
 
     foreach my $linked ( $self->$table() ) {
@@ -284,11 +291,40 @@ sub replace_check {
   #return "Can't change _date!" unless $old->_date eq $new->_date;
   return "Can't change _date" unless $old->_date == $new->_date;
   return "Can't change charged" unless $old->charged == $new->charged
-                                    || $old->charged == 0;
+                                    || $old->charged == 0
+                                   || $new->{'Hash'}{'cc_surcharge_replace_hack'};
 
   '';
 }
 
+
+=item add_cc_surcharge
+
+Giant hack
+
+=cut
+
+sub add_cc_surcharge {
+    my ($self, $pkgnum, $amount) = (shift, shift, shift);
+
+    my $error;
+    my $cust_bill_pkg = new FS::cust_bill_pkg({
+                                   'invnum' => $self->invnum,
+                                   'pkgnum' => $pkgnum,
+                                   'setup' => $amount,
+                       });
+    $error = $cust_bill_pkg->insert;
+    return $error if $error;
+
+    $self->{'Hash'}{'cc_surcharge_replace_hack'} = 1;
+    $self->charged($self->charged+$amount);
+    $error = $self->replace;
+    return $error if $error;
+
+    $self->apply_payments_and_credits;
+}
+
+
 =item check
 
 Checks all fields to make sure this is a valid invoice.  If there is an error,
@@ -616,44 +652,97 @@ sub cust_credit_bill {
   shift->cust_credited(@_);
 }
 
-=item cust_bill_pay_pkgnum PKGNUM
+#=item cust_bill_pay_pkgnum PKGNUM
+#
+#Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
+#with matching pkgnum.
+#
+#=cut
+#
+#sub cust_bill_pay_pkgnum {
+#  my( $self, $pkgnum ) = @_;
+#  map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray;
+#  sort { $a->_date <=> $b->_date }
+#    qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum,
+#                                'pkgnum' => $pkgnum,
+#                              }
+#           );
+#}
+
+=item cust_bill_pay_pkg PKGNUM
 
 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
-with matching pkgnum.
+applied against the matching pkgnum.
 
 =cut
 
-sub cust_bill_pay_pkgnum {
+sub cust_bill_pay_pkg {
   my( $self, $pkgnum ) = @_;
-  map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum,
-                                'pkgnum' => $pkgnum,
-                              }
-           );
+
+  qsearch({
+    'select'    => 'cust_bill_pay_pkg.*',
+    'table'     => 'cust_bill_pay_pkg',
+    'addl_from' => ' LEFT JOIN cust_bill_pay USING ( billpaynum ) '.
+                   ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ',
+    'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
+                   "   AND cust_bill_pkg.pkgnum = $pkgnum",
+  });
+
 }
 
-=item cust_credited_pkgnum PKGNUM
+#=item cust_credited_pkgnum PKGNUM
+#
+#=item cust_credit_bill_pkgnum PKGNUM
+#
+#Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice
+#with matching pkgnum.
+#
+#=cut
+#
+#sub cust_credited_pkgnum {
+#  my( $self, $pkgnum ) = @_;
+#  map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray;
+#  sort { $a->_date <=> $b->_date }
+#    qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum,
+#                                   'pkgnum' => $pkgnum,
+#                                 }
+#           );
+#}
+#
+#sub cust_credit_bill_pkgnum {
+#  shift->cust_credited_pkgnum(@_);
+#}
 
-=item cust_credit_bill_pkgnum PKGNUM
+=item cust_credit_bill_pkg PKGNUM
 
-Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice
-with matching pkgnum.
+Returns all credit applications (see L<FS::cust_credit_bill>) for this invoice
+applied against the matching pkgnum.
 
 =cut
 
-sub cust_credited_pkgnum {
+sub cust_credit_bill_pkg {
   my( $self, $pkgnum ) = @_;
-  map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum,
-                                   'pkgnum' => $pkgnum,
-                                 }
-           );
+
+  qsearch({
+    'select'    => 'cust_credit_bill_pkg.*',
+    'table'     => 'cust_credit_bill_pkg',
+    'addl_from' => ' LEFT JOIN cust_credit_bill USING ( creditbillnum ) '.
+                   ' LEFT JOIN cust_bill_pkg    USING ( billpkgnum    ) ',
+    'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum.
+                   "   AND cust_bill_pkg.pkgnum = $pkgnum",
+  });
+
 }
 
-sub cust_credit_bill_pkgnum {
-  shift->cust_credited_pkgnum(@_);
+=item cust_bill_batch
+
+Returns all invoice batch records (L<FS::cust_bill_batch>) for this invoice.
+
+=cut
+
+sub cust_bill_batch {
+  my $self = shift;
+  qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
 }
 
 =item tax
@@ -696,8 +785,8 @@ sub owed_pkgnum {
   my $balance = 0;
   $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum);
 
-  $balance -= $_->amount            for $self->cust_bill_pay_pkgnum($pkgnum);
-  $balance -= $_->amount            for $self->cust_credited_pkgnum($pkgnum);
+  $balance -= $_->amount            for $self->cust_bill_pay_pkg($pkgnum);
+  $balance -= $_->amount            for $self->cust_credit_bill_pkg($pkgnum);
 
   $balance = sprintf( "%.2f", $balance);
   $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
@@ -878,6 +967,7 @@ sub generate_email {
     'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
     'template'      => $args{'template'},
     'notice_name'   => ( $args{'notice_name'} || 'Invoice' ),
+    'no_coupon'     => $args{'no_coupon'},
   );
 
   my $cust_main = $self->cust_main;
@@ -956,6 +1046,19 @@ sub generate_email {
       'Filename'   => 'logo.png',
       'Content-ID' => "<$content_id>",
     ;
+   
+    my $barcode;
+    if($conf->exists('invoice-barcode')){
+       my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+       $barcode = build MIME::Entity
+         'Type'       => 'image/png',
+         'Encoding'   => 'base64',
+         'Data'       => $self->invoice_barcode(0),
+         'Filename'   => 'barcode.png',
+         'Content-ID' => "<$barcode_content_id>",
+       ;
+       $opt{'barcode_cid'} = $barcode_content_id;
+    }
 
     $alternative->attach(
       'Type'        => 'text/html',
@@ -1029,7 +1132,12 @@ sub generate_email {
       #   image/png
 
       $return{'content-type'} = 'multipart/related';
-      $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
+      if($conf->exists('invoice-barcode')){
+         $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
+      }
+      else {
+         $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
+      }
       $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
       #$return{'disposition'} = 'inline';
 
@@ -1208,11 +1316,12 @@ sub queueable_email {
   my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
     or die "invalid invoice number: " . $opt{invnum};
 
-  my @args = ( $opt{template} );
-  push @args, $opt{invoice_from}
-    if exists($opt{invoice_from}) && $opt{invoice_from};
+  my %args = ( 'template' => $opt{template} );
+  $args{$_} = $opt{$_}
+    foreach grep { exists($opt{$_}) && $opt{$_} }
+              qw( invoice_from notice_name no_coupon );
 
-  my $error = $self->email( @args );
+  my $error = $self->email( \%args );
   die $error if $error;
 
 }
@@ -1221,16 +1330,18 @@ sub queueable_email {
 sub email {
   my $self = shift;
 
-  my( $template, $invoice_from, $notice_name );
+  my( $template, $invoice_from, $notice_name, $no_coupon );
   if ( ref($_[0]) ) {
     my $opt = shift;
     $template = $opt->{'template'} || '';
     $invoice_from = $opt->{'invoice_from'};
     $notice_name = $opt->{'notice_name'} || 'Invoice';
+    $no_coupon = $opt->{'no_coupon'} || 0;
   } else {
     $template = scalar(@_) ? shift : '';
     $invoice_from = shift if scalar(@_);
     $notice_name = 'Invoice';
+    $no_coupon = 0;
   }
 
   $invoice_from ||= $self->_agent_invoice_from ||    #XXX should go away
@@ -1239,8 +1350,14 @@ sub email {
   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } 
                             $self->cust_main->invoicing_list;
 
-  #better to notify this person than silence
-  @invoicing_list = ($invoice_from) unless @invoicing_list;
+  if ( ! @invoicing_list ) { #no recipients
+    if ( $conf->exists('cust_bill-no_recipients-error') ) {
+      die 'No recipients for customer #'. $self->custnum;
+    } else {
+      #default: better to notify this person than silence
+      @invoicing_list = ($invoice_from);
+    }
+  }
 
   my $subject = $self->email_subject($template);
 
@@ -1251,6 +1368,7 @@ sub email {
       'subject'     => $subject,
       'template'    => $template,
       'notice_name' => $notice_name,
+      'no_coupon'   => $no_coupon,
     )
   );
   die "can't email invoice: $error\n" if $error;
@@ -1402,14 +1520,36 @@ isn't an open batch, one will be created.
 
 sub batch_invoice {
   my ($self, $opt) = @_;
-  my $batch = FS::bill_batch->get_open_batch;
+  my $bill_batch = $self->get_open_bill_batch;
   my $cust_bill_batch = FS::cust_bill_batch->new({
-      batchnum => $batch->batchnum,
+      batchnum => $bill_batch->batchnum,
       invnum   => $self->invnum,
   });
   return $cust_bill_batch->insert($opt);
 }
 
+=item get_open_batch
+
+Returns the currently open batch as an FS::bill_batch object, creating a new
+one if necessary.  (A per-agent batch if invoice_print_pdf-spoolagent is
+enabled)
+
+=cut
+
+sub get_open_bill_batch {
+  my $self = shift;
+  my $hashref = { status => 'O' };
+  $hashref->{'agentnum'} = $conf->exists('invoice_print_pdf-spoolagent')
+                             ? $self->cust_main->agentnum
+                             : '';
+  my $batch = qsearchs('bill_batch', $hashref);
+  return $batch if $batch;
+  $batch = FS::bill_batch->new($hashref);
+  my $error = $batch->insert;
+  die $error if $error;
+  return $batch;
+}
+
 =item ftp_invoice [ TEMPLATENAME ] 
 
 Sends this invoice data via FTP.
@@ -1950,7 +2090,8 @@ sub realtime_lec {
 }
 
 sub realtime_bop {
-  my( $self, $method ) = @_;
+  my( $self, $method ) = (shift,shift);
+  my %opt = @_;
 
   my $cust_main = $self->cust_main;
   my $balance = $cust_main->balance;
@@ -1979,6 +2120,7 @@ sub realtime_bop {
 #this didn't do what we want, it just calls apply_payments_and_credits
 #    'apply'       => 1,
     'apply_to_invoice' => 1,
+    %opt,
  #what we want:
  #this changes application behavior: auto payments
                         #triggered against a specific invoice are now applied
@@ -2048,7 +2190,7 @@ sub print_text {
   $params{'time'} = $today if $today;
   $params{'template'} = $template if $template;
   $params{$_} = $opt{$_} 
-    foreach grep $opt{$_}, qw( unsquealch_cdr notice_name );
+    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
 
   $self->print_generic( %params );
 }
@@ -2090,7 +2232,7 @@ sub print_latex {
   $params{'time'} = $today if $today;
   $params{'template'} = $template if $template;
   $params{$_} = $opt{$_} 
-    foreach grep $opt{$_}, qw( unsquealch_cdr notice_name );
+    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
 
   $template ||= $self->_agent_template;
 
@@ -2113,6 +2255,28 @@ sub print_latex {
   close $lh;
   $params{'logo_file'} = $lh->filename;
 
+  if($conf->exists('invoice-barcode')){
+      my $png_file = $self->invoice_barcode($dir);
+      my $eps_file = $png_file;
+      $eps_file =~ s/\.png$/.eps/g;
+      $png_file =~ /(barcode.*png)/;
+      $png_file = $1;
+      $eps_file =~ /(barcode.*eps)/;
+      $eps_file = $1;
+
+      my $curr_dir = cwd();
+      chdir($dir); 
+      # after painfuly long experimentation, it was determined that sam2p won't
+      #        accept : and other chars in the path, no matter how hard I tried to
+      # escape them, hence the chdir (and chdir back, just to be safe)
+      system('sam2p', '-j:quiet', $png_file, 'EPS:', $eps_file ) == 0
+       or die "sam2p failed: $!\n";
+      unlink($png_file);
+      chdir($curr_dir);
+
+      $params{'barcode_file'} = $eps_file;
+  }
+
   my @filled_in = $self->print_generic( %params );
   
   my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
@@ -2124,8 +2288,37 @@ sub print_latex {
   close $fh;
 
   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
-  return ($1, $params{'logo_file'});
+  return ($1, $params{'logo_file'}, $params{'barcode_file'});
+
+}
+
+=item invoice_barcode DIR_OR_FALSE
+
+Generates an invoice barcode PNG. If DIR_OR_FALSE is a true value,
+it is taken as the temp directory where the PNG file will be generated and the
+PNG file name is returned. Otherwise, the PNG image itself is returned.
 
+=cut
+
+sub invoice_barcode {
+    my ($self, $dir) = (shift,shift);
+    
+    my $gdbar = new GD::Barcode('Code39',$self->invnum);
+       die "can't create barcode: " . $GD::Barcode::errStr unless $gdbar;
+    my $gd = $gdbar->plot(Height => 30);
+
+    if($dir) {
+       my $bh = new File::Temp( TEMPLATE => 'barcode.'. $self->invnum. '.XXXXXXXX',
+                          DIR      => $dir,
+                          SUFFIX   => '.png',
+                          UNLINK   => 0,
+                        ) or die "can't open temp file: $!\n";
+       print $bh $gd->png or die "cannot write barcode to file: $!\n";
+       my $png_file = $bh->filename;
+       close $bh;
+       return $png_file;
+    }
+    return $gd->png;
 }
 
 =item print_generic OPTION => VALUE ...
@@ -2178,11 +2371,14 @@ sub print_generic {
                      'template' => [ '{', '}' ],
                    );
 
+  warn "$me print_generic creating template\n"
+    if $DEBUG > 1;
+
   #create the template
   my $template = $params{template} ? $params{template} : $self->_agent_template;
   my $templatefile = "invoice_$format";
   $templatefile .= "_$template"
-    if length($template);
+    if length($template) && $conf->exists($templatefile."_$template");
   my @invoice_template = map "$_\n", $conf->config($templatefile)
     or die "cannot load config data $templatefile";
 
@@ -2195,12 +2391,18 @@ sub print_generic {
     @invoice_template = _translate_old_latex_format(@invoice_template);
   } 
 
+  warn "$me print_generic creating T:T object\n"
+    if $DEBUG > 1;
+
   my $text_template = new Text::Template(
     TYPE => 'ARRAY',
     SOURCE => \@invoice_template,
     DELIMITERS => $delimiters{$format},
   );
 
+  warn "$me print_generic compiling T:T object\n"
+    if $DEBUG > 1;
+
   $text_template->compile()
     or die "Can't compile $templatefile: $Text::Template::ERROR\n";
 
@@ -2291,15 +2493,19 @@ sub print_generic {
   my $nbsp = $nbsps{$format};
 
   my %escape_functions = ( 'latex'    => \&_latex_escape,
-                           'html'     => \&_html_escape#\&encode_entities,
+                           'html'     => \&_html_escape_nbsp,#\&encode_entities,
                            'template' => sub { shift },
                          );
   my $escape_function = $escape_functions{$format};
+  my $escape_function_nonbsp = ($format eq 'html')
+                                 ? \&_html_escape : $escape_function;
 
-  my %date_formats = ( 'latex'    => '%b %o, %Y',
-                       'html'     => '%b&nbsp;%o,&nbsp;%Y',
+  my %date_formats = ( 'latex'    => $date_format_long,
+                       'html'     => $date_format_long,
                        'template' => '%s',
                      );
+  $date_formats{'html'} =~ s/ /&nbsp;/g;
+
   my $date_format = $date_formats{$format};
 
   my %embolden_functions = ( 'latex'    => sub { return '\textbf{'. shift(). '}'
@@ -2310,6 +2516,14 @@ sub print_generic {
                            );
   my $embolden_function = $embolden_functions{$format};
 
+  my %newline_tokens = (  'latex'     => '\\\\',
+                          'html'      => '<br>',
+                          'template'  => "\n",
+                        );
+  my $newline_token = $newline_tokens{$format};
+
+  warn "$me generating template variables\n"
+    if $DEBUG > 1;
 
   # generate template variables
   my $returnaddress;
@@ -2363,6 +2577,9 @@ sub print_generic {
 
   }
 
+  warn "$me generating invoice data\n"
+    if $DEBUG > 1;
+
   my $agentnum = $self->cust_main->agentnum;
 
   my %invoice_data = (
@@ -2370,13 +2587,14 @@ sub print_generic {
     #invoice from info
     'company_name'    => scalar( $conf->config('company_name', $agentnum) ),
     'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n",
+    'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ),
     'returnaddress'   => $returnaddress,
     'agent'           => &$escape_function($cust_main->agent->agent),
 
     #invoice info
     'invnum'          => $self->invnum,
     'date'            => time2str($date_format, $self->_date),
-    'today'           => time2str('%b %o, %Y', $today),
+    'today'           => time2str($date_format_long, $today),
     'terms'           => $self->terms,
     'template'        => $template, #params{'template'},
     'notice_name'     => ($params{'notice_name'} || 'Invoice'),#escape_function?
@@ -2418,6 +2636,21 @@ sub print_generic {
     'total_pages'     => 1,
 
   );
+  
+  my $min_sdate = 999999999999;
+  my $max_edate = 0;
+  foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+    next unless $cust_bill_pkg->pkgnum > 0;
+    $min_sdate = $cust_bill_pkg->sdate
+      if length($cust_bill_pkg->sdate) && $cust_bill_pkg->sdate < $min_sdate;
+    $max_edate = $cust_bill_pkg->edate
+      if length($cust_bill_pkg->edate) && $cust_bill_pkg->edate > $max_edate;
+  }
+
+  $invoice_data{'bill_period'} = '';
+  $invoice_data{'bill_period'} = time2str('%e %h', $min_sdate) 
+    . " to " . time2str('%e %h', $max_edate)
+    if ($max_edate != 0 && $min_sdate != 999999999999);
 
   $invoice_data{finance_section} = '';
   if ( $conf->config('finance_pkgclass') ) {
@@ -2469,6 +2702,12 @@ sub print_generic {
 
   $invoice_data{'logo_file'} = $params{'logo_file'}
     if $params{'logo_file'};
+  $invoice_data{'barcode_file'} = $params{'barcode_file'}
+    if $params{'barcode_file'};
+  $invoice_data{'barcode_img'} = $params{'barcode_img'}
+    if $params{'barcode_img'};
+  $invoice_data{'barcode_cid'} = $params{'barcode_cid'}
+    if $params{'barcode_cid'};
 
   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
@@ -2485,8 +2724,12 @@ sub print_generic {
   }
   $invoice_data{'summarypage'} = $summarypage;
 
-  #do variable substitution in notes, footer, smallfooter
-  foreach my $include (qw( notes footer smallfooter coupon )) {
+  warn "$me substituting variables in notes, footer, smallfooter\n"
+    if $DEBUG > 1;
+
+  my @include = (qw( notes footer smallfooter ));
+  push @include, 'coupon' unless $params{'no_coupon'};
+  foreach my $include (@include) {
 
     my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
     my @inc_src;
@@ -2556,12 +2799,15 @@ sub print_generic {
   $invoice_data{'buf'} = \@buf;
   $invoice_data{'sections'} = \@sections;
 
+  warn "$me generating sections\n"
+    if $DEBUG > 1;
+
   my $previous_section = { 'description' => 'Previous Charges',
                            'subtotal'    => $other_money_char.
                                             sprintf('%.2f', $pr_total),
                            'summarized'  => $summarypage ? 'Y' : '',
                          };
-  $previous_section->{posttotal} = '0 / 30 / 60/ 90 days overdue '. 
+  $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. 
     join(' / ', map { $cust_main->balance_date_range(@$_) }
                 $self->_prior_month30s
         )
@@ -2598,7 +2844,7 @@ sub print_generic {
   my $extra_lines = ();
   if ( $multisection ) {
     ($extra_sections, $extra_lines) =
-      $self->_items_extra_usage_sections($escape_function, $format)
+      $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
       if $conf->exists('usage_class_as_a_section', $cust_main->agentnum);
 
     push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
@@ -2607,16 +2853,24 @@ sub print_generic {
     push @sections,
       $self->_items_sections( $late_sections,      # this could stand a refactor
                               $summarypage,
-                              $escape_function,
+                              $escape_function_nonbsp,
                               $extra_sections,
                               $format,             #bah
                             );
     if ($conf->exists('svc_phone_sections')) {
       my ($phone_sections, $phone_lines) =
-        $self->_items_svc_phone_sections($escape_function, $format);
+        $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
       push @{$late_sections}, @$phone_sections;
       push @detail_items, @$phone_lines;
     }
+    if ($conf->exists('voip-cust_accountcode_cdr') && $cust_main->accountcode_cdr) {
+      my ($accountcode_section, $accountcode_lines) =
+        $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
+      if ( scalar(@$accountcode_lines) ) {
+          push @{$late_sections}, $accountcode_section;
+          push @detail_items, @$accountcode_lines;
+      }
+    }
   }else{
     push @sections, { 'description' => '', 'subtotal' => '' };
   }
@@ -2626,6 +2880,9 @@ sub print_generic {
          )
   {
 
+    warn "$me adding previous balances\n"
+      if $DEBUG > 1;
+
     foreach my $line_item ( $self->_items_previous ) {
 
       my $detail = {
@@ -2651,16 +2908,31 @@ sub print_generic {
     }
 
   }
-
+  
   if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) {
     push @buf, ['','-----------'];
     push @buf, [ 'Total Previous Balance',
                  $money_char. sprintf("%10.2f", $pr_total) ];
     push @buf, ['',''];
   }
+  if ( $conf->exists('svc_phone-did-summary') ) {
+      warn "$me adding DID summary\n"
+        if $DEBUG > 1;
+
+      my ($didsummary,$minutes) = $self->_did_summary;
+      my $didsummary_desc = 'DID Activity Summary (since last invoice)';
+      push @detail_items, 
+       { 'description' => $didsummary_desc,
+           'ext_description' => [ $didsummary, $minutes ],
+       };
+  }
 
   foreach my $section (@sections, @$late_sections) {
 
+    warn "$me adding section \n". Dumper($section)
+      if $DEBUG > 1;
+
     # begin some normalization
     $section->{'subtotal'} = $section->{'amount'}
       if $multisection
@@ -2686,6 +2958,9 @@ sub print_generic {
                  );
     }
 
+    warn "$me   setting options\n"
+      if $DEBUG > 1;
+
     my $multilocation = scalar($cust_main->cust_location); #too expensive?
     my %options = ();
     $options{'section'} = $section if $multisection;
@@ -2699,7 +2974,14 @@ sub print_generic {
     $options{'multilocation'} = $multilocation;
     $options{'multisection'} = $multisection;
 
+    warn "$me   searching for line items\n"
+      if $DEBUG > 1;
+
     foreach my $line_item ( $self->_items_pkg(%options) ) {
+
+      warn "$me     adding line item $line_item\n"
+        if $DEBUG > 1;
+
       my $detail = {
         ext_description => [],
       };
@@ -2745,6 +3027,9 @@ sub print_generic {
     unshift @sections, $previous_section if $pr_total;
   }
 
+  warn "$me adding taxes\n"
+    if $DEBUG > 1;
+
   foreach my $tax ( $self->_items_tax ) {
 
     $taxtotal += $tax->{'amount'};
@@ -2931,6 +3216,26 @@ sub print_generic {
       push @buf,[$self->balance_due_msg, $money_char. 
         sprintf("%10.2f", $balance_due ) ];
     }
+
+    if ( $conf->exists('previous_balance-show_credit')
+        and $cust_main->balance < 0 ) {
+      my $credit_total = {
+        'total_item'    => &$embolden_function($self->credit_balance_msg),
+        'total_amount'  => &$embolden_function(
+          $other_money_char. sprintf('%.2f', -$cust_main->balance)
+        ),
+      };
+      if ( $multisection ) {
+        $adjust_section->{'posttotal'} .= $newline_token .
+          $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
+      }
+      else {
+        push @total_items, $credit_total;
+      }
+      push @buf,['','-----------'];
+      push @buf,[$self->credit_balance_msg, $money_char. 
+        sprintf("%10.2f", -$cust_main->balance ) ];
+    }
   }
 
   if ( $multisection ) {
@@ -3078,10 +3383,10 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 sub print_ps {
   my $self = shift;
 
-  my ($file, $lfile) = $self->print_latex(@_);
+  my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
   my $ps = generate_ps($file);
-  unlink($file.'.tex');
-  unlink($lfile);
+  unlink($logofile);
+  unlink($barcodefile) if $barcodefile;
 
   $ps;
 }
@@ -3107,10 +3412,10 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 sub print_pdf {
   my $self = shift;
 
-  my ($file, $lfile) = $self->print_latex(@_);
+  my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
   my $pdf = generate_pdf($file);
-  unlink($file.'.tex');
-  unlink($lfile);
+  unlink($logofile);
+  unlink($barcodefile) if $barcodefile;
 
   $pdf;
 }
@@ -3145,7 +3450,7 @@ sub print_html {
   }
 
   $params{'format'} = 'html';
-
+  
   $self->print_generic( %params );
 }
 
@@ -3165,10 +3470,14 @@ sub _latex_escape {
   $value;
 }
 
-
 sub _html_escape {
   my $value = shift;
   encode_entities($value);
+  $value;
+}
+
+sub _html_escape_nbsp {
+  my $value = _html_escape(shift);
   $value =~ s/ +/&nbsp;/g;
   $value;
 }
@@ -3286,6 +3595,8 @@ sub balance_due_date {
   $duedate;
 }
 
+sub credit_balance_msg { 'Credit Balance Remaining' }
+
 =item invnum_date_pretty
 
 Returns a string with the invoice number and date, for example:
@@ -3427,7 +3738,7 @@ sub _items_sections {
                     }
                   } @sections;
   push @early, @$extra_sections if $extra_sections;
+
   sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
 
 }
@@ -3708,6 +4019,8 @@ sub _items_extra_usage_sections {
   my %classnums = ();
   my %lines = ();
 
+  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
+
   my %usage_class =  map { $_->classnum => $_ } qsearch( 'usage_class', {} );
   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
     next unless $cust_bill_pkg->pkgnum > 0;
@@ -3727,8 +4040,8 @@ sub _items_extra_usage_sections {
 
         my $desc = $detail->regionname; 
         my $description = $desc;
-        $description = substr($desc, 0, 50). '...'
-          if $format eq 'latex' && length($desc) > 50;
+        $description = substr($desc, 0, $maxlength). '...'
+          if $format eq 'latex' && length($desc) > $maxlength;
 
         $lines{$section}{$desc} ||= {
           description     => &{$escape}($description),
@@ -3789,6 +4102,149 @@ sub _items_extra_usage_sections {
 
 }
 
+sub _did_summary {
+    my $self = shift;
+    my $end = $self->_date;
+
+    # start at date of previous invoice + 1 second or 0 if no previous invoice
+    my $start = $self->scalar_sql("SELECT max(_date) FROM cust_bill WHERE custnum = ? and invnum != ?",$self->custnum,$self->invnum);
+    $start = 0 if !$start;
+    $start++;
+
+    my $cust_main = $self->cust_main;
+    my @pkgs = $cust_main->all_pkgs;
+    my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes)
+       = (0,0,0,0,0);
+    my @seen = ();
+    foreach my $pkg ( @pkgs ) {
+       my @h_cust_svc = $pkg->h_cust_svc($end);
+       foreach my $h_cust_svc ( @h_cust_svc ) {
+           next if grep {$_ eq $h_cust_svc->svcnum} @seen;
+           next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone';
+
+           my $inserted = $h_cust_svc->date_inserted;
+           my $deleted = $h_cust_svc->date_deleted;
+           my $phone_inserted = $h_cust_svc->h_svc_x($inserted+5);
+           my $phone_deleted;
+           $phone_deleted =  $h_cust_svc->h_svc_x($deleted) if $deleted;
+           
+# DID either activated or ported in; cannot be both for same DID simultaneously
+           if ($inserted >= $start && $inserted <= $end && $phone_inserted
+               && (!$phone_inserted->lnp_status 
+                   || $phone_inserted->lnp_status eq ''
+                   || $phone_inserted->lnp_status eq 'native')) {
+               $num_activated++;
+           }
+           else { # this one not so clean, should probably move to (h_)svc_phone
+                my $phone_portedin = qsearchs( 'h_svc_phone',
+                     { 'svcnum' => $h_cust_svc->svcnum, 
+                       'lnp_status' => 'portedin' },  
+                     FS::h_svc_phone->sql_h_searchs($end),  
+                   );
+                $num_portedin++ if $phone_portedin;
+           }
+
+# DID either deactivated or ported out;        cannot be both for same DID simultaneously
+           if($deleted >= $start && $deleted <= $end && $phone_deleted
+               && (!$phone_deleted->lnp_status 
+                   || $phone_deleted->lnp_status ne 'portingout')) {
+               $num_deactivated++;
+           } 
+           elsif($deleted >= $start && $deleted <= $end && $phone_deleted 
+               && $phone_deleted->lnp_status 
+               && $phone_deleted->lnp_status eq 'portingout') {
+               $num_portedout++;
+           }
+
+           # increment usage minutes
+        if ( $phone_inserted ) {
+            my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end,'billsec_sum'=>1);
+            $minutes = $cdrs[0]->billsec_sum if scalar(@cdrs) == 1;
+        }
+        else {
+            warn "WARNING: no matching h_svc_phone insert record for insert time $inserted, svcnum " . $h_cust_svc->svcnum;
+        }
+
+           # don't look at this service again
+           push @seen, $h_cust_svc->svcnum;
+       }
+    }
+
+    $minutes = sprintf("%d", $minutes);
+    ("Activated: $num_activated  Ported-In: $num_portedin  Deactivated: "
+       . "$num_deactivated  Ported-Out: $num_portedout ",
+           "Total Minutes: $minutes");
+}
+
+sub _items_accountcode_cdr {
+    my $self = shift;
+    my $escape = shift;
+    my $format = shift;
+
+    my $section = { 'amount'        => 0,
+                    'calls'         => 0,
+                    'duration'      => 0,
+                    'sort_weight'   => '',
+                    'phonenum'      => '',
+                    'description'   => 'Usage by Account Code',
+                    'post_total'    => '',
+                    'summarized'    => '',
+                    'header'        => '',
+                  };
+    my @lines;
+    my %accountcodes = ();
+
+    foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+        next unless $cust_bill_pkg->pkgnum > 0;
+
+        my @header = $cust_bill_pkg->details_header;
+        next unless scalar(@header);
+        $section->{'header'} = join(',',@header);
+
+        foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
+
+            $section->{'header'} = $detail->formatted('format' => $format)
+                if($detail->detail eq $section->{'header'}); 
+      
+            my $accountcode = $detail->accountcode;
+            next unless $accountcode;
+
+            my $amount = $detail->amount;
+            next unless $amount && $amount > 0;
+
+            $accountcodes{$accountcode} ||= {
+                    description => $accountcode,
+                    pkgnum      => '',
+                    ref         => '',
+                    amount      => 0,
+                    calls       => 0,
+                    duration    => 0,
+                    quantity    => '',
+                    product_code => 'N/A',
+                    section     => $section,
+                    ext_description => [],
+            };
+
+            $section->{'amount'} += $amount;
+            $accountcodes{$accountcode}{'amount'} += $amount;
+            $accountcodes{$accountcode}{calls}++;
+            $accountcodes{$accountcode}{duration} += $detail->duration;
+            push @{$accountcodes{$accountcode}{ext_description}},
+                $detail->formatted('format' => $format);
+        }
+    }
+
+    foreach my $l ( values %accountcodes ) {
+        $l->{amount} = sprintf( "%.2f", $l->{amount} );
+        unshift @{$l->{ext_description}}, $section->{'header'};
+        push @lines, $l;
+    }
+
+    my @sorted_lines = sort { $a->{'description'} <=> $b->{'description'} } @lines;
+
+    return ($section,\@sorted_lines);
+}
+
 sub _items_svc_phone_sections {
   my $self = shift;
   my $escape = shift;
@@ -3798,6 +4254,8 @@ sub _items_svc_phone_sections {
   my %classnums = ();
   my %lines = ();
 
+  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
+
   my %usage_class =  map { $_->classnum => $_ } qsearch( 'usage_class', {} );
   $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 };
 
@@ -3827,8 +4285,8 @@ sub _items_svc_phone_sections {
 
       my $desc = $detail->regionname; 
       my $description = $desc;
-      $description = substr($desc, 0, 50). '...'
-        if $format eq 'latex' && length($desc) > 50;
+      $description = substr($desc, 0, $maxlength). '...'
+        if $format eq 'latex' && length($desc) > $maxlength;
 
       $lines{$phonenum}{$desc} ||= {
         description     => &{$escape}($description),
@@ -3933,6 +4391,85 @@ sub _items_svc_phone_sections {
       push @lines, $l;
     }
   }
+  
+  if($conf->exists('phone_usage_class_summary')) { 
+      # this only works with Latex
+      my @newlines;
+      my @newsections;
+
+      # after this, we'll have only two sections per DID:
+      # Calls Summary and Calls Detail
+      foreach my $section ( @sections ) {
+       if($section->{'post_total'}) {
+           $section->{'description'} = 'Calls Summary: '.$section->{'phonenum'};
+           $section->{'total_line_generator'} = sub { '' };
+           $section->{'total_generator'} = sub { '' };
+           $section->{'header_generator'} = sub { '' };
+           $section->{'description_generator'} = '';
+           push @newsections, $section;
+           my %calls_detail = %$section;
+           $calls_detail{'post_total'} = '';
+           $calls_detail{'sort_weight'} = '';
+           $calls_detail{'description_generator'} = sub { '' };
+           $calls_detail{'header_generator'} = sub {
+               return ' & Date/Time & Called Number & Duration & Price'
+                   if $format eq 'latex';
+               '';
+           };
+           $calls_detail{'description'} = 'Calls Detail: '
+                                                   . $section->{'phonenum'};
+           push @newsections, \%calls_detail;  
+       }
+      }
+
+      # after this, each usage class is collapsed/summarized into a single
+      # line under the Calls Summary section
+      foreach my $newsection ( @newsections ) {
+       if($newsection->{'post_total'}) { # this means Calls Summary
+           foreach my $section ( @sections ) {
+               next unless ($section->{'phonenum'} eq $newsection->{'phonenum'} 
+                               && !$section->{'post_total'});
+               my $newdesc = $section->{'description'};
+               my $tn = $section->{'phonenum'};
+               $newdesc =~ s/$tn//g;
+               my $line = {  ext_description => [],
+                             pkgnum => '',
+                             ref => '',
+                             quantity => '',
+                             calls => $section->{'calls'},
+                             section => $newsection,
+                             duration => $section->{'duration'},
+                             description => $newdesc,
+                             amount => sprintf("%.2f",$section->{'amount'}),
+                             product_code => 'N/A',
+                           };
+               push @newlines, $line;
+           }
+       }
+      }
+
+      # after this, Calls Details is populated with all CDRs
+      foreach my $newsection ( @newsections ) {
+       if(!$newsection->{'post_total'}) { # this means Calls Details
+           foreach my $line ( @lines ) {
+               next unless (scalar(@{$line->{'ext_description'}}) &&
+                       $line->{'section'}->{'phonenum'} eq $newsection->{'phonenum'}
+                           );
+               my @extdesc = @{$line->{'ext_description'}};
+               my @newextdesc;
+               foreach my $extdesc ( @extdesc ) {
+                   $extdesc =~ s/scriptsize/normalsize/g if $format eq 'latex';
+                   push @newextdesc, $extdesc;
+               }
+               $line->{'ext_description'} = \@newextdesc;
+               $line->{'section'} = $newsection;
+               push @newlines, $line;
+           }
+       }
+      }
+
+      return(\@newsections, \@newlines);
+  }
 
   return(\@sections, \@lines);
 
@@ -3990,9 +4527,21 @@ sub _items_previous {
 sub _items_pkg {
   my $self = shift;
   my %options = @_;
+
+  warn "$me _items_pkg searching for all package line items\n"
+    if $DEBUG > 1;
+
   my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
+
+  warn "$me _items_pkg filtering line items\n"
+    if $DEBUG > 1;
   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
+
   if ($options{section} && $options{section}->{condensed}) {
+
+    warn "$me _items_pkg condensing section\n"
+      if $DEBUG > 1;
+
     my %itemshash = ();
     local $Storable::canonical = 1;
     foreach ( @items ) {
@@ -4012,6 +4561,10 @@ sub _items_pkg {
                  }
              keys %itemshash;
   }
+
+  warn "$me _items_pkg returning ". scalar(@items). " items\n"
+    if $DEBUG > 1;
+
   @items;
 }
 
@@ -4032,7 +4585,7 @@ sub _items_tax {
 
 sub _items_cust_bill_pkg {
   my $self = shift;
-  my $cust_bill_pkg = shift;
+  my $cust_bill_pkgs = shift;
   my %opt = @_;
 
   my $format = $opt{format} || '';
@@ -4043,22 +4596,18 @@ sub _items_cust_bill_pkg {
   my $summary_page = $opt{summary_page} || '';
   my $multilocation = $opt{multilocation} || '';
   my $multisection = $opt{multisection} || '';
+  my $discount_show_always = 0;
+
+  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
 
   my @b = ();
   my ($s, $r, $u) = ( undef, undef, undef );
-  foreach my $cust_bill_pkg ( @$cust_bill_pkg )
+  foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
   {
 
-    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
-      if ( $_ && !$cust_bill_pkg->hidden ) {
-        $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
-        $_->{amount}      =~ s/^\-0\.00$/0.00/;
-        $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
-        push @b, { %$_ }
-          unless $_->{amount} == 0;
-        $_ = undef;
-      }
-    }
+    warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
+         $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
+      if $DEBUG > 1;
 
     foreach my $display ( grep { defined($section)
                                  ? $_->section eq $section
@@ -4070,11 +4619,14 @@ sub _items_cust_bill_pkg {
                         )
     {
 
+      warn "$me _items_cust_bill_pkg considering display item $display\n"
+        if $DEBUG > 1;
+
       my $type = $display->type;
 
       my $desc = $cust_bill_pkg->desc;
-      $desc = substr($desc, 0, 50). '...'
-        if $format eq 'latex' && length($desc) > 50;
+      $desc = substr($desc, 0, $maxlength). '...'
+        if $format eq 'latex' && length($desc) > $maxlength;
 
       my %details_opt = ( 'format'          => $format,
                           'escape_function' => $escape_function,
@@ -4083,26 +4635,45 @@ sub _items_cust_bill_pkg {
 
       if ( $cust_bill_pkg->pkgnum > 0 ) {
 
+        warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
+          if $DEBUG > 1;
         my $cust_pkg = $cust_bill_pkg->cust_pkg;
 
-        if ( $cust_bill_pkg->setup != 0 && (!$type || $type eq 'S') ) {
+        if (    (!$type || $type eq 'S')
+             && (    $cust_bill_pkg->setup != 0
+                  || $cust_bill_pkg->setup_show_zero
+                )
+           )
+         {
+
+          warn "$me _items_cust_bill_pkg adding setup\n"
+            if $DEBUG > 1;
 
           my $description = $desc;
-          $description .= ' Setup' if $cust_bill_pkg->recur != 0;
+          $description .= ' Setup'
+            if $cust_bill_pkg->recur != 0
+            || $discount_show_always
+            || $cust_bill_pkg->recur_show_zero;
 
           my @d = ();
           unless ( $cust_pkg->part_pkg->hide_svc_detail
                 || $cust_bill_pkg->hidden )
           {
+
             push @d, map &{$escape_function}($_),
-                         $cust_pkg->h_labels_short($self->_date);
+                         $cust_pkg->h_labels_short($self->_date, undef, 'I')
+              unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
+
             if ( $multilocation ) {
               my $loc = $cust_pkg->location_label;
-              $loc = substr($loc, 0, 50). '...'
-                if $format eq 'latex' && length($loc) > 50;
+              $loc = substr($loc, 0, $maxlength). '...'
+                if $format eq 'latex' && length($loc) > $maxlength;
               push @d, &{$escape_function}($loc);
             }
+
           }
+
           push @d, $cust_bill_pkg->details(%details_opt)
             if $cust_bill_pkg->recur == 0;
 
@@ -4112,6 +4683,7 @@ sub _items_cust_bill_pkg {
             push @{ $s->{ext_description} }, @d;
           } else {
             $s = {
+              _is_setup       => 1,
               description     => $description,
               #pkgpart         => $part_pkg->pkgpart,
               pkgnum          => $cust_bill_pkg->pkgnum,
@@ -4124,19 +4696,28 @@ sub _items_cust_bill_pkg {
 
         }
 
-        if ( ( $cust_bill_pkg->recur != 0  || $cust_bill_pkg->setup == 0 ) &&
-             ( !$type || $type eq 'R' || $type eq 'U' )
+        if (    ( !$type || $type eq 'R' || $type eq 'U' )
+             && (
+                     $cust_bill_pkg->recur != 0
+                  || $cust_bill_pkg->setup == 0
+                  || $discount_show_always
+                  || $cust_bill_pkg->recur_show_zero
+                )
            )
         {
 
+          warn "$me _items_cust_bill_pkg adding recur/usage\n"
+            if $DEBUG > 1;
+
           my $is_summary = $display->summary;
           my $description = ($is_summary && $type && $type eq 'U')
                             ? "Usage charges" : $desc;
 
-          unless ( $conf->exists('disable_line_item_date_ranges') ) {
-            $description .= " (" . time2str($date_format, $cust_bill_pkg->sdate).
-                            " - ". time2str($date_format, $cust_bill_pkg->edate). ")";
-          }
+          $description .= " (" . time2str($date_format, $cust_bill_pkg->sdate).
+                          " - ". time2str($date_format, $cust_bill_pkg->edate).
+                          ")"
+            unless $conf->exists('disable_line_item_date_ranges')
+                || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
 
           my @d = ();
 
@@ -4145,39 +4726,64 @@ sub _items_cust_bill_pkg {
           my @dates = ( $self->_date );
           my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
           push @dates, $prev->sdate if $prev;
+          push @dates, undef if !$prev;
 
           unless ( $cust_pkg->part_pkg->hide_svc_detail
                 || $cust_bill_pkg->itemdesc
                 || $cust_bill_pkg->hidden
                 || $is_summary && $type && $type eq 'U' )
           {
+
+            warn "$me _items_cust_bill_pkg adding service details\n"
+              if $DEBUG > 1;
+
             push @d, map &{$escape_function}($_),
-                         $cust_pkg->h_labels_short(@dates)
+                         $cust_pkg->h_labels_short(@dates, 'I')
                                                    #$cust_bill_pkg->edate,
                                                    #$cust_bill_pkg->sdate)
-            ;
+              unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
+
+            warn "$me _items_cust_bill_pkg done adding service details\n"
+              if $DEBUG > 1;
+
             if ( $multilocation ) {
               my $loc = $cust_pkg->location_label;
-              $loc = substr($loc, 0, 50). '...'
-                if $format eq 'latex' && length($loc) > 50;
+              $loc = substr($loc, 0, $maxlength). '...'
+                if $format eq 'latex' && length($loc) > $maxlength;
               push @d, &{$escape_function}($loc);
             }
+
           }
 
-          push @d, $cust_bill_pkg->details(%details_opt)
-            unless ($is_summary || $type && $type eq 'R');
+          unless ( $is_summary ) {
+            warn "$me _items_cust_bill_pkg adding details\n"
+              if $DEBUG > 1;
+
+            #instead of omitting details entirely in this case (unwanted side
+            # effects), just omit CDRs
+            $details_opt{'format_function'} = sub { () }
+              if $type && $type eq 'R';
+
+            push @d, $cust_bill_pkg->details(%details_opt);
+          }
+
+          warn "$me _items_cust_bill_pkg calculating amount\n"
+            if $DEBUG > 1;
   
           my $amount = 0;
           if (!$type) {
             $amount = $cust_bill_pkg->recur;
-          }elsif($type eq 'R') {
+          } elsif ($type eq 'R') {
             $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
-          }elsif($type eq 'U') {
+          } elsif ($type eq 'U') {
             $amount = $cust_bill_pkg->usage;
           }
   
           if ( !$type || $type eq 'R' ) {
 
+            warn "$me _items_cust_bill_pkg adding recur\n"
+              if $DEBUG > 1;
+
             if ( $cust_bill_pkg->hidden ) {
               $r->{amount}      += $amount;
               $r->{unit_amount} += $cust_bill_pkg->unitrecur;
@@ -4196,6 +4802,9 @@ sub _items_cust_bill_pkg {
 
           } else {  # $type eq 'U'
 
+            warn "$me _items_cust_bill_pkg adding usage\n"
+              if $DEBUG > 1;
+
             if ( $cust_bill_pkg->hidden ) {
               $u->{amount}      += $amount;
               $u->{unit_amount} += $cust_bill_pkg->unitrecur;
@@ -4211,13 +4820,15 @@ sub _items_cust_bill_pkg {
                 ext_description => \@d,
               };
             }
-
           }
 
         } # recurring or usage with recurring charge
 
       } else { #pkgnum tax or one-shot line item (??)
 
+        warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
+          if $DEBUG > 1;
+
         if ( $cust_bill_pkg->setup != 0 ) {
           push @b, {
             'description' => $desc,
@@ -4237,18 +4848,40 @@ sub _items_cust_bill_pkg {
 
     }
 
-  }
+    $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
+                                && $conf->exists('discount-show-always'));
 
-  foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
-    if ( $_  ) {
-      $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
-      $_->{amount}      =~ s/^\-0\.00$/0.00/;
-      $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
-      push @b, { %$_ }
-        unless $_->{amount} == 0;
+    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+      if ( $_ && !$cust_bill_pkg->hidden ) {
+        $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+        $_->{amount}      =~ s/^\-0\.00$/0.00/;
+        $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+        push @b, { %$_ }
+          if $_->{amount} != 0
+          || $discount_show_always
+          || ( ! $_->{_is_setup} && $cust_bill_pkg->recur_show_zero )
+          || (   $_->{_is_setup} && $cust_bill_pkg->setup_show_zero )
+        ;
+        $_ = undef;
+      }
     }
+
   }
 
+  #foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+  #  if ( $_  ) {
+  #    $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+  #    $_->{amount}      =~ s/^\-0\.00$/0.00/;
+  #    $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+  #    push @b, { %$_ }
+  #      if $_->{amount} != 0
+  #      || $discount_show_always
+  #  }
+  #}
+
+  warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
+    if $DEBUG > 1;
+
   @b;
 
 }