per-agent disable_previous_balance, #15863
[freeside.git] / FS / FS / cust_bill.pm
index fed1066..35ce48c 100644 (file)
@@ -1,13 +1,15 @@
 package FS::cust_bill;
 
 use strict;
-use vars qw( @ISA $DEBUG $me $conf
+use vars qw( @ISA $DEBUG $me 
              $money_char $date_format $rdate_format $date_format_long );
+             # but NOT $conf
 use vars qw( $invoice_lines @buf ); #yuck
 use Fcntl qw(:flock); #for spool_csv
 use Cwd;
-use List::Util qw(min max);
+use List::Util qw(min max sum);
 use Date::Format;
+use Date::Language;
 use Text::Template 1.20;
 use File::Temp 0.14;
 use String::ShellQuote;
@@ -41,6 +43,8 @@ use FS::bill_batch;
 use FS::cust_bill_batch;
 use FS::cust_bill_pay_pkg;
 use FS::cust_credit_bill_pkg;
+use FS::discount_plan;
+use FS::L10N;
 
 @ISA = qw( FS::cust_main_Mixin FS::Record );
 
@@ -49,7 +53,7 @@ $me = '[FS::cust_bill]';
 
 #ask FS::UID to run this stuff for us later
 FS::UID->install_callback( sub { 
-  $conf = new FS::Conf;
+  my $conf = new FS::Conf; #global
   $money_char       = $conf->config('money_char')       || '$';  
   $date_format      = $conf->config('date_format')      || '%x'; #/YY
   $rdate_format     = $conf->config('date_format')      || '%m/%d/%Y';  #/YYYY
@@ -140,6 +144,8 @@ Specific use cases
 
 =item agent_invid - legacy invoice number
 
+=item promised_date - customer promised payment date, for collection
+
 =back
 
 =head1 METHODS
@@ -240,11 +246,11 @@ sub delete {
     cust_event
     cust_credit_bill
     cust_bill_pay
-    cust_bill_pay
     cust_credit_bill
     cust_pay_batch
     cust_bill_pay_batch
     cust_bill_pkg
+    cust_bill_batch
   )) {
 
     foreach my $linked ( $self->$table() ) {
@@ -363,6 +369,7 @@ cust_bill-default_agent_invid is set and it has a value, invnum otherwise.
 
 sub display_invnum {
   my $self = shift;
+  my $conf = $self->conf;
   if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){
     return $self->agent_invid;
   } else {
@@ -733,6 +740,29 @@ sub cust_credit_bill_pkg {
 
 }
 
+=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 discount_plans
+
+Returns all discount plans (L<FS::discount_plan>) for this invoice, as a 
+hash keyed by term length.
+
+=cut
+
+sub discount_plans {
+  my $self = shift;
+  FS::discount_plan->all($self);
+}
+
 =item tax
 
 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
@@ -781,6 +811,23 @@ sub owed_pkgnum {
   $balance;
 }
 
+=item hide
+
+Returns true if this invoice should be hidden.  See the
+selfservice-hide_invoices-taxclass configuraiton setting.
+
+=cut
+
+sub hide {
+  my $self = shift;
+  my $conf = $self->conf;
+  my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
+    or return '';
+  my @cust_bill_pkg = $self->cust_bill_pkg;
+  my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
+  ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
+}
+
 =item apply_payments_and_credits [ OPTION => VALUE ... ]
 
 Applies unapplied payments and credits to this invoice.
@@ -795,6 +842,7 @@ If there is an error, returns the error, otherwise returns false.
 
 sub apply_payments_and_credits {
   my( $self, %options ) = @_;
+  my $conf = $self->conf;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -943,6 +991,7 @@ sub generate_email {
 
   my $self = shift;
   my %args = @_;
+  my $conf = $self->conf;
 
   my $me = '[FS::cust_bill::generate_email]';
 
@@ -977,7 +1026,7 @@ sub generate_email {
 
     my $alternative = build MIME::Entity
       'Type'        => 'multipart/alternative',
-      'Encoding'    => '7bit',
+      #'Encoding'    => '7bit',
       'Disposition' => 'inline'
     ;
 
@@ -1005,47 +1054,60 @@ sub generate_email {
 
     $alternative->attach(
       'Type'        => 'text/plain',
-      #'Encoding'    => 'quoted-printable',
-      'Encoding'    => '7bit',
+      'Encoding'    => 'quoted-printable',
+      #'Encoding'    => '7bit',
       'Data'        => $data,
       'Disposition' => 'inline',
     );
 
-    $args{'from'} =~ /\@([\w\.\-]+)/;
-    my $from = $1 || 'example.com';
-    my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
 
-    my $logo;
-    my $agentnum = $cust_main->agentnum;
-    if ( defined($args{'template'}) && length($args{'template'})
-         && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
-       )
-    {
-      $logo = 'logo_'. $args{'template'}. '.png';
+    my $htmldata;
+    my $image = '';
+    my $barcode = '';
+    if ( $conf->exists('invoice_email_pdf')
+         and scalar($conf->config('invoice_email_pdf_note')) ) {
+
+      $htmldata = join('<BR>', $conf->config('invoice_email_pdf_note') );
+
     } else {
-      $logo = "logo.png";
-    }
-    my $image_data = $conf->config_binary( $logo, $agentnum);
-
-    my $image = build MIME::Entity
-      'Type'       => 'image/png',
-      'Encoding'   => 'base64',
-      'Data'       => $image_data,
-      'Filename'   => 'logo.png',
-      'Content-ID' => "<$content_id>",
-    ;
+
+      $args{'from'} =~ /\@([\w\.\-]+)/;
+      my $from = $1 || 'example.com';
+      my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+
+      my $logo;
+      my $agentnum = $cust_main->agentnum;
+      if ( defined($args{'template'}) && length($args{'template'})
+           && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
+         )
+      {
+        $logo = 'logo_'. $args{'template'}. '.png';
+      } else {
+        $logo = "logo.png";
+      }
+      my $image_data = $conf->config_binary( $logo, $agentnum);
+
+      $image = build MIME::Entity
+        'Type'       => 'image/png',
+        'Encoding'   => 'base64',
+        'Data'       => $image_data,
+        '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;
+      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;
+      }
+
+      $htmldata = $self->print_html({ 'cid'=>$content_id, %opt });
     }
 
     $alternative->attach(
@@ -1058,7 +1120,7 @@ sub generate_email {
                          '    </title>',
                          '  </head>',
                          '  <body bgcolor="#e8e8e8">',
-                         $self->print_html({ 'cid'=>$content_id, %opt }),
+                         $htmldata,
                          '  </body>',
                          '</html>',
                        ],
@@ -1066,6 +1128,7 @@ sub generate_email {
       #'Filename'    => 'invoice.pdf',
     );
 
+
     my @otherparts = ();
     if ( $cust_main->email_csv_cdr ) {
 
@@ -1104,7 +1167,7 @@ sub generate_email {
 
       $related->add_part($alternative);
 
-      $related->add_part($image);
+      $related->add_part($image) if $image;
 
       my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt);
 
@@ -1120,11 +1183,10 @@ sub generate_email {
       #   image/png
 
       $return{'content-type'} = 'multipart/related';
-      if($conf->exists('invoice-barcode')){
-         $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
-      }
-      else {
-         $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
+      if ($conf->exists('invoice-barcode') && $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';
@@ -1228,6 +1290,7 @@ sub queueable_send {
 
 sub send {
   my $self = shift;
+  my $conf = $self->conf;
 
   my( $template, $invoice_from, $notice_name );
   my $agentnums = '';
@@ -1317,6 +1380,8 @@ sub queueable_email {
 #sub email_invoice {
 sub email {
   my $self = shift;
+  return if $self->hide;
+  my $conf = $self->conf;
 
   my( $template, $invoice_from, $notice_name, $no_coupon );
   if ( ref($_[0]) ) {
@@ -1366,6 +1431,7 @@ sub email {
 
 sub email_subject {
   my $self = shift;
+  my $conf = $self->conf;
 
   #my $template = scalar(@_) ? shift : '';
   #per-template?
@@ -1397,6 +1463,7 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub lpr_data {
   my $self = shift;
+  my $conf = $self->conf;
   my( $template, $notice_name );
   if ( ref($_[0]) ) {
     my $opt = shift;
@@ -1432,6 +1499,9 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 #sub print_invoice {
 sub print {
   my $self = shift;
+  return if $self->hide;
+  my $conf = $self->conf;
+
   my( $template, $notice_name );
   if ( ref($_[0]) ) {
     my $opt = shift;
@@ -1471,6 +1541,9 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub fax_invoice {
   my $self = shift;
+  return if $self->hide;
+  my $conf = $self->conf;
+
   my( $template, $notice_name );
   if ( ref($_[0]) ) {
     my $opt = shift;
@@ -1508,14 +1581,37 @@ 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 $conf = $self->conf;
+  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.
@@ -1526,6 +1622,7 @@ TEMPLATENAME is unused?
 
 sub ftp_invoice {
   my $self = shift;
+  my $conf = $self->conf;
   my $template = scalar(@_) ? shift : '';
 
   $self->send_csv(
@@ -1548,6 +1645,7 @@ TEMPLATENAME is unused?
 
 sub spool_invoice {
   my $self = shift;
+  my $conf = $self->conf;
   my $template = scalar(@_) ? shift : '';
 
   $self->spool_csv(
@@ -2057,6 +2155,7 @@ sub realtime_lec {
 
 sub realtime_bop {
   my( $self, $method ) = (shift,shift);
+  my $conf = $self->conf;
   my %opt = @_;
 
   my $cust_main = $self->cust_main;
@@ -2156,7 +2255,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 );
 }
@@ -2185,6 +2284,7 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub print_latex {
   my $self = shift;
+  my $conf = $self->conf;
   my( $today, $template, %opt );
   if ( ref($_[0]) ) {
     %opt = %{ shift() };
@@ -2198,7 +2298,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;
 
@@ -2250,6 +2350,7 @@ sub print_latex {
                            SUFFIX   => '.tex',
                            UNLINK   => 0,
                          ) or die "can't open temp file: $!\n";
+  binmode($fh, ':utf8'); # language support
   print $fh join('', @filled_in );
   close $fh;
 
@@ -2311,14 +2412,16 @@ unsquelch_cdr - overrides any per customer cdr squelching when true
 
 notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
 
+locale - override customer's locale
+
 =cut
 
 #what's with all the sprintf('%10.2f')'s in here?  will it cause any
 # (alignment in text invoice?) problems to change them all to '%.2f' ?
-# yes: fixed width (dot matrix) text printing will be borked
+# yes: fixed width/plain text printing will be borked
 sub print_generic {
-
   my( $self, %params ) = @_;
+  my $conf = $self->conf;
   my $today = $params{today} ? $params{today} : time;
   warn "$me print_generic called on $self with suffix $params{template}\n"
     if $DEBUG;
@@ -2553,7 +2656,7 @@ 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'=> $conf->config('company_phonenum', $agentnum),
+    'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ),
     'returnaddress'   => $returnaddress,
     'agent'           => &$escape_function($cust_main->agent->agent),
 
@@ -2602,7 +2705,18 @@ sub print_generic {
     'total_pages'     => 1,
 
   );
-  
+  #localization
+  my $lh = FS::L10N->get_handle( $params{'locale'} || $cust_main->locale );
+  $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
+  my %info = FS::Locales->locale_info($cust_main->locale || 'en_US');
+  # eval to avoid death for unimplemented languages
+  my $dh = eval { Date::Language->new($info{'name'}) } ||
+           Date::Language->new(); # fall back to English
+  # prototype here to silence warnings
+  $invoice_data{'time2str'} = sub ($;$$) { $dh->time2str(@_) };
+  # eventually use this date handle everywhere in here, too
+
   my $min_sdate = 999999999999;
   my $max_edate = 0;
   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
@@ -2679,11 +2793,30 @@ sub print_generic {
 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
   #my $balance_due = $self->owed + $pr_total - $cr_total;
   my $balance_due = $self->owed + $pr_total;
+
+  # the customer's current balance as shown on the invoice before this one
   $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) );
+
+  # the change in balance from that invoice to this one
   $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) );
+
+  # the sum of amount owed on all previous invoices
   $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
+
+  # the sum of amount owed on all invoices
   $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
 
+  # info from customer's last invoice before this one, for some 
+  # summary formats
+  $invoice_data{'last_bill'} = {};
+  my $last_bill = $pr_cust_bill[-1];
+  if ( $last_bill ) {
+    $invoice_data{'last_bill'} = {
+      '_date'     => $last_bill->_date, #unformatted
+      # all we need for now
+    };
+  }
+
   my $summarypage = '';
   if ( $conf->exists('invoice_usesummary', $agentnum) ) {
     $summarypage = 1;
@@ -2737,9 +2870,12 @@ sub print_generic {
       if ($format eq 'latex');
   }
 
-  $invoice_data{'po_line'} =
+  # let invoices use either of these as needed
+  $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') 
+    ? $cust_main->payinfo : '';
+  $invoice_data{'po_line'} = 
     (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
-      ? &$escape_function("Purchase Order #". $cust_main->payinfo)
+      ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
       : $nbsp;
 
   my %money_chars = ( 'latex'    => '',
@@ -2768,10 +2904,10 @@ sub print_generic {
   warn "$me generating sections\n"
     if $DEBUG > 1;
 
-  my $previous_section = { 'description' => 'Previous Charges',
+  my $previous_section = { 'description' => $self->mt('Previous Charges'),
                            'subtotal'    => $other_money_char.
                                             sprintf('%.2f', $pr_total),
-                           'summarized'  => $summarypage ? 'Y' : '',
+                           'summarized'  => '', #why? $summarypage ? 'Y' : '',
                          };
   $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. 
     join(' / ', map { $cust_main->balance_date_range(@$_) }
@@ -2780,26 +2916,25 @@ sub print_generic {
     if $conf->exists('invoice_include_aging');
 
   my $taxtotal = 0;
-  my $tax_section = { 'description' => 'Taxes, Surcharges, and Fees',
+  my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
                       'subtotal'    => $taxtotal,   # adjusted below
-                      'summarized'  => $summarypage ? 'Y' : '',
                     };
   my $tax_weight = _pkg_category($tax_section->{description})
                         ? _pkg_category($tax_section->{description})->weight
                         : 0;
-  $tax_section->{'summarized'} = $summarypage && !$tax_weight ? 'Y' : '';
+  $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
   $tax_section->{'sort_weight'} = $tax_weight;
 
 
   my $adjusttotal = 0;
-  my $adjust_section = { 'description' => 'Credits, Payments, and Adjustments',
+  my $adjust_section = { 'description' => 
+    $self->mt('Credits, Payments, and Adjustments'),
                          'subtotal'    => 0,   # adjusted below
-                         'summarized'  => $summarypage ? 'Y' : '',
                        };
   my $adjust_weight = _pkg_category($adjust_section->{description})
                         ? _pkg_category($adjust_section->{description})->weight
                         : 0;
-  $adjust_section->{'summarized'} = $summarypage && !$adjust_weight ? 'Y' : '';
+  $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
   $adjust_section->{'sort_weight'} = $adjust_weight;
 
   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
@@ -2829,11 +2964,36 @@ sub print_generic {
       push @{$late_sections}, @$phone_sections;
       push @detail_items, @$phone_lines;
     }
-  }else{
-    push @sections, { 'description' => '', 'subtotal' => '' };
+    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 {# not multisection
+    # make a default section
+    push @sections, { 'description' => '', 'subtotal' => '', 
+      'no_subtotal' => 1 };
+    # and calculate the finance charge total, since it won't get done otherwise.
+    # XXX possibly other totals?
+    # XXX possibly finance_pkgclass should not be used in this manner?
+    if ( $conf->exists('finance_pkgclass') ) {
+      my @finance_charges;
+      foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+        if ( grep { $_->section eq $invoice_data{finance_section} }
+             $cust_bill_pkg->cust_bill_pkg_display ) {
+          # I think these are always setup fees, but just to be sure...
+          push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
+        }
+      }
+      $invoice_data{finance_amount} = 
+        sprintf('%.2f', sum( @finance_charges ) || 0);
+    }
   }
 
-  unless (    $conf->exists('disable_previous_balance')
+  unless (    $conf->exists('disable_previous_balance', $agentnum)
            || $conf->exists('previous_balance-summary_only')
          )
   {
@@ -2866,10 +3026,11 @@ sub print_generic {
     }
 
   }
-
-  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) {
+  
+  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance', $agentnum) ) 
+    {
     push @buf, ['','-----------'];
-    push @buf, [ 'Total Previous Balance',
+    push @buf, [ $self->mt('Total Previous Balance'),
                  $money_char. sprintf("%10.2f", $pr_total) ];
     push @buf, ['',''];
   }
@@ -2879,12 +3040,11 @@ sub print_generic {
         if $DEBUG > 1;
 
       my ($didsummary,$minutes) = $self->_did_summary;
-      my $didsummary_desc = 'DID Activity Summary (Past 30 days)';
+      my $didsummary_desc = 'DID Activity Summary (since last invoice)';
       push @detail_items, 
-       { 'description' => $didsummary_desc,
-           'ext_description' => [ $didsummary, $minutes ],
-       }
-       if !$multisection;
+       { 'description' => $didsummary_desc,
+           'ext_description' => [ $didsummary, $minutes ],
+       };
   }
 
   foreach my $section (@sections, @$late_sections) {
@@ -2925,7 +3085,7 @@ sub print_generic {
     $options{'section'} = $section if $multisection;
     $options{'format'} = $format;
     $options{'escape_function'} = $escape_function;
-    $options{'format_function'} = sub { () } unless $unsquelched;
+    $options{'no_usage'} = 1 unless $unsquelched;
     $options{'unsquelched'} = $unsquelched;
     $options{'summary_page'} = $summarypage;
     $options{'skip_usage'} =
@@ -2956,6 +3116,10 @@ sub print_generic {
       $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
                                  $line_item->{'unit_amount'};
       $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+
+      $detail->{'sdate'} = $line_item->{'sdate'};
+      $detail->{'edate'} = $line_item->{'edate'};
+      $detail->{'seconds'} = $line_item->{'seconds'};
   
       push @detail_items, $detail;
       push @buf, ( [ $detail->{'description'},
@@ -2968,7 +3132,7 @@ sub print_generic {
     if ( $section->{'description'} ) {
       push @buf, ( ['','-----------'],
                    [ $section->{'description'}. ' sub-total',
-                      $money_char. sprintf("%10.2f", $section->{'subtotal'})
+                      $section->{'subtotal'} # already formatted this 
                    ],
                    [ '', '' ],
                    [ '', '' ],
@@ -2976,11 +3140,11 @@ sub print_generic {
     }
   
   }
-  
+
   $invoice_data{current_less_finance} =
     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
 
-  if ( $multisection && !$conf->exists('disable_previous_balance')
+  if ( $multisection && !$conf->exists('disable_previous_balance', $agentnum)
     || $conf->exists('previous_balance-summary_only') )
   {
     unshift @sections, $previous_section if $pr_total;
@@ -3026,7 +3190,7 @@ sub print_generic {
   
   if ( $taxtotal ) {
     my $total = {};
-    $total->{'total_item'} = 'Sub-total';
+    $total->{'total_item'} = $self->mt('Sub-total');
     $total->{'total_amount'} =
       $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
 
@@ -3043,7 +3207,8 @@ sub print_generic {
   $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
 
   push @buf,['','-----------'];
-  push @buf,[( $conf->exists('disable_previous_balance') 
+  push @buf,[$self->mt( 
+              $conf->exists('disable_previous_balance', $agentnum) 
                ? 'Total Charges'
                : 'Total New Charges'
              ),
@@ -3057,21 +3222,21 @@ sub print_generic {
          || 'Total New Charges'
       if $conf->exists('previous_balance-exclude_from_total');
     my $amount = $self->charged +
-                   ( $conf->exists('disable_previous_balance') ||
+                   ( $conf->exists('disable_previous_balance', $agentnum) ||
                      $conf->exists('previous_balance-exclude_from_total')
                      ? 0
                      : $pr_total
                    );
-    $total->{'total_item'} = &$embolden_function($item);
+    $total->{'total_item'} = &$embolden_function($self->mt($item));
     $total->{'total_amount'} =
       &$embolden_function( $other_money_char.  sprintf( '%.2f', $amount ) );
     if ( $multisection ) {
       if ( $adjust_section->{'sort_weight'} ) {
-        $adjust_section->{'posttotal'} = 'Balance Forward '. $other_money_char.
-          sprintf("%.2f", ($self->billing_balance || 0) );
+        $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
+          $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
       } else {
-        $adjust_section->{'pretotal'} = 'New charges total '. $other_money_char.
-                                        sprintf('%.2f', $self->charged );
+        $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
+          $other_money_char.  sprintf('%.2f', $self->charged );
       } 
     }else{
       push @total_items, $total;
@@ -3084,7 +3249,7 @@ sub print_generic {
     push @buf,['',''];
   }
   
-  unless ( $conf->exists('disable_previous_balance') ) {
+  unless ( $conf->exists('disable_previous_balance', $agentnum) ) {
     #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
   
     # credits
@@ -3154,6 +3319,7 @@ sub print_generic {
         unless $adjust_section->{sort_weight};
     }
 
+    # create Balance Due message
     { 
       my $total;
       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
@@ -3214,6 +3380,26 @@ sub print_generic {
       if $unsquelched;
   }
 
+  # make a discounts-available section, even without multisection
+  if ( $conf->exists('discount-show_available') 
+       and my @discounts_avail = $self->_items_discounts_avail ) {
+    my $discount_section = {
+      'description' => $self->mt('Discounts Available'),
+      'subtotal'    => '',
+      'no_subtotal' => 1,
+    };
+
+    push @sections, $discount_section;
+    push @detail_items, map { +{
+        'ref'         => '', #should this be something else?
+        'section'     => $discount_section,
+        'description' => &$escape_function( $_->{description} ),
+        'amount'      => $money_char . &$escape_function( $_->{amount} ),
+        'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
+    } } @discounts_avail;
+  }
+
+  # All sections and items are built; now fill in templates.
   my @includelist = ();
   push @includelist, 'summary' if $summarypage;
   foreach my $include ( @includelist ) {
@@ -3276,28 +3462,27 @@ sub print_generic {
     }
 
     #setup subroutine for the template
-    sub FS::cust_bill::_template::invoice_lines {
-      my $lines = shift || scalar(@FS::cust_bill::_template::buf);
+    $invoice_data{invoice_lines} = sub {
+      my $lines = shift || scalar(@buf);
       map { 
-        scalar(@FS::cust_bill::_template::buf)
-          ? shift @FS::cust_bill::_template::buf
+        scalar(@buf)
+          ? shift @buf
           : [ '', '' ];
       }
       ( 1 .. $lines );
-    }
+    };
 
     my $lines;
     my @collect;
     while (@buf) {
       push @collect, split("\n",
-        $text_template->fill_in( HASH => \%invoice_data,
-                                 PACKAGE => 'FS::cust_bill::_template'
-                               )
+        $text_template->fill_in( HASH => \%invoice_data )
       );
-      $FS::cust_bill::_template::page++;
+      $invoice_data{'page'}++;
     }
     map "$_\n", @collect;
   }else{
+    # this is where we actually create the invoice
     warn "filling in template for invoice ". $self->invnum. "\n"
       if $DEBUG;
     warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
@@ -3506,6 +3691,7 @@ sub _translate_old_latex_format {
 
 sub terms {
   my $self = shift;
+  my $conf = $self->conf;
 
   #check for an invoice-specific override
   return $self->invoice_terms if $self->invoice_terms;
@@ -3534,10 +3720,11 @@ sub due_date2str {
 
 sub balance_due_msg {
   my $self = shift;
-  my $msg = 'Balance Due';
+  my $msg = $self->mt('Balance Due');
   return $msg unless $self->terms;
   if ( $self->due_date ) {
-    $msg .= ' - Please pay by '. $self->due_date2str($date_format);
+    $msg .= ' - ' . $self->mt('Please pay by'). ' '.
+      $self->due_date2str($date_format);
   } elsif ( $self->terms ) {
     $msg .= ' - '. $self->terms;
   }
@@ -3546,6 +3733,7 @@ sub balance_due_msg {
 
 sub balance_due_date {
   my $self = shift;
+  my $conf = $self->conf;
   my $duedate = '';
   if (    $conf->exists('invoice_default_terms') 
        && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
@@ -3554,7 +3742,10 @@ sub balance_due_date {
   $duedate;
 }
 
-sub credit_balance_msg { 'Credit Balance Remaining' }
+sub credit_balance_msg { 
+  my $self = shift;
+  $self->mt('Credit Balance Remaining')
+}
 
 =item invnum_date_pretty
 
@@ -3565,7 +3756,7 @@ Returns a string with the invoice number and date, for example:
 
 sub invnum_date_pretty {
   my $self = shift;
-  'Invoice #'. $self->invnum. ' ('. $self->_date_pretty. ')';
+  $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')';
 }
 
 =item _date_pretty
@@ -3579,6 +3770,53 @@ sub _date_pretty {
   time2str($date_format, $self->_date);
 }
 
+=item _items_sections LATE SUMMARYPAGE ESCAPE EXTRA_SECTIONS FORMAT
+
+Generate section information for all items appearing on this invoice.
+This will only be called for multi-section invoices.
+
+For each line item (L<FS::cust_bill_pkg> record), this will fetch all 
+related display records (L<FS::cust_bill_pkg_display>) and organize 
+them into two groups ("early" and "late" according to whether they come 
+before or after the total), then into sections.  A subtotal is calculated 
+for each section.
+
+Section descriptions are returned in sort weight order.  Each consists 
+of a hash containing:
+
+description: the package category name, escaped
+subtotal: the total charges in that section
+tax_section: a flag indicating that the section contains only tax charges
+summarized: same as tax_section, for some reason
+sort_weight: the package category's sort weight
+
+If 'condense' is set on the display record, it also contains everything 
+returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
+coderefs to generate parts of the invoice.  This is not advised.
+
+Arguments:
+
+LATE: an arrayref to push the "late" section hashes onto.  The "early"
+group is simply returned from the method.
+
+SUMMARYPAGE: a flag indicating whether this is a summary-format invoice.
+Turning this on has the following effects:
+- Ignores display items with the 'summary' flag.
+- Combines all items into the "early" group.
+- Creates sections for all non-disabled package categories, even if they 
+have no charges on this invoice, as well as a section with no name.
+
+ESCAPE: an escape function to use for section titles.
+
+EXTRA_SECTIONS: an arrayref of additional sections to return after the 
+sorted list.  If there are any of these, section subtotals exclude 
+usage charges.
+
+FORMAT: 'latex', 'html', or 'template' (i.e. text).  Not used, but 
+passed through to C<_condense_section()>.
+
+=cut
+
 use vars qw(%pkg_category_cache);
 sub _items_sections {
   my $self = shift;
@@ -3697,7 +3935,7 @@ sub _items_sections {
                     }
                   } @sections;
   push @early, @$extra_sections if $extra_sections;
+
   sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
 
 }
@@ -3971,6 +4209,7 @@ sub _condensed_total_line_generator {
 
 sub _items_extra_usage_sections {
   my $self = shift;
+  my $conf = $self->conf;
   my $escape = shift;
   my $format = shift;
 
@@ -3978,6 +4217,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;
@@ -3997,8 +4238,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),
@@ -4062,7 +4303,12 @@ sub _items_extra_usage_sections {
 sub _did_summary {
     my $self = shift;
     my $end = $self->_date;
-    my $start = $end - 2592000; # 30 days
+
+    # 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)
@@ -4076,7 +4322,7 @@ sub _did_summary {
 
            my $inserted = $h_cust_svc->date_inserted;
            my $deleted = $h_cust_svc->date_deleted;
-           my $phone_inserted = $h_cust_svc->h_svc_x($inserted);
+           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;
            
@@ -4109,10 +4355,13 @@ sub _did_summary {
            }
 
            # increment usage minutes
-           my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end);
-           foreach my $cdr ( @cdrs ) {
-               $minutes += $cdr->billsec/60;
-           }
+        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;
@@ -4125,8 +4374,82 @@ sub _did_summary {
            "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->{'header'} ],
+                    detail_temp => [],
+            };
+
+            $section->{'amount'} += $amount;
+            $accountcodes{$accountcode}{'amount'} += $amount;
+            $accountcodes{$accountcode}{calls}++;
+            $accountcodes{$accountcode}{duration} += $detail->duration;
+            push @{$accountcodes{$accountcode}{detail_temp}}, $detail;
+        }
+    }
+
+    foreach my $l ( values %accountcodes ) {
+        $l->{amount} = sprintf( "%.2f", $l->{amount} );
+        my @sorted_detail = sort { $a->startdate <=> $b->startdate } @{$l->{detail_temp}};
+        foreach my $sorted_detail ( @sorted_detail ) {
+            push @{$l->{ext_description}}, $sorted_detail->formatted('format'=>$format);
+        }
+        delete $l->{detail_temp};
+        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 $conf = $self->conf;
   my $escape = shift;
   my $format = shift;
 
@@ -4134,6 +4457,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 };
 
@@ -4163,8 +4488,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),
@@ -4353,7 +4678,7 @@ sub _items_svc_phone_sections {
 
 }
 
-sub _items {
+sub _items { # seems to be unused
   my $self = shift;
 
   #my @display = scalar(@_)
@@ -4372,6 +4697,7 @@ sub _items {
 
 sub _items_previous {
   my $self = shift;
+  my $conf = $self->conf;
   my $cust_main = $self->cust_main;
   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
   my @b = ();
@@ -4380,7 +4706,7 @@ sub _items_previous {
                ? 'due '. $_->due_date2str($date_format)
                : time2str($date_format, $_->_date);
     push @b, {
-      'description' => 'Previous Balance, Invoice #'. $_->invnum. " ($date)",
+      'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)",
       #'pkgpart'     => 'N/A',
       'pkgnum'      => 'N/A',
       'amount'      => sprintf("%.2f", $_->owed),
@@ -4402,6 +4728,21 @@ sub _items_previous {
   #};
 }
 
+=item _items_pkg [ OPTIONS ]
+
+Return line item hashes for each package item on this invoice. Nearly 
+equivalent to 
+
+$self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
+
+The only OPTIONS accepted is 'section', which may point to a hashref 
+with a key named 'condensed', which may have a true value.  If it 
+does, this method tries to merge identical items into items with 
+'quantity' equal to the number of items (not the sum of their 
+separate quantities, for some reason).
+
+=cut
+
 sub _items_pkg {
   my $self = shift;
   my %options = @_;
@@ -4461,43 +4802,80 @@ sub _items_tax {
   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
 }
 
+=item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
+
+Takes an arrayref of L<FS::cust_bill_pkg> objects, and returns a
+list of hashrefs describing the line items they generate on the invoice.
+
+OPTIONS may include:
+
+format: the invoice format.
+
+escape_function: the function used to escape strings.
+
+DEPRECATED? (expensive, mostly unused?)
+format_function: the function used to format CDRs.
+
+section: a hashref containing 'description'; if this is present, 
+cust_bill_pkg_display records not belonging to this section are 
+ignored.
+
+multisection: a flag indicating that this is a multisection invoice,
+which does something complicated.
+
+multilocation: a flag to display the location label for the package.
+
+Returns a list of hashrefs, each of which may contain:
+
+pkgnum, description, amount, unit_amount, quantity, _is_setup, and 
+ext_description, which is an arrayref of detail lines to show below 
+the package line.
+
+=cut
+
 sub _items_cust_bill_pkg {
   my $self = shift;
+  my $conf = $self->conf;
   my $cust_bill_pkgs = shift;
   my %opt = @_;
 
   my $format = $opt{format} || '';
   my $escape_function = $opt{escape_function} || sub { shift };
   my $format_function = $opt{format_function} || '';
-  my $unsquelched = $opt{unsquelched} || '';
+  my $no_usage = $opt{no_usage} || '';
+  my $unsquelched = $opt{unsquelched} || ''; #unused
   my $section = $opt{section}->{description} if $opt{section};
-  my $summary_page = $opt{summary_page} || '';
+  my $summary_page = $opt{summary_page} || ''; #unused
   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_pkgs )
   {
 
-    warn "$me _items_cust_bill_pkg considering cust_bill_pkg $cust_bill_pkg\n"
-      if $DEBUG > 1;
-
-    $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
-                               && $conf->exists('discount-show-always'));
-
     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 && !$discount_show_always );
+          if $_->{amount} != 0
+          || $discount_show_always
+          || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+          || (   $_->{_is_setup} && $_->{setup_show_zero} )
+        ;
         $_ = 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
                                  : 1
@@ -4508,18 +4886,20 @@ sub _items_cust_bill_pkg {
                         )
     {
 
-      warn "$me _items_cust_bill_pkg considering display item $display\n"
+      warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
+           $display->billpkgdisplaynum. "\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,
                           'format_function' => $format_function,
+                          'no_usage'        => $opt{'no_usage'},
                         );
 
       if ( $cust_bill_pkg->pkgnum > 0 ) {
@@ -4529,13 +4909,25 @@ sub _items_cust_bill_pkg {
  
         my $cust_pkg = $cust_bill_pkg->cust_pkg;
 
-        if ( $cust_bill_pkg->setup != 0 && (!$type || $type eq 'S') ) {
+        # start/end dates for invoice formats that do nonstandard 
+        # things with them
+        my %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate');
+
+        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
@@ -4548,12 +4940,12 @@ sub _items_cust_bill_pkg {
 
             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);
             }
 
-          }
+          } #unless hiding service details
 
           push @d, $cust_bill_pkg->details(%details_opt)
             if $cust_bill_pkg->recur == 0;
@@ -4564,10 +4956,12 @@ 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,
               amount          => $cust_bill_pkg->setup,
+              setup_show_zero => $cust_bill_pkg->setup_show_zero,
               unit_amount     => $cust_bill_pkg->unitsetup,
               quantity        => $cust_bill_pkg->quantity,
               ext_description => \@d,
@@ -4576,9 +4970,13 @@ sub _items_cust_bill_pkg {
 
         }
 
-        if ( ( $cust_bill_pkg->recur != 0  || $cust_bill_pkg->setup == 0 || 
-               ($discount_show_always && $cust_bill_pkg->recur == 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
+                )
            )
         {
 
@@ -4589,12 +4987,23 @@ sub _items_cust_bill_pkg {
           my $description = ($is_summary && $type && $type eq 'U')
                             ? "Usage charges" : $desc;
 
-          $description .= " (" . time2str($date_format, $cust_bill_pkg->sdate).
-                          " - ". time2str($date_format, $cust_bill_pkg->edate).
-                          ")"
-            unless $conf->exists('disable_line_item_date_ranges');
+          unless (
+            $conf->exists('disable_line_item_date_ranges')
+              || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1)
+          ) {
+            my $time_period;
+            my $date_style = $conf->config('cust_bill-line_item-date_style');
+            if ( $date_style eq 'month_of' ) {
+              $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
+            } else {
+              $time_period =      time2str($date_format, $cust_bill_pkg->sdate).
+                           " - ". time2str($date_format, $cust_bill_pkg->edate);
+            }
+            $description .= " ($time_period)";
+          }
 
           my @d = ();
+          my @seconds = (); # for display of usage info
 
           #at least until cust_bill_pkg has "past" ranges in addition to
           #the "future" sdate/edate ones... see #3032
@@ -4623,11 +5032,32 @@ sub _items_cust_bill_pkg {
 
             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);
             }
 
+            # Display of seconds_since_sqlradacct:
+            # On the invoice, when processing @detail_items, look for a field
+            # named 'seconds'.  This will contain total seconds for each 
+            # service, in the same order as @ext_description.  For services 
+            # that don't support this it will show undef.
+            if ( $conf->exists('svc_acct-usage_seconds') 
+                 and ! $cust_bill_pkg->pkgpart_override ) {
+              foreach my $cust_svc ( 
+                  $cust_pkg->h_cust_svc(@dates, 'I') 
+                ) {
+
+                # eval because not having any part_export_usage exports 
+                # is a fatal error, last_bill/_date because that's how 
+                # sqlradius_hour billing does it
+                my $sec = eval {
+                  $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
+                };
+                push @seconds, $sec;
+              }
+            } #if svc_acct-usage_seconds
+
           }
 
           unless ( $is_summary ) {
@@ -4636,7 +5066,7 @@ sub _items_cust_bill_pkg {
 
             #instead of omitting details entirely in this case (unwanted side
             # effects), just omit CDRs
-            $details_opt{'format_function'} = sub { () }
+            $details_opt{'no_usage'} = 1
               if $type && $type eq 'R';
 
             push @d, $cust_bill_pkg->details(%details_opt);
@@ -4669,10 +5099,13 @@ sub _items_cust_bill_pkg {
                 #pkgpart         => $part_pkg->pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
                 amount          => $amount,
+                recur_show_zero => $cust_bill_pkg->recur_show_zero,
                 unit_amount     => $cust_bill_pkg->unitrecur,
                 quantity        => $cust_bill_pkg->quantity,
+                %item_dates,
                 ext_description => \@d,
               };
+              $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
             }
 
           } else {  # $type eq 'U'
@@ -4690,12 +5123,13 @@ sub _items_cust_bill_pkg {
                 #pkgpart         => $part_pkg->pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
                 amount          => $amount,
+                recur_show_zero => $cust_bill_pkg->recur_show_zero,
                 unit_amount     => $cust_bill_pkg->unitrecur,
                 quantity        => $cust_bill_pkg->quantity,
+                %item_dates,
                 ext_description => \@d,
               };
             }
-
           }
 
         } # recurring or usage with recurring charge
@@ -4724,10 +5158,10 @@ sub _items_cust_bill_pkg {
 
     }
 
-  }
+    $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
+                                && $conf->exists('discount-show-always'));
 
-  warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
-    if $DEBUG > 1;
+  }
 
   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
     if ( $_  ) {
@@ -4735,10 +5169,16 @@ sub _items_cust_bill_pkg {
       $_->{amount}      =~ s/^\-0\.00$/0.00/;
       $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
       push @b, { %$_ }
-        unless ( $_->{amount} == 0 && !$discount_show_always );
+        if $_->{amount} != 0
+        || $discount_show_always
+        || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+        || (   $_->{_is_setup} && $_->{setup_show_zero} )
     }
   }
 
+  warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
+    if $DEBUG > 1;
+
   @b;
 
 }
@@ -4761,7 +5201,7 @@ sub _items_credits {
       #'description' => 'Credit ref\#'. $_->crednum.
       #                 " (". time2str("%x",$_->cust_credit->_date) .")".
       #                 $reason,
-      'description' => 'Credit applied '.
+      'description' => $self->mt('Credit applied').' '.
                        time2str($date_format,$_->cust_credit->_date). $reason,
       'amount'      => sprintf("%.2f",$_->amount),
     };
@@ -4781,7 +5221,7 @@ sub _items_payments {
     #something more elaborate if $_->amount ne ->cust_pay->paid ?
 
     push @b, {
-      'description' => "Payment received ".
+      'description' => $self->mt('Payment received').' '.
                        time2str($date_format,$_->cust_pay->_date ),
       'amount'      => sprintf("%.2f", $_->amount )
     };
@@ -4791,6 +5231,48 @@ sub _items_payments {
 
 }
 
+=item _items_discounts_avail
+
+Returns an array of line item hashrefs representing available term discounts
+for this invoice.  This makes the same assumptions that apply to term 
+discounts in general: that the package is billed monthly, at a flat rate, 
+with no usage charges.  A prorated first month will be handled, as will 
+a setup fee if the discount is allowed to apply to setup fees.
+
+=cut
+
+sub _items_discounts_avail {
+  my $self = shift;
+  my $list_pkgnums = 0; # if any packages are not eligible for all discounts
+
+  my %plans = $self->discount_plans;
+
+  $list_pkgnums = grep { $_->list_pkgnums } values %plans;
+
+  map {
+    my $months = $_;
+    my $plan = $plans{$months};
+
+    my $term_total = sprintf('%.2f', $plan->discounted_total);
+    my $percent = sprintf('%.0f', 
+                          100 * (1 - $term_total / $plan->base_total) );
+    my $permonth = sprintf('%.2f', $term_total / $months);
+    my $detail = $self->mt('discount on item'). ' '.
+                 join(', ', map { "#$_" } $plan->pkgnums)
+      if $list_pkgnums;
+
+    +{
+      description => $self->mt('Save [_1]% by paying for [_2] months',
+                                $percent, $months),
+      amount      => $self->mt('[_1] ([_2] per month)', 
+                                $term_total, $money_char.$permonth),
+      ext_description => ($detail || ''),
+    }
+  } #map
+  sort { $b <=> $a } keys %plans;
+
+}
+
 =item call_details [ OPTION => VALUE ... ]
 
 Returns an array of CSV strings representing the call details for this invoice
@@ -5007,6 +5489,7 @@ Currently only supported on PostgreSQL.
 =cut
 
 sub due_date_sql {
+  my $conf = new FS::Conf;
 'COALESCE(
   SUBSTRING(
     COALESCE(
@@ -5075,6 +5558,11 @@ sub search_sql_where {
     push @search, "cust_main.agentnum = $1";
   }
 
+  #agentnum
+  if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
+    push @search, "cust_bill.custnum = $1";
+  }
+
   #_date
   if ( $param->{_date} ) {
     my($beginning, $ending) = @{$param->{_date}};
@@ -5146,6 +5634,15 @@ sub search_sql_where {
 
   }
 
+  #promised_date - also has an option to accept nulls
+  if ( $param->{promised_date} ) {
+    my($beginning, $ending, $null) = @{$param->{promised_date}};
+
+    push @search, "(( cust_bill.promised_date >= $beginning AND ".
+                    "cust_bill.promised_date <  $ending )" .
+                    ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')');
+  }
+
   #agent virtualization
   my $curuser = $FS::CurrentUser::CurrentUser;
   if ( $curuser->username eq 'fs_queue'