and turn it back off as an agent override, RT#79636
[freeside.git] / FS / FS / Template_Mixin.pm
index f55fc66..8bfc51c 100644 (file)
@@ -2,40 +2,84 @@ package FS::Template_Mixin;
 
 use strict;
 use vars qw( $DEBUG $me
-             $money_char $date_format $rdate_format $date_format_long );
+             $money_char
+             $date_format
+           );
              # but NOT $conf
 use vars qw( $invoice_lines @buf ); #yuck
-use List::Util qw(sum);
+use List::Util qw(sum); #can't import first, it conflicts with cust_main.first
 use Date::Format;
 use Date::Language;
+use Time::Local qw( timelocal );
 use Text::Template 1.20;
 use File::Temp 0.14;
+use Archive::Zip qw( :ERROR_CODES :CONSTANTS );
+use IO::Scalar;
 use HTML::Entities;
-use Locale::Country;
 use Cwd;
 use FS::UID;
-use FS::Record qw( qsearch qsearchs );
+use FS::Misc qw( send_email );
+use FS::Record qw( qsearch qsearchs dbh );
+use FS::Conf;
 use FS::Misc qw( generate_ps generate_pdf );
 use FS::pkg_category;
 use FS::pkg_class;
+use FS::invoice_mode;
 use FS::L10N;
 
 $DEBUG = 0;
 $me = '[FS::Template_Mixin]';
-FS::UID->install_callback( sub { 
+FS::UID->install_callback( sub {
   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
-  $date_format_long = $conf->config('date_format_long') || '%b %o, %Y';
+  $money_char  = $conf->config('money_char')  || '$';
+  $date_format = $conf->config('date_format') || '%x'; #/YY
 } );
 
-=item print_text HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
+=item conf [ MODE ]
+
+Returns a configuration handle (L<FS::Conf>) set to the customer's locale.
+
+If the "mode" pseudo-field is set on the object, the configuration handle
+will be an L<FS::invoice_conf> for that invoice mode (and the customer's
+locale).
+
+=cut
+
+sub conf {
+  my $self = shift;
+  my $mode = $self->get('mode');
+  if ($self->{_conf} and !defined($mode)) {
+    return $self->{_conf};
+  }
+
+  my $cust_main = $self->cust_main;
+  my $locale = $cust_main ? $cust_main->locale : '';
+  my $conf;
+  if ( $mode ) {
+    if ( ref $mode and $mode->isa('FS::invoice_mode') ) {
+      $mode = $mode->modenum;
+    } elsif ( $mode =~ /\D/ ) {
+      die "invalid invoice mode $mode";
+    }
+    $conf = qsearchs('invoice_conf', { modenum => $mode, locale => $locale });
+    if (!$conf) {
+      $conf = qsearchs('invoice_conf', { modenum => $mode, locale => '' });
+      # it doesn't have a locale, but system conf still might
+      $conf->set('locale' => $locale) if $conf;
+    }
+  }
+  # if $mode is unspecified, or if there is no invoice_conf matching this mode
+  # and locale, then use the system config only (but with the locale)
+  $conf ||= FS::Conf->new({ 'locale' => $locale });
+  # cache it
+  return $self->{_conf} = $conf;
+}
+
+=item print_text OPTIONS
 
 Returns an text invoice, as a list of lines.
 
-Options can be passed as a hashref (recommended) or as a list of time, template
-and then any key/value pairs for any other options.
+Options can be passed as a hash.
 
 I<time>, if specified, is used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
@@ -50,25 +94,19 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub print_text {
   my $self = shift;
-  my( $today, $template, %opt );
+  my %params;
   if ( ref($_[0]) ) {
-    %opt = %{ shift() };
-    $today = delete($opt{'time'}) || '';
-    $template = delete($opt{template}) || '';
+    %params = %{ shift() };
   } else {
-    ( $today, $template, %opt ) = @_;
+    %params = @_;
   }
 
-  my %params = ( 'format' => 'template' );
-  $params{'time'} = $today if $today;
-  $params{'template'} = $template if $template;
-  $params{$_} = $opt{$_} 
-    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
+  $params{'format'} = 'template'; # for some reason
 
   $self->print_generic( %params );
 }
 
-=item print_latex HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
+=item print_latex HASHREF
 
 Internal method - returns a filename of a filled-in LaTeX template for this
 invoice (Note: add ".tex" to get the actual filename), and a filename of
@@ -76,8 +114,7 @@ an associated logo (with the .eps extension included).
 
 See print_ps and print_pdf for methods that return PostScript and PDF output.
 
-Options can be passed as a hashref (recommended) or as a list of time, template
-and then any key/value pairs for any other options.
+Options can be passed as a hash.
 
 I<time>, if specified, is used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
@@ -85,6 +122,8 @@ It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
 I<template>, if specified, is the name of a suffix for alternate invoices.
+This is strongly deprecated; see L<FS::invoice_conf> for the right way to
+customize invoice templates for different purposes.
 
 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
 
@@ -92,25 +131,27 @@ 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 );
+  my %params;
+
   if ( ref($_[0]) ) {
-    %opt = %{ shift() };
-    $today = delete($opt{'time'}) || '';
-    $template = delete($opt{template}) || '';
+    %params = %{ shift() };
   } else {
-    ( $today, $template, %opt ) = @_;
+    %params = @_;
   }
 
-  my %params = ( 'format' => 'latex' );
-  $params{'time'} = $today if $today;
-  $params{'template'} = $template if $template;
-  $params{$_} = $opt{$_} 
-    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name no_date no_number );
+  $params{'format'} = 'latex';
+  my $conf = $self->conf;
 
+  # this needs to go away
+  my $template = $params{'template'};
+  # and this especially
   $template ||= $self->_agent_template
     if $self->can('_agent_template');
 
+  #the new way
+  $self->set('mode', $params{mode})
+    if $params{mode};
+
   my $pkey = $self->primary_key;
   my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX';
 
@@ -134,7 +175,7 @@ sub print_latex {
   close $lh;
   $params{'logo_file'} = $lh->filename;
 
-  if( $conf->exists('invoice-barcode') 
+  if( $conf->exists('invoice-barcode')
         && $self->can('invoice_barcode')
         && $self->invnum ) { # don't try to barcode statements
       my $png_file = $self->invoice_barcode($dir);
@@ -146,7 +187,7 @@ sub print_latex {
       $eps_file = $1;
 
       my $curr_dir = cwd();
-      chdir($dir); 
+      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)
@@ -159,7 +200,7 @@ sub print_latex {
   }
 
   my @filled_in = $self->print_generic( %params );
-  
+
   my $fh = new File::Temp( TEMPLATE => $tmp_template,
                            DIR      => $dir,
                            SUFFIX   => '.tex',
@@ -186,25 +227,82 @@ Internal method - returns a filled-in template for this invoice as a scalar.
 
 See print_ps and print_pdf for methods that return PostScript and PDF output.
 
-Non optional options include 
-  format - latex, html, template
+Required options
+
+=over 4
+
+=item format
+
+The B<format> option is required and should be set to html, latex (print and PDF) or template (plaintext).
+
+=back
+
+Additional options
+
+=over 4
+
+=item notice_name
 
-Optional options include
+Overrides "Invoice" as the name of the sent document.
 
-template - a value used as a suffix for a configuration template
+=item today
 
-time - a value used to control the printing of overdue messages.  The
+Used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
-cid - 
+=item logo_file
 
-unsquelch_cdr - overrides any per customer cdr squelching when true
+Logo file (path to temporary EPS file on the local filesystem)
 
-notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+=item cid
 
-locale - override customer's locale
+CID for inline (emailed) images (logo)
+
+=item locale
+
+Override customer's locale
+
+=item unsquelch_cdr
+
+Overrides any per customer cdr squelching when true
+
+=item no_number
+
+Supress the (invoice, quotation, statement, etc.) number
+
+=item no_date
+
+Supress the date
+
+=item no_coupon
+
+Supress the payment coupon
+
+=item barcode_file
+
+Barcode file (path to temporary EPS file on the local filesystem)
+
+=item barcode_img
+
+Flag indicating the barcode image should be a link (normal HTML dipaly)
+
+=item barcode_cid
+
+Barcode CID for inline (emailed) images
+
+=item preref_callback
+
+Coderef run for each line item, code should return HTML to be displayed
+before that line item (quotations only)
+
+=item template
+
+Deprecated.  Used as a suffix for a configuration template.  Please
+don't use this, it deprecated in favor of more flexible alternatives.
+
+=back
 
 =cut
 
@@ -214,6 +312,7 @@ locale - override customer's locale
 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;
@@ -223,9 +322,8 @@ sub print_generic {
     unless $format =~ /^(latex|html|template)$/;
 
   my $cust_main = $self->cust_main || $self->prospect_main;
-  $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
-    unless $cust_main->payname
-        && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
+
+  my $locale = $params{'locale'} || $cust_main->locale;
 
   my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
                      'html'     => [ '<%=', '%>' ],
@@ -235,22 +333,31 @@ sub print_generic {
   warn "$me print_generic creating template\n"
     if $DEBUG > 1;
 
+  # set the notice name here, and nowhere else.
+  my $notice_name =  $params{notice_name}
+                  || $conf->config('notice_name')
+                  || $self->notice_name;
+
   #create the template
   my $template = $params{template} ? $params{template} : $self->_agent_template;
   my $templatefile = $self->template_conf. $format;
   $templatefile .= "_$template"
     if length($template) && $conf->exists($templatefile."_$template");
+
+  $self->set('_template',$template);
+
+  # the base template
   my @invoice_template = map "$_\n", $conf->config($templatefile)
     or die "cannot load config data $templatefile";
 
-  my $old_latex = '';
   if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
     #change this to a die when the old code is removed
-    warn "old-style invoice template $templatefile; ".
+    # it's been almost ten years, changing it to a die
+    die "old-style invoice template $templatefile; ".
          "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
-    $old_latex = 'true';
-    @invoice_template = _translate_old_latex_format(@invoice_template);
-  } 
+         #$old_latex = 'true';
+         #@invoice_template = _translate_old_latex_format(@invoice_template);
+  }
 
   warn "$me print_generic creating T:T object\n"
     if $DEBUG > 1;
@@ -269,7 +376,7 @@ sub print_generic {
 
 
   # additional substitution could possibly cause breakage in existing templates
-  my %convert_maps = ( 
+  my %convert_maps = (
     'latex' => {
                  'notes'         => sub { map "$_", @_ },
                  'footer'        => sub { map "$_", @_ },
@@ -281,7 +388,7 @@ sub print_generic {
     'html'  => {
                  'notes' =>
                    sub {
-                     map { 
+                     map {
                        s/%%(.*)$/<!-- $1 -->/g;
                        s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
                        s/\\begin\{enumerate\}/<ol>/g;
@@ -301,7 +408,7 @@ sub print_generic {
                    sub { map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
                  'returnaddress' =>
                    sub {
-                     map { 
+                     map {
                        s/~/&nbsp;/g;
                        s/\\\\\*?\s*$/<BR>/;
                        s/\\hyphenation\{[\w\s\-]+}//;
@@ -315,7 +422,7 @@ sub print_generic {
     'template' => {
                  'notes' =>
                    sub {
-                     map { 
+                     map {
                        s/%%.*$//g;
                        s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
                        s/\\begin\{enumerate\}//g;
@@ -333,7 +440,7 @@ sub print_generic {
                    sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
                  'returnaddress' =>
                    sub {
-                     map { 
+                     map {
                        s/~/ /g;
                        s/\\\\\*?\s*$/\n/;             # dubious
                        s/\\hyphenation\{[\w\s\-]+}//;
@@ -361,14 +468,6 @@ sub print_generic {
   my $escape_function_nonbsp = ($format eq 'html')
                                  ? \&_html_escape : $escape_function;
 
-  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 %newline_tokens = (  'latex'     => '\\\\',
                           'html'      => '<br>',
                           'template'  => "\n",
@@ -380,6 +479,7 @@ sub print_generic {
 
   # generate template variables
   my $returnaddress;
+
   if (
          defined( $conf->config_orbase( "invoice_${format}returnaddress",
                                         $template
@@ -450,32 +550,40 @@ sub print_generic {
     'quotationnum'    => $self->quotationnum,
     'no_date'         => $params{'no_date'},
     '_date'           => ( $params{'no_date'} ? '' : $self->_date ),
+      # workaround for inconsistent behavior in the early plain text
+      # templates; see RT#28271
     'date'            => ( $params{'no_date'}
                              ? ''
-                             : time2str($date_format, $self->_date)
+                             : ($format eq 'template'
+                               ? $self->_date
+                               : $self->time2str_local('long', $self->_date, $format)
+                               )
                          ),
-    'today'           => time2str($date_format_long, $today),
+    'today'           => $self->time2str_local('long', $today, $format),
     'terms'           => $self->terms,
     'template'        => $template, #params{'template'},
-    'notice_name'     => ($params{'notice_name'} || $self->notice_name),#escape_function?
+    'notice_name'     => $notice_name, # escape?
     'current_charges' => sprintf("%.2f", $self->charged),
-    'duedate'         => $self->due_date2str($rdate_format), #date_format?
+    'duedate'         => $self->due_date2str('rdate'), #date_format?
+    'duedate_long'    => $self->due_date2str('long'),
 
     #customer info
     'custnum'         => $cust_main->display_custnum,
     'prospectnum'     => $cust_main->prospectnum,
     'agent_custid'    => &$escape_function($cust_main->agent_custid),
-    ( map { $_ => &$escape_function($cust_main->$_()) } qw(
-      payname company address1 address2 city state zip fax
-    )),
+    ( map { $_ => &$escape_function($cust_main->$_()) }
+        qw( company address1 address2 city state zip fax )
+    ),
+    'payname'         => &$escape_function( $cust_main->invoice_attn
+                                             || $cust_main->contact_firstlast ),
 
     #global config
-    'ship_enable'     => $conf->exists('invoice-ship_address'),
+    'ship_enable'     => $cust_main->invoice_ship_address || $conf->exists('invoice-ship_address'),
     'unitprices'      => $conf->exists('invoice-unitprice'),
     'smallernotes'    => $conf->exists('invoice-smallernotes'),
     'smallerfooter'   => $conf->exists('invoice-smallerfooter'),
     'balance_due_below_line' => $conf->exists('balance_due_below_line'),
-   
+
     #layout info -- would be fancy to calc some of this and bury the template
     #               here in the code
     'topmargin'             => scalar($conf->config('invoice_latextopmargin', $agentnum)),
@@ -497,17 +605,14 @@ sub print_generic {
     'total_pages'     => 1,
 
   );
+
+  #quotations have $name
+  $invoice_data{'name'} = $invoice_data{'payname'};
+
   #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
+  $invoice_data{'time2str'} = sub ($;$$) { $self->time2str_local(@_, $format) };
 
   my $min_sdate = 999999999999;
   my $max_edate = 0;
@@ -520,8 +625,10 @@ sub print_generic {
   }
 
   $invoice_data{'bill_period'} = '';
-  $invoice_data{'bill_period'} = time2str('%e %h', $min_sdate) 
-    . " to " . time2str('%e %h', $max_edate)
+  $invoice_data{'bill_period'} =
+      $self->time2str_local('%e %h', $min_sdate, $format)
+      . " to " .
+      $self->time2str_local('%e %h', $max_edate, $format)
     if ($max_edate != 0 && $min_sdate != 999999999999);
 
   $invoice_data{finance_section} = '';
@@ -529,37 +636,41 @@ sub print_generic {
     my $pkg_class =
       qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
     $invoice_data{finance_section} = $pkg_class->categoryname;
-  } 
+  }
   $invoice_data{finance_amount} = '0.00';
   $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
 
   my $countrydefault = $conf->config('countrydefault') || 'US';
   foreach ( qw( address1 address2 city state zip country fax) ){
     my $method = 'ship_'.$_;
-    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
+    $invoice_data{"ship_$_"} = $escape_function->($cust_main->$method);
   }
-  foreach ( qw( contact company ) ) { #compatibility
-    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$_);
+  if ( length($cust_main->ship_company) ) {
+    $invoice_data{'ship_company'} = $escape_function->($cust_main->ship_company);
+  } else {
+    $invoice_data{'ship_company'} = $escape_function->($cust_main->company);
   }
+  $invoice_data{'ship_contact'} = $escape_function->($cust_main->contact);
   $invoice_data{'ship_country'} = ''
     if ( $invoice_data{'ship_country'} eq $countrydefault );
-  
+
   $invoice_data{'cid'} = $params{'cid'}
     if $params{'cid'};
 
-  if ( $cust_main->country eq $countrydefault ) {
-    $invoice_data{'country'} = '';
+  if ( $cust_main->bill_locationnum
+       && $cust_main->bill_location->country ne $countrydefault ) {
+    $invoice_data{'country'} = &$escape_function($cust_main->bill_country_full);
   } else {
-    $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
+    $invoice_data{'country'} = '';
   }
 
   my @address = ();
   $invoice_data{'address'} = \@address;
   push @address,
-    $cust_main->payname.
-      ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
-        ? " (P.O. #". $cust_main->payinfo. ")"
-        : ''
+    $invoice_data{'payname'}.
+      ( $cust_main->po_number
+          ? " (P.O. #". $cust_main->po_number. ")"
+          : ''
       )
   ;
   push @address, $cust_main->company
@@ -583,80 +694,142 @@ sub print_generic {
   $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
-  #my $balance_due = $self->owed + $pr_total - $cr_total;
-  my $balance_due = $self->owed + $pr_total;
 
-  #these are used on the summary page only
+  # re: rt:78190
+  #   using owed_on_invoice() instead of owed() here for $balance_due
+  #   using _items_previous_total() instead of ->previous() for $pr_total
+  #
+  #   owed_on_invoice() is aware of configuration flags that affect how an
+  #     invoice is rendered.  May not return actual current balance. Will
+  #     return balance appropriate for the invoice being rendered, based
+  #     on which past due items, current charges, and future payments are
+  #     displayed.
+  #
+  #   Going forward, usage of owed(), or bypassing cust_bill helper methods
+  #     when generating invoice lines may lead to incorrect or misleading
+  #     math on invoices.
+  #
+  #   Helper methods that are aware of invoicing conf flags:
+  #   - owed_on_invoice          # use instead of owed()
+  #   - _items_previous()        # use instead of previous()
+  #   - _items_credits()         # use instead of cust_credit()
+  #   - _items_payments()
+  #   - _items_total()
+  #   - _items_previous_total()  # use instead of previous()
+  #   - _items_payments_total()
+  #   - _items_credits_total()   # use instead of cust_credit()
+
+  my $pr_total    = $self->_items_previous_total();
+
+  my $balance_due = $self->owed_on_invoice();
+  $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
 
-    # the customer's current balance as shown on the invoice before this one
-    $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) );
+  # flag telling this invoice to have a first-page summary
+  my $summarypage = '';
 
-    # the change in balance from that invoice to this one
-    $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) );
+  if ( $self->custnum && $self->invnum ) {
+    # XXX should be an FS::cust_bill method to set the defaults, instead
+    # of checking the type here
 
-    # the sum of amount owed on all previous invoices
-    # ($pr_total is used elsewhere but not as $previous_balance)
-    $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
+    # info from customer's last invoice before this one, for some
+    # summary formats
+    $invoice_data{'last_bill'} = {};
 
-  # the sum of amount owed on all invoices
-  # (this is used in the summary & on the payment coupon)
-  $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
+    #    my $last_bill = $self->previous_bill;
+    # if ( $last_bill ) {
 
-  # info from customer's last invoice before this one, for some 
-  # summary formats
-  $invoice_data{'last_bill'} = {};
+    # Populate template stash for previous balance and payments
+    if ($pr_total) {
+      # Used on summary page as "Previous Balance"
+      $invoice_data{'true_previous_balance'} = sprintf("%.2f", $pr_total);
 
-  if ( $self->custnum && $self->invnum ) {
+      # Used on summary page as "Payments"
+      $invoice_data{'balance_adjustments'} = sprintf("%.2f",
+        $self->_items_payments_total() + $self->_items_credits_total()
+      );
 
-    if ( $self->previous_bill ) {
+      # Used in invoice template as "Previous Balance"
+      $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
+
+      # $invoice_data{last_bill}{_date}:
+      # Not used in default templates, but may be in use by someone
+      #
+      # ! May be a problem field if they are using it... this field
+      #   stores the date of the previous invoice... it is possible to
+      #   carry a balance, but have the immediately previous invoice paid off.
+      #   In this case, this field might be presenting bad data?  Not
+      #   altering the problematic behavior, because someone might be
+      #   expecting this bad behavior in their templates for some other
+      #   purpose, such as a "your last bill was dated %_date%"
       my $last_bill = $self->previous_bill;
-      $invoice_data{'last_bill'} = {
-        '_date'     => $last_bill->_date, #unformatted
-      };
-      my (@payments, @credits);
-      # for formats that itemize previous payments
-      foreach my $cust_pay ( qsearch('cust_pay', {
-                              'custnum' => $self->custnum,
-                              '_date'   => { op => '>=',
-                                             value => $last_bill->_date }
-                             } ) )
-      {
-        next if $cust_pay->_date > $self->_date;
-        push @payments, {
-            '_date'       => $cust_pay->_date,
-            'date'        => time2str($date_format, $cust_pay->_date),
-            'payinfo'     => $cust_pay->payby_payinfo_pretty,
-            'amount'      => sprintf('%.2f', $cust_pay->paid),
-        };
-        # not concerned about applications
-      }
-      foreach my $cust_credit ( qsearch('cust_credit', {
-                              'custnum' => $self->custnum,
-                              '_date'   => { op => '>=',
-                                             value => $last_bill->_date }
-                             } ) )
-      {
-        next if $cust_credit->_date > $self->_date;
-        push @credits, {
-            '_date'       => $cust_credit->_date,
-            'date'        => time2str($date_format, $cust_credit->_date),
-            'creditreason'=> $cust_credit->reason,
-            'amount'      => sprintf('%.2f', $cust_credit->amount),
-        };
+      $invoice_data{'last_bill'}{'_date'}
+        = ref $last_bill
+        ? $last_bill->_date()
+        : undef;
+
+      # $invoice_data{previous_payments}
+      # Not used in default templates, but may be in use by someone
+      #
+      # Returns an array of hrefs representing payments, each with keys:
+      #  - _date:       epoch timestamp
+      #  - date:        text formatted date
+      #  - amount:      money formatted amount string
+      #  - payinfo:     string from payby_payinfo_pretty()
+      #  - paynum:      id for cust_pay
+      #  - description: Text description for bill line item
+      #
+      my @payments = $self->_items_payments();
+      $invoice_data{previous_payments} = \@payments;
+
+      # $invoice_data{previous_credits}
+      # Not used in default templates, but may be in use by someone
+      #
+      # Returns an array of hrefs representing credits, each with keys:
+      #  - _date:        epoch timestamp
+      #  - date:         text formatted date
+      #  - amount:       money formatted amount string
+      #  - crednum:      id for cust_credit
+      #  - description:  Text description for bill line item
+      #  - creditreason: reason() from cust_credit
+      #
+      my @credits = $self->_items_credits();
+      $invoice_data{previous_credits} = \@credits;
+
+      # Populate formatted date field
+      for my $pmt_href (@payments, @credits) {
+        $pmt_href->{date} = $self->time2str_local(
+          'long',
+          $pmt_href->{_date},
+          $format
+        );
       }
-      $invoice_data{'previous_payments'} = \@payments;
-      $invoice_data{'previous_credits'}  = \@credits;
+
+    } else {
+      # There are no outstanding invoices    = YAPH
+      $invoice_data{'true_previous_balance'} =
+      $invoice_data{'balance_adjustments'}   =
+      $invoice_data{'previous_balance'}      = '0.00';
+      $invoice_data{'previous_payments'}     =
+      $invoice_data{'previous_credits'}      = [];
     }
 
-  }
+    # Condencing a lot of debug staements here
+    if ($DEBUG) {
+      warn "\$invoice_data{$_}: $invoice_data{$_}"
+        for qw(
+          true_previous_balance
+          balance_adjustments
+          previous_balance
+          previous_payments
+          previous_credits
+        );
+    }
 
-  my $summarypage = '';
-  if ( $conf->exists('invoice_usesummary', $agentnum) ) {
-    $summarypage = 1;
-  }
-  $invoice_data{'summarypage'} = $summarypage;
+    if ( $conf->config_bool('invoice_usesummary', $agentnum) ) {
+      $invoice_data{'summarypage'} = $summarypage = 1;
+    }
+
+  } # if this is an invoice
 
   warn "$me substituting variables in notes, footer, smallfooter\n"
     if $DEBUG > 1;
@@ -665,35 +838,36 @@ sub print_generic {
   my @include = ( [ $tc,        'notes' ],
                   [ 'invoice_', 'footer' ],
                   [ 'invoice_', 'smallfooter', ],
+                  [ 'invoice_', 'watermark' ],
                 );
   push @include, [ $tc,        'coupon', ]
     unless $params{'no_coupon'};
 
   foreach my $i (@include) {
 
+    # load the configuration for this sub-template
+
     my($base, $include) = @$i;
 
     my $inc_file = $conf->key_orbase("$base$format$include", $template);
-    my @inc_src;
-
-    if ( $conf->exists($inc_file, $agentnum)
-         && length( $conf->config($inc_file, $agentnum) ) ) {
-
-      @inc_src = $conf->config($inc_file, $agentnum);
-
-    } else {
-
-      $inc_file = $conf->key_orbase("${base}latex$include", $template);
 
-      my $convert_map = $convert_maps{$format}{$include};
-
-      @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
-                       s/--\@\]/$delimiters{$format}[1]/g;
-                       $_;
-                     } 
-                 &$convert_map( $conf->config($inc_file, $agentnum) );
+    my @inc_src = $conf->config($inc_file, $agentnum);
+    if (!@inc_src) {
+      my $converter = $convert_maps{$format}{$include};
+      if ( $converter ) {
+        # then attempt to convert LaTeX to the requested format
+        $inc_file = $conf->key_orbase($base.'latex'.$include, $template);
+        @inc_src = &$converter( $conf->config($inc_file, $agentnum) );
+        foreach (@inc_src) {
+          # this isn't included in the convert_maps
+          my ($open, $close) = @{ $delimiters{$format} };
+          s/\[\@--/$open/g;
+          s/--\@\]/$close/g;
+        }
+      }
+    } # else @inc_src is empty and that's fine
 
-    }
+    # make a Text::Template out of it
 
     my $inc_tt = new Text::Template (
       TYPE       => 'ARRAY',
@@ -707,19 +881,23 @@ sub print_generic {
       die $error;
     }
 
+    # fill in variables
+
     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
 
     $invoice_data{$include} =~ s/\n+$//
       if ($format eq 'latex');
   }
 
-  # 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($self->mt("Purchase Order #").$cust_main->payinfo)
-      : $nbsp;
+# if (well, probably when) we still need PO numbers in the brave new world of
+# 4.x, then we'll have to add them back as their own customer fields
+#  # 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($self->mt("Purchase Order #").$cust_main->payinfo)
+#      : $nbsp;
 
   my %money_chars = ( 'latex'    => '',
                       'html'     => $conf->config('money_char') || '$',
@@ -727,6 +905,7 @@ sub print_generic {
                     );
   my $money_char = $money_chars{$format};
 
+  # extremely dubious
   my %other_money_chars = ( 'latex'    => '\dollar ',#XXX should be a config too
                             'html'     => $conf->config('money_char') || '$',
                             'template' => '',
@@ -752,31 +931,8 @@ sub print_generic {
   warn "$me generating sections\n"
     if $DEBUG > 1;
 
-  my $taxtotal = 0;
-  my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
-                      'subtotal'    => $taxtotal,   # adjusted below
-                    };
-  my $tax_weight = _pkg_category($tax_section->{description})
-                        ? _pkg_category($tax_section->{description})->weight
-                        : 0;
-  $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
-  $tax_section->{'sort_weight'} = $tax_weight;
-
-  my $adjusttotal = 0;
-  my $adjust_section = {
-    'description'    => $self->mt('Credits, Payments, and Adjustments'),
-    'adjust_section' => 1,
-    'subtotal'       => 0,   # adjusted below
-  };
-  my $adjust_weight = _pkg_category($adjust_section->{description})
-                        ? _pkg_category($adjust_section->{description})->weight
-                        : 0;
-  $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';
-  my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) ||
-                     $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
+  my $multisection = $self->has_sections;
   $invoice_data{'multisection'} = $multisection;
   my $late_sections;
   my $extra_sections = [];
@@ -784,36 +940,61 @@ sub print_generic {
 
   # default section ('Charges')
   my $default_section = { 'description' => '',
-                          'subtotal'    => '', 
+                          'subtotal'    => '',
                           'no_subtotal' => 1,
                         };
 
   # Previous Charges section
   # subtotal is the first return value from $self->previous
   my $previous_section;
-  # if the invoice has major sections, or if we're summarizing previous 
+  # if the invoice has major sections, or if we're summarizing previous
   # charges with a single line, or if we've been specifically told to put them
   # in a section, create a section for previous charges:
   if ( $multisection or
        $conf->exists('previous_balance-summary_only') or
        $conf->exists('previous_balance-section') ) {
-    
+
     $previous_section =  { 'description' => $self->mt('Previous Charges'),
                            'subtotal'    => $other_money_char.
                                             sprintf('%.2f', $pr_total),
                            'summarized'  => '', #why? $summarypage ? 'Y' : '',
                          };
-    $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. 
-      join(' / ', map { $cust_main->balance_date_range(@$_) }
-                  $self->_prior_month30s
-          )
-      if $conf->exists('invoice_include_aging');
+
+    # Include balance aging line and template variables
+    my @aged_balances = $self->_items_aging_balances();
+    ( $invoice_data{aged_balance_current},
+      $invoice_data{aged_balance_30d},
+      $invoice_data{aged_balance_60d},
+      $invoice_data{aged_balance_90d}
+    ) = @aged_balances;
+
+    if ($conf->exists('invoice_include_aging')) {
+      $previous_section->{posttotal} = sprintf(
+        '0 / 30 / 60 / 90 days overdue %.2f / %.2f / %.2f / %.2f',
+        @aged_balances,
+      );
+    }
 
   } else {
     # otherwise put them in the main section
     $previous_section = $default_section;
   }
 
+  my $adjust_section = {
+    'description'    => $self->mt('Credits, Payments, and Adjustments'),
+    'adjust_section' => 1,
+    'subtotal'       => 0,   # adjusted below
+  };
+  my $adjust_weight = _pkg_category($adjust_section->{description})
+                        ? _pkg_category($adjust_section->{description})->weight
+                        : 0;
+  $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
+  # Note: 'sort_weight' here is actually a flag telling whether there is an
+  # explicit package category for the adjust section. If so, certain behavior
+  # happens.
+  $adjust_section->{'sort_weight'} = $adjust_weight;
+
+
   if ( $multisection ) {
     ($extra_sections, $extra_lines) =
       $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
@@ -825,10 +1006,10 @@ sub print_generic {
     push @detail_items, @$extra_lines if $extra_lines;
 
     # the code is written so that both methods can be used together, but
-    # we haven't yet changed the template to take advantage of that, so for 
+    # we haven't yet changed the template to take advantage of that, so for
     # now, treat them as mutually exclusive.
     my %section_method = ( by_category => 1 );
-    if ( $conf->exists($tc.'sections_by_location') ) {
+    if ( $conf->config($tc.'sections_method') eq 'location' ) {
       %section_method = ( by_location => 1 );
     }
     my ($early, $late) =
@@ -871,7 +1052,7 @@ sub print_generic {
     my @finance_charges;
     my @charges;
     foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
-      if ( $invoice_data{finance_section} and 
+      if ( $invoice_data{finance_section} and
         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...
@@ -880,12 +1061,42 @@ sub print_generic {
         push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
       }
     }
-    $invoice_data{finance_amount} = 
+    $invoice_data{finance_amount} =
       sprintf('%.2f', sum( @finance_charges ) || 0);
     $default_section->{subtotal} = $other_money_char.
                                     sprintf('%.2f', sum( @charges ) || 0);
   }
 
+  # start setting up summary subtotals
+  my @summary_subtotals;
+  my $method = $conf->config('summary_subtotals_method');
+  if ( $method and $method ne $conf->config($tc.'sections_method') ) {
+    # then re-section them by the correct method
+    my %section_method = ( by_category => 1 );
+    if ( $conf->config('summary_subtotals_method') eq 'location' ) {
+      %section_method = ( by_location => 1 );
+    }
+    my ($early, $late) =
+      $self->_items_sections( 'summary' => $summarypage,
+                              'escape'  => $escape_function_nonbsp,
+                              'extra_sections' => $extra_sections,
+                              'format'  => $format,
+                              %section_method
+                            );
+    foreach ( @$early ) {
+      next if $_->{subtotal} == 0;
+      $_->{subtotal} = $other_money_char.sprintf('%.2f', $_->{subtotal});
+      push @summary_subtotals, $_;
+    }
+  } else {
+    # subtotal sectioning is the same as for the actual invoice sections
+    @summary_subtotals = @sections;
+  }
+
+  # Hereafter, push sections to both @sections and @summary_subtotals
+  # if they belong in both places (e.g. tax section).  Late sections are
+  # never in @summary_subtotals.
+
   # previous invoice balances in the Previous Charges section if there
   # is one, otherwise in the main detail section
   # (except if summary_only is enabled, don't show them at all)
@@ -901,14 +1112,13 @@ sub print_generic {
       my $detail = {
         ref             => $line_item->{'pkgnum'},
         pkgpart         => $line_item->{'pkgpart'},
-        quantity        => 1,
+        #quantity        => 1, # not really correct
         section         => $previous_section, # which might be $default_section
         description     => &$escape_function($line_item->{'description'}),
-        ext_description => [ map { &$escape_function($_) } 
+        ext_description => [ map { &$escape_function($_) }
                              @{ $line_item->{'ext_description'} || [] }
                            ],
-        amount          => ( $old_latex ? '' : $money_char).
-                            $line_item->{'amount'},
+        amount          => $money_char . $line_item->{'amount'},
         product_code    => $line_item->{'pkgpart'} || 'N/A',
       };
 
@@ -920,20 +1130,20 @@ sub print_generic {
 
   }
 
-  if ( @pr_cust_bill && $self->enable_previous ) {
+  if ( $pr_total && $self->enable_previous ) {
     push @buf, ['','-----------'];
     push @buf, [ $self->mt('Total Previous Balance'),
                  $money_char. sprintf("%10.2f", $pr_total) ];
     push @buf, ['',''];
   }
+
   if ( $conf->exists('svc_phone-did-summary') && $self->can('_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, 
+      push @detail_items,
        { 'description' => $didsummary_desc,
            'ext_description' => [ $didsummary, $minutes ],
        };
@@ -941,9 +1151,6 @@ sub print_generic {
 
   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
@@ -954,14 +1161,27 @@ sub print_generic {
       if ( $invoice_data{finance_section} &&
            $section->{'description'} eq $invoice_data{finance_section} );
 
-    $section->{'subtotal'} = $other_money_char.
-                             sprintf('%.2f', $section->{'subtotal'})
-      if $multisection;
+    if ( $multisection ) {
+
+      if ( ref($section->{'subtotal'}) ) {
+
+        $section->{'subtotal'} =
+          sprintf("$other_money_char%.2f to $other_money_char%.2f",
+                    $section->{'subtotal'}[0],
+                    $section->{'subtotal'}[1]
+                 );
+
+      } else {
+
+        $section->{'subtotal'} = $other_money_char.
+                                 sprintf('%.2f', $section->{'subtotal'})
+
+      }
 
-    # continue some normalization
-    $section->{'amount'}   = $section->{'subtotal'}
-      if $multisection;
+      # continue some normalization
+      $section->{'amount'}   = $section->{'subtotal'}
 
+    }
 
     if ( $section->{'description'} ) {
       push @buf, ( [ &$escape_function($section->{'description'}), '' ],
@@ -974,6 +1194,8 @@ sub print_generic {
 
     my %options = ();
     $options{'section'} = $section if $multisection;
+    $options{'section_with_taxes'} = 1
+      if $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum);
     $options{'format'} = $format;
     $options{'escape_function'} = $escape_function;
     $options{'no_usage'} = 1 unless $unsquelched;
@@ -981,57 +1203,97 @@ sub print_generic {
     $options{'summary_page'} = $summarypage;
     $options{'skip_usage'} =
       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
+    $options{'preref_callback'} = $params{'preref_callback'};
 
     warn "$me   searching for line items\n"
       if $DEBUG > 1;
 
-    foreach my $line_item ( $self->_items_pkg(%options) ) {
+    my %section_tax_lines;
+    my %seen_tax_lines;
+    foreach my $line_item ( $self->_items_pkg(%options),
+                            $self->_items_fee(%options) ) {
 
-      warn "$me     adding line item $line_item\n"
+      warn "$me     adding line item ".
+           join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
         if $DEBUG > 1;
 
-      my $detail = {
-        ext_description => [],
-      };
-      $detail->{'ref'} = $line_item->{'pkgnum'};
-      $detail->{'pkgpart'} = $line_item->{'pkgpart'};
-      $detail->{'quantity'} = $line_item->{'quantity'};
-      $detail->{'section'} = $section;
-      $detail->{'description'} = &$escape_function($line_item->{'description'});
-      if ( exists $line_item->{'ext_description'} ) {
-        @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
-      }
-      $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
-                              $line_item->{'amount'};
-      if ( exists $line_item->{'unit_amount'} ) {
-        $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'};
-      $detail->{'svc_label'} = $line_item->{'svc_label'};
-  
-      push @detail_items, $detail;
-      push @buf, ( [ $detail->{'description'},
+      push @buf, ( [ $line_item->{'description'},
                      $money_char. sprintf("%10.2f", $line_item->{'amount'}),
                    ],
-                   map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
+                   map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
                  );
+
+      $line_item->{'ref'} = $line_item->{'pkgnum'};
+      $line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()?
+      $line_item->{'section'} = $section;
+      $line_item->{'description'} = &$escape_function($line_item->{'description'});
+      $line_item->{'amount'} = $money_char.$line_item->{'amount'};
+
+      if ( length($line_item->{'unit_amount'}) ) {
+        $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
+      }
+      $line_item->{'ext_description'} ||= [];
+
+      if ( $options{section_with_taxes} && ref $line_item->{pkg_tax} ) {
+        for my $line_tax ( @{$ line_item->{pkg_tax} } ) {
+
+          # It is rarely possible for the same tax record to be presented here
+          # multiple times.  See cust_bill_pkg::_pkg_tax_list for more info
+          next if $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} };
+          $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} } = 1;
+
+          $section_tax_lines{ $line_tax->{taxname} } += $line_tax->{amount};
+        }
+      }
+
+      push @detail_items, $line_item;
+    }
+
+    # If conf flag invoice_sections_with_taxes:
+    # - Add @detail_items for taxes into each section
+    # - Update section subtotal to include taxes
+    if ( $options{section_with_taxes} && %section_tax_lines ) {
+      for my $taxname ( keys %section_tax_lines ) {
+
+        push @detail_items, {
+          section => $section,
+          amount  => sprintf($money_char."%.2f",$section_tax_lines{$taxname}),
+          description => &$escape_function($taxname),
+        };
+
+        # Append taxes to total.  If line format resembles "$5.00 to $12.00"
+        # append to the second value.
+
+        # $section->{subtotal} = '$5.00 to 12.00'; # for testing:
+        if ($section->{subtotal} =~ /to/) {
+          my @subtotal = split /\s/, $section->{subtotal};
+          $subtotal[2] =~ s/[^\d\.]//g;
+          $subtotal[2] = sprintf(
+            $money_char."%.2f",
+            ( $subtotal[2] + $section_tax_lines{$taxname} )
+          );
+          $section->{subtotal} = join ' ', @subtotal;
+        } else {
+          $section->{subtotal} =~ s/[^\d\.]//g;
+          $section->{subtotal} = sprintf(
+            $money_char . "%.2f",
+            ( $section->{subtotal} + $section_tax_lines{$taxname} )
+          );
+        }
+
+      }
     }
 
     if ( $section->{'description'} ) {
       push @buf, ( ['','-----------'],
                    [ $section->{'description'}. ' sub-total',
-                      $section->{'subtotal'} # already formatted this 
+                      $section->{'subtotal'} # already formatted this
                    ],
                    [ '', '' ],
                    [ '', '' ],
                  );
     }
-  
+
   }
 
   $invoice_data{current_less_finance} =
@@ -1040,12 +1302,34 @@ sub print_generic {
   # if there's anything in the Previous Charges section, prepend it to the list
   if ( $pr_total and $previous_section ne $default_section ) {
     unshift @sections, $previous_section;
+    # but not @summary_subtotals
   }
 
   warn "$me adding taxes\n"
     if $DEBUG > 1;
 
+  # create a tax section if we don't yet have one
   my @items_tax = $self->_items_tax;
+  my $tax_description = 'Taxes, Surcharges, and Fees';
+  my $tax_section =
+    List::Util::first { $_->{description} eq $tax_description } @sections;
+  if (!$tax_section) {
+    $tax_section = { 'description' => $tax_description };
+    push @sections, $tax_section if $multisection and @items_tax > 0;
+  }
+  $tax_section->{tax_section} = 1; # mark this section as containing taxes
+  # if this is an existing tax section, we're merging the tax items into it.
+  # grab the taxtotal that's already there, strip the money symbol if any
+  my $taxtotal = $tax_section->{'subtotal'} || 0;
+  $taxtotal =~ s/^\Q$other_money_char\E//;
+
+  # this does nothing
+  #my $tax_weight = _pkg_category($tax_section->{description})
+  #                      ? _pkg_category($tax_section->{description})->weight
+  #                      : 0;
+  #$tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
+  #$tax_section->{'sort_weight'} = $tax_weight;
+
   foreach my $tax ( @items_tax ) {
 
     $taxtotal += $tax->{'amount'};
@@ -1055,13 +1339,12 @@ sub print_generic {
 
     if ( $multisection ) {
 
-      my $money = $old_latex ? '' : $money_char;
       push @detail_items, {
         ext_description => [],
         ref          => '',
         quantity     => '',
         description  => $description,
-        amount       => $money. $amount,
+        amount       => $money_char. $amount,
         product_code => '',
         section      => $tax_section,
       };
@@ -1080,7 +1363,7 @@ sub print_generic {
               ];
 
   }
-  
+
   if ( @items_tax ) {
     my $total = {};
     $total->{'total_item'} = $self->mt('Sub-total');
@@ -1088,27 +1371,39 @@ sub print_generic {
       $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
 
     if ( $multisection ) {
-      $tax_section->{'subtotal'} = $other_money_char.
-                                   sprintf('%.2f', $taxtotal);
-      $tax_section->{'pretotal'} = 'New charges sub-total '.
-                                   $total->{'total_amount'};
-      push @sections, $tax_section if $taxtotal;
-    }else{
+      if ( $taxtotal > 0 ) {
+        # there are taxes, so prepare the section to be displayed.
+        # $taxtotal already includes any line items that were already in the
+        # section (fees, taxes that are charged as packages for some reason).
+        # also set 'summarized' to false so that this isn't a summary-only
+        # section.
+        $tax_section->{'subtotal'} = $other_money_char.
+                                     sprintf('%.2f', $taxtotal);
+        $tax_section->{'pretotal'} = 'New charges sub-total '.
+                                     $total->{'total_amount'};
+        $tax_section->{'description'} = $self->mt($tax_description);
+        $tax_section->{'summarized'} = '';
+
+        if ( $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum) ) {
+
+          # remove tax section if taxes are itemized within other sections
+          @sections = grep{ $_ ne $tax_section } @sections;
+
+        } elsif ( !grep $tax_section, @sections ) {
+
+          # append it if it's not already there
+          push @sections, $tax_section;
+          push @summary_subtotals, $tax_section;
+
+        }
+
+      }
+    } else {
       unshift @total_items, $total;
     }
   }
   $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
 
-  push @buf,['','-----------'];
-  push @buf,[$self->mt( 
-              (!$self->enable_previous)
-               ? 'Total Charges'
-               : 'Total New Charges'
-             ),
-             $money_char. sprintf("%10.2f",$self->charged) ];
-  push @buf,['',''];
-
-
   ###
   # Totals
   ###
@@ -1120,77 +1415,71 @@ sub print_generic {
   );
   my $embolden_function = $embolden_functions{$format};
 
-  if ( $self->can('_items_total') ) { # quotations
-
-    $self->_items_total(\@total_items);
+  if ( $multisection ) {
 
-    foreach ( @total_items ) {
-      $_->{'total_item'}   = &$embolden_function( $_->{'total_item'} );
-      $_->{'total_amount'} = &$embolden_function( $other_money_char.
-                                                   $_->{'total_amount'}
-                                                );
+    if ( $adjust_section->{'sort_weight'} ) {
+      $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
+        $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
+    } else{
+      $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
+        $other_money_char.  sprintf('%.2f', $self->charged );
     }
 
-  } else { #normal invoice case
+  }
 
-    # calculate total, possibly including total owed on previous
-    # invoices
-    my $total = {};
-    my $item = 'Total';
-    $item = $conf->config('previous_balance-exclude_from_total')
-         || 'Total New Charges'
-      if $conf->exists('previous_balance-exclude_from_total');
-    my $amount = $self->charged;
-    if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) {
-      $amount += $pr_total;
-    }
+  if ( $self->can('_items_total') ) { # should always be true now
 
-    $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'} = $self->mt('Balance Forward').' '.
-          $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
+    # even for multisection, need plain text version
+
+    my @new_total_items = $self->_items_total;
+
+    push @buf,['','-----------'];
+
+    foreach ( @new_total_items ) {
+      my ($item, $amount) = ($_->{'total_item'}, $_->{'total_amount'});
+      $_->{'total_item'}   = &$embolden_function( $item );
+
+      if ( ref($amount) ) {
+        $_->{'total_amount'} = &$embolden_function(
+                                 $other_money_char.$amount->[0]. ' to '.
+                                 $other_money_char.$amount->[1]
+                               );
       } else {
-        $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
-          $other_money_char.  sprintf('%.2f', $self->charged );
-      } 
-    } else {
-      push @total_items, $total;
+        $_->{'total_amount'} = &$embolden_function( $other_money_char.$amount );
+      }
+
+      # but if it's multisection, don't append to @total_items. the adjust
+      # section has all this stuff
+      push @total_items, $_ if !$multisection;
+      push @buf, [ $item, $money_char.sprintf('%10.2f',$amount) ];
     }
-    push @buf,['','-----------'];
-    push @buf,[$item,
-               $money_char.
-               sprintf( '%10.2f', $amount )
-              ];
-    push @buf,['',''];
+
+    push @buf, [ '', '' ];
 
     # if we're showing previous invoices, also show previous
-    # credits and payments 
-    if ( $self->enable_previous 
+    # credits and payments
+    if ( $self->enable_previous
           and $self->can('_items_credits')
           and $self->can('_items_payments') )
       {
-      #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
-    
+
       # credits
       my $credittotal = 0;
-      foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
+      foreach my $credit (
+        $self->_items_credits( 'template' => $template, 'trim_len' => 40 )
+      ) {
 
         my $total;
         $total->{'total_item'} = &$escape_function($credit->{'description'});
         $credittotal += $credit->{'amount'};
         $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
-        $adjusttotal += $credit->{'amount'};
         if ( $multisection ) {
-          my $money = $old_latex ? '' : $money_char;
           push @detail_items, {
             ext_description => [],
             ref          => '',
             quantity     => '',
             description  => &$escape_function($credit->{'description'}),
-            amount       => $money. $credit->{'amount'},
+            amount       => $money_char . $credit->{'amount'},
             product_code => '',
             section      => $adjust_section,
           };
@@ -1202,26 +1491,28 @@ sub print_generic {
       $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
 
       #credits (again)
-      foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
+      foreach my $credit (
+        $self->_items_credits( 'template' => $template, 'trim_len'=>32 )
+      ) {
         push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
       }
 
       # payments
       my $paymenttotal = 0;
-      foreach my $payment ( $self->_items_payments ) {
+      foreach my $payment (
+        $self->_items_payments( 'template' => $template )
+      ) {
         my $total = {};
         $total->{'total_item'} = &$escape_function($payment->{'description'});
         $paymenttotal += $payment->{'amount'};
         $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
-        $adjusttotal += $payment->{'amount'};
         if ( $multisection ) {
-          my $money = $old_latex ? '' : $money_char;
           push @detail_items, {
             ext_description => [],
             ref          => '',
             quantity     => '',
             description  => &$escape_function($payment->{'description'}),
-            amount       => $money. $payment->{'amount'},
+            amount       => $money_char . $payment->{'amount'},
             product_code => '',
             section      => $adjust_section,
           };
@@ -1233,35 +1524,40 @@ sub print_generic {
                    ];
       }
       $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
-    
+
       if ( $multisection ) {
         $adjust_section->{'subtotal'} = $other_money_char.
-                                        sprintf('%.2f', $adjusttotal);
+                                        sprintf('%.2f', $credittotal + $paymenttotal);
+
+        #why this? because {sort_weight} forces the adjust_section to appear
+        #in @extra_sections instead of @sections. obviously.
         push @sections, $adjust_section
           unless $adjust_section->{sort_weight};
+        # do not summarize; adjustments there are shown according to
+        # different rules
       }
 
       # create Balance Due message
-      { 
+      {
         my $total;
         $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
         $total->{'total_amount'} =
           &$embolden_function(
-            $other_money_char. sprintf('%.2f', #why? $summarypage 
+            $other_money_char. sprintf('%.2f', #why? $summarypage
                                                #  ? $self->charged +
                                                #    $self->billing_balance
                                                #  :
-                                                   $self->owed + $pr_total
+                                                   $balance_due
                                       )
           );
         if ( $multisection && !$adjust_section->{sort_weight} ) {
           $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
                                            $total->{'total_amount'};
-        }else{
+        } else {
           push @total_items, $total;
         }
         push @buf,['','-----------'];
-        push @buf,[$self->balance_due_msg, $money_char. 
+        push @buf,[$self->balance_due_msg, $money_char.
           sprintf("%10.2f", $balance_due ) ];
       }
 
@@ -1281,7 +1577,7 @@ sub print_generic {
           push @total_items, $credit_total;
         }
         push @buf,['','-----------'];
-        push @buf,[$self->credit_balance_msg, $money_char. 
+        push @buf,[$self->credit_balance_msg, $money_char.
           sprintf("%10.2f", -$cust_main->balance ) ];
       }
     }
@@ -1297,7 +1593,7 @@ sub print_generic {
       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
       $total->{'total_amount'} =
         &$embolden_function(
-          $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
+          $other_money_char. sprintf('%.2f', $balance_due)
         );
       my $last_section = pop @sections;
       $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
@@ -1309,7 +1605,7 @@ sub print_generic {
   }
 
   # make a discounts-available section, even without multisection
-  if ( $conf->exists('discount-show_available') 
+  if ( $conf->exists('discount-show_available')
        and my @discounts_avail = $self->_items_discounts_avail ) {
     my $discount_section = {
       'description' => $self->mt('Discounts Available'),
@@ -1317,7 +1613,7 @@ sub print_generic {
       'no_subtotal' => 1,
     };
 
-    push @sections, $discount_section;
+    push @sections, $discount_section; # do not summarize
     push @detail_items, map { +{
         'ref'         => '', #should this be something else?
         'section'     => $discount_section,
@@ -1327,26 +1623,52 @@ sub print_generic {
     } } @discounts_avail;
   }
 
-  my @summary_subtotals;
-  # the templates say "$_->{tax_section} || !$_->{summarized}"
-  # except 'summarized' is only true when tax_section is true, so this 
-  # is always true, so what's the deal?
-  foreach my $s (@sections) {
-    # not to include in the "summary of new charges" block:
-    # finance charges, adjustments, previous charges, 
-    # and itemized phone usage sections
-    if ( $s eq $adjust_section   or
-         ($s eq $previous_section and $s ne $default_section) or
-         ($invoice_data{'finance_section'} and 
-          $invoice_data{'finance_section'} eq $s->{description}) or
-         $s->{'description'} =~ /^\d+ $/ ) {
-      next;
+  # not adding any more sections after this
+  $invoice_data{summary_subtotals} = \@summary_subtotals;
+
+  # usage subtotals
+  if ( $conf->exists('usage_class_summary')
+       and $self->can('_items_usage_class_summary') ) {
+    my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function, 'money_char' => $other_money_char);
+    if ( @usage_subtotals ) {
+      unshift @sections, $usage_subtotals[0]->{section}; # do not summarize
+      unshift @detail_items, @usage_subtotals;
     }
-    push @summary_subtotals, $s;
   }
-  $invoice_data{summary_subtotals} = \@summary_subtotals;
 
-  # debugging hook: call this with 'diag' => 1 to just get a hash of 
+  # invoice history "section" (not really a section)
+  # not to be included in any subtotals, completely independent of
+  # everything...
+  if ( $conf->exists('previous_invoice_history') and $cust_main->isa('FS::cust_main') ) {
+    my %history;
+    my %monthorder;
+    foreach my $cust_bill ( $cust_main->cust_bill ) {
+      # XXX hardcoded format, and currently only 'charged'; add other fields
+      # if they become necessary
+      my $date = $self->time2str_local('%b %Y', $cust_bill->_date);
+      $history{$date} ||= 0;
+      $history{$date} += $cust_bill->charged;
+      # just so we have a numeric sort key
+      $monthorder{$date} ||= $cust_bill->_date;
+    }
+    my @sorted_months = sort { $monthorder{$a} <=> $monthorder{$b} }
+                        keys %history;
+    my @sorted_amounts = map { sprintf('%.2f', $history{$_}) } @sorted_months;
+    $invoice_data{monthly_history} = [ \@sorted_months, \@sorted_amounts ];
+  }
+
+  # service locations: another option for template customization
+  my %location_info;
+  foreach my $item (@detail_items) {
+    if ( $item->{locationnum} ) {
+      $location_info{ $item->{locationnum} } ||= {
+        FS::cust_location->by_key( $item->{locationnum} )->location_hash
+      };
+    }
+  }
+  $invoice_data{location_info} = \%location_info;
+
+  # debugging hook: call this with 'diag' => 1 to just get a hash of
   # the invoice variables
   return \%invoice_data if ( $params{'diag'} );
 
@@ -1371,7 +1693,7 @@ sub print_generic {
       @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
                        s/--\@\]/$delimiters{$format}[1]/g;
                        $_;
-                     } 
+                     }
                  &$convert_map( $conf->config($inc_file, $agentnum) );
 
     }
@@ -1404,24 +1726,24 @@ sub print_generic {
   die "no invoice_lines() functions in template?"
     if ( $format eq 'template' && !$wasfunc );
 
-  if ($format eq 'template') {
+  if ( $invoice_lines ) {
+    $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
+    $invoice_data{'total_pages'}++
+      if scalar(@buf) % $invoice_lines;
+  }
 
-    if ( $invoice_lines ) {
-      $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
-      $invoice_data{'total_pages'}++
-        if scalar(@buf) % $invoice_lines;
+  #setup subroutine for the template
+  $invoice_data{invoice_lines} = sub {
+    my $lines = shift || scalar(@buf);
+    map {
+      scalar(@buf)
+        ? shift @buf
+        : [ '', '' ];
     }
+    ( 1 .. $lines );
+  };
 
-    #setup subroutine for the template
-    $invoice_data{invoice_lines} = sub {
-      my $lines = shift || scalar(@buf);
-      map { 
-        scalar(@buf)
-          ? shift @buf
-          : [ '', '' ];
-      }
-      ( 1 .. $lines );
-    };
+  if ($format eq 'template') {
 
     my $lines;
     my @collect;
@@ -1435,6 +1757,13 @@ sub print_generic {
 
   } else { # this is where we actually create the invoice
 
+    if ( $params{no_addresses} ) {
+      delete $invoice_data{$_} foreach qw(
+        payname company address1 address2 city state zip country
+      );
+      $invoice_data{returnaddress} = '~';
+    }
+
     warn "filling in template for invoice ". $self->invnum. "\n"
       if $DEBUG;
     warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
@@ -1446,22 +1775,9 @@ sub print_generic {
 
 sub notice_name { '('.shift->table.')'; }
 
-sub template_conf { 'invoice_'; }
-
-# helper routine for generating date ranges
-sub _prior_month30s {
-  my $self = shift;
-  my @ranges = (
-   [ 1,       2592000 ], # 0-30 days ago
-   [ 2592000, 5184000 ], # 30-60 days ago
-   [ 5184000, 7776000 ], # 60-90 days ago
-   [ 7776000, 0       ], # 90+   days ago
-  );
-
-  map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
-          $_->[1] ? $self->_date - $_->[1] - 1 : '',
-      ] }
-  @ranges;
+# this is not supposed to happen
+sub template_conf { warn "bare FS::Template_Mixin::template_conf";
+  'invoice_';
 }
 
 =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
@@ -1542,26 +1858,23 @@ sub print_html {
   my $self = shift;
   my %params;
   if ( ref($_[0]) ) {
-    %params = %{ shift() }; 
-  }else{
-    $params{'time'} = shift;
-    $params{'template'} = shift;
-    $params{'cid'} = shift;
+    %params = %{ shift() };
+  } else {
+    %params = @_;
   }
-
   $params{'format'} = 'html';
-  
+
   $self->print_generic( %params );
 }
 
 # quick subroutine for print_latex
 #
 # There are ten characters that LaTeX treats as special characters, which
-# means that they do not simply typeset themselves: 
+# means that they do not simply typeset themselves:
 #      # $ % & ~ _ ^ \ { }
 #
 # TeX ignores blanks following an escaped character; if you want a blank (as
-# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
+# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ...").
 
 sub _latex_escape {
   my $value = shift;
@@ -1586,18 +1899,18 @@ sub _html_escape_nbsp {
 
 sub _translate_old_latex_format {
   warn "_translate_old_latex_format called\n"
-    if $DEBUG; 
+    if $DEBUG;
 
   my @template = ();
   while ( @_ ) {
     my $line = shift;
-  
+
     if ( $line =~ /^%%Detail\s*$/ ) {
-  
+
       push @template, q![@--!,
                       q!  foreach my $_tr_line (@detail_items) {!,
                       q!    if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
-                      q!      $_tr_line->{'description'} .= !, 
+                      q!      $_tr_line->{'description'} .= !,
                       q!        "\\tabularnewline\n~~".!,
                       q!        join( "\\tabularnewline\n~~",!,
                       q!          @{$_tr_line->{'ext_description'}}!,
@@ -1633,9 +1946,9 @@ sub _translate_old_latex_format {
 
     } else {
       $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
-      push @template, $line;  
+      push @template, $line;
     }
-  
+
   }
 
   if ($DEBUG) {
@@ -1645,87 +1958,625 @@ sub _translate_old_latex_format {
   (@template);
 }
 
+=item terms
+
+=cut
+
 sub terms {
   my $self = shift;
   my $conf = $self->conf;
 
   #check for an invoice-specific override
   return $self->invoice_terms if $self->invoice_terms;
-  
+
   #check for a customer- specific override
   my $cust_main = $self->cust_main;
   return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
 
+  my $agentnum = '';
+  if ( $cust_main ) {
+    $agentnum = $cust_main->agentnum;
+  } elsif ( my $prospect_main = $self->prospect_main ) {
+    $agentnum = $prospect_main->agentnum;
+  }
+
   #use configured default
-  $conf->config('invoice_default_terms') || '';
+  $conf->config('invoice_default_terms', $agentnum) || '';
 }
 
+=item due_date
+
+=cut
+
 sub due_date {
   my $self = shift;
   my $duedate = '';
   if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
     $duedate = $self->_date() + ( $1 * 86400 );
+  } elsif ( $self->terms =~ /^End of Month$/ ) {
+    my ($mon,$year) = (localtime($self->_date) )[4,5];
+    $mon++;
+    until ( $mon < 12 ) { $mon -= 12; $year++; }
+    my $nextmonth_first = timelocal(0,0,0,1,$mon,$year);
+    $duedate = $nextmonth_first - 86400;
   }
   $duedate;
 }
 
+=item due_date2str
+
+=cut
+
 sub due_date2str {
   my $self = shift;
-  $self->due_date ? time2str(shift, $self->due_date) : '';
+  $self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
+}
+
+=item invoice_pay_by_msg
+
+  displays the invoice_pay_by_msg or default Please pay by [_1] if empty.
+
+=cut
+
+sub invoice_pay_by_msg {
+  my $self = shift;
+  my $msg = '';
+  my $please_pay_by =
+        $self->conf->config('invoice_pay_by_msg', $self->agentnum)
+        || 'Please pay by [_1]';
+  $msg .= ' - ' . $self->mt($please_pay_by, $self->due_date2str('short')) . ' ';
+
+  $msg;
 }
 
+=item balance_due_msg
+
+=cut
+
 sub balance_due_msg {
   my $self = shift;
   my $msg = $self->mt('Balance Due');
-  return $msg unless $self->terms;
-  if ( $self->due_date ) {
-    $msg .= ' - ' . $self->mt('Please pay by'). ' '.
-      $self->due_date2str($date_format);
-  } elsif ( $self->terms ) {
-    $msg .= ' - '. $self->terms;
+  return $msg unless $self->terms; # huh?
+  if ( !$self->conf->exists('invoice_show_prior_due_date')
+       || $self->has_sections ) {
+    # if enabled, the due date is shown with Total New Charges (see
+    # _items_total) and not here
+    # (yes, or if invoice_sections is enabled; this is just for compatibility)
+    if ( $self->due_date ) {
+      $msg .= $self->invoice_pay_by_msg
+       unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum);
+    } elsif ( $self->terms ) {
+      $msg .= ' - '. $self->mt($self->terms);
+    }
   }
   $msg;
 }
 
+=item balance_due_date
+
+=cut
+
 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*$/ ) {
-    $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
+  my $terms = $self->terms;
+  if ( $terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
+    $duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) );
   }
   $duedate;
 }
 
-sub credit_balance_msg { 
+sub credit_balance_msg {
   my $self = shift;
   $self->mt('Credit Balance Remaining')
 }
 
 =item _date_pretty
 
-Returns a string with the date, for example: "3/20/2008"
+Returns a string with the date, for example: "3/20/2008", localized for the
+customer.  Use _date_pretty_unlocalized for non-end-customer display use.
 
 =cut
 
 sub _date_pretty {
   my $self = shift;
+  $self->time2str_local('short', $self->_date);
+}
+
+=item _date_pretty_unlocalized
+
+Returns a string with the date, for example: "3/20/2008", in the format
+configured for the back-office.  Use _date_pretty for end-customer display use.
+
+=cut
+
+sub _date_pretty_unlocalized {
+  my $self = shift;
   time2str($date_format, $self->_date);
 }
 
+=item email HASHREF
+
+Emails this template.
+
+Options are passed as a hashref.  Available options:
+
+=over 4
+
+=item from
+
+If specified, overrides the default From: address.
+
+=item notice_name
+
+If specified, overrides the name of the sent document ("Invoice" or "Quotation")
+
+=item template
+
+(Deprecated) If specified, is the name of a suffix for alternate template files.
+
+=back
+
+Options accepted by generate_email can also be used.
+
+=cut
+
+sub email {
+  my $self = shift;
+  my $opt = shift || {};
+  if ($opt and !ref($opt)) {
+    die ref($self). '->email called with positional parameters';
+  }
+
+  return if $self->hide;
+
+  my $error = send_email(
+    $self->generate_email(
+      'subject'     => $self->email_subject($opt->{template}),
+      %$opt, # template, etc.
+    )
+  );
+
+  die "can't email: $error\n" if $error;
+}
+
+=item generate_email OPTION => VALUE ...
+
+Options:
+
+=over 4
+
+=item from
+
+sender address, required
+
+=item template
+
+alternate template name, optional
+
+=item subject
+
+email subject, optional
+
+=item notice_name
+
+notice name instead of "Invoice", optional
+
+=back
+
+Returns an argument list to be passed to L<FS::Misc::send_email>.
+
+=cut
+
+use MIME::Entity;
+use Encode;
+
+sub generate_email {
+
+  my $self = shift;
+  my %args = @_;
+  my $conf = $self->conf;
+
+  my $me = '[FS::Template_Mixin::generate_email]';
+
+  my %return = (
+    'from'      => $args{'from'},
+    'subject'   => ($args{'subject'} || $self->email_subject),
+    'custnum'   => $self->custnum,
+    'msgtype'   => 'invoice',
+  );
+
+  $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email');
+
+  my $cust_main = $self->cust_main;
+
+  if (ref($args{'to'}) eq 'ARRAY') {
+    $return{'to'} = $args{'to'};
+  } elsif ( $cust_main ) {
+    $return{'to'} = [ $cust_main->invoicing_list_emailonly ];
+  }
+
+  my $tc = $self->template_conf;
+
+  my @text; # array of lines
+  my $html; # a big string
+  my @related_parts; # will contain the text/HTML alternative, and images
+  my $related; # will contain the multipart/related object
+
+  if ( $conf->exists($tc. 'email_pdf') ) {
+    if ( my $msgnum = $conf->config($tc.'email_pdf_msgnum') ) {
+
+      warn "$me using '${tc}email_pdf_msgnum' in multipart message"
+        if $DEBUG;
+
+      my $msg_template = FS::msg_template->by_key($msgnum)
+        or die "${tc}email_pdf_msgnum $msgnum not found\n";
+      my $cust_msg = $msg_template->prepare(
+        cust_main => $self->cust_main,
+        object    => $self,
+        msgtype   => 'invoice',
+      );
+
+      # XXX hack to make this work in the new cust_msg era; consider replacing
+      # with cust_bill_send_with_notice events.
+      my @parts = $cust_msg->parts;
+      foreach my $part (@parts) { # will only have two parts, normally
+        if ( $part->mime_type eq 'text/plain' ) {
+          @text = @{ $part->body };
+        } elsif ( $part->mime_type eq 'text/html' ) {
+          $html = $part->bodyhandle->as_string;
+        }
+      }
+
+    } elsif ( my @note = $conf->config($tc.'email_pdf_note') ) {
+
+      warn "$me using '${tc}email_pdf_note' in multipart message"
+        if $DEBUG;
+      @text = $conf->config($tc.'email_pdf_note');
+      $html = join('<BR>', @text);
+
+    } # else use the plain text invoice
+  }
+
+  if (!@text) {
+
+    if ( $conf->config($tc.'template') ) {
+
+      warn "$me generating plain text invoice"
+        if $DEBUG;
+
+      # 'print_text' argument is no longer used
+      @text = map Encode::encode_utf8($_), $self->print_text(\%args);
+
+    } else {
+
+      warn "$me no plain text version exists; sending empty message body"
+        if $DEBUG;
+
+    }
+
+  }
+
+  my $text_part = build MIME::Entity (
+    'Type'        => 'text/plain',
+    'Encoding'    => 'quoted-printable',
+    'Charset'     => 'UTF-8',
+    #'Encoding'    => '7bit',
+    'Data'        => \@text,
+    'Disposition' => 'inline',
+  );
+
+  if (!$html) {
+
+    if ( $conf->exists($tc.'html') ) {
+      warn "$me generating HTML invoice"
+        if $DEBUG;
+
+      $args{'from'} =~ /\@([\w\.\-]+)/;
+      my $from = $1 || 'example.com';
+      my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+
+      my $logo;
+      my $agentnum = $cust_main ? $cust_main->agentnum
+                                : $self->prospect_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);
+
+      push @related_parts, build MIME::Entity
+        'Type'       => 'image/png',
+        'Encoding'   => 'base64',
+        'Data'       => $image_data,
+        'Filename'   => 'logo.png',
+        'Content-ID' => "<$content_id>",
+      ;
+
+      if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) {
+        my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+        push @related_parts, build MIME::Entity
+          'Type'       => 'image/png',
+          'Encoding'   => 'base64',
+          'Data'       => $self->invoice_barcode(0),
+          'Filename'   => 'barcode.png',
+          'Content-ID' => "<$barcode_content_id>",
+        ;
+        $args{'barcode_cid'} = $barcode_content_id;
+      }
+
+      $html = $self->print_html({ 'cid'=>$content_id, %args });
+    }
+
+  }
+
+  if ( $html ) {
+
+    warn "$me creating HTML/text multipart message"
+      if $DEBUG;
+
+    $return{'nobody'} = 1;
+
+    my $alternative = build MIME::Entity
+      'Type'        => 'multipart/alternative',
+      #'Encoding'    => '7bit',
+      'Disposition' => 'inline'
+    ;
+
+    if ( @text ) {
+      $alternative->add_part($text_part);
+    }
+
+    $alternative->attach(
+      'Type'        => 'text/html',
+      'Encoding'    => 'quoted-printable',
+      'Data'        => [ '<html>',
+                         '  <head>',
+                         '    <title>',
+                         '      '. encode_entities($return{'subject'}),
+                         '    </title>',
+                         '  </head>',
+                         '  <body bgcolor="#e8e8e8">',
+                         Encode::encode_utf8($html),
+                         '  </body>',
+                         '</html>',
+                       ],
+      'Disposition' => 'inline',
+      #'Filename'    => 'invoice.pdf',
+    );
+
+    unshift @related_parts, $alternative;
+
+    $related = build MIME::Entity 'Type'     => 'multipart/related',
+                                  'Encoding' => '7bit';
+
+    #false laziness w/Misc::send_email
+    $related->head->replace('Content-type',
+      $related->mime_type.
+      '; boundary="'. $related->head->multipart_boundary. '"'.
+      '; type=multipart/alternative'
+    );
+
+    $related->add_part($_) foreach @related_parts;
+
+  }
+
+  my @otherparts = ();
+  if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
+
+    if ( $conf->config('voip-cdr_email_attach') eq 'zip' ) {
+
+      my $data = join('', map "$_\n",
+                   $self->call_details(prepend_billed_number=>1)
+                 );
+
+      my $zip = new Archive::Zip;
+      my $file = $zip->addString( $data, 'usage-'.$self->invnum.'.csv' );
+      $file->desiredCompressionMethod( COMPRESSION_DEFLATED );
+
+      my $zipdata = '';
+      my $SH = IO::Scalar->new(\$zipdata);
+      my $status = $zip->writeToFileHandle($SH);
+      die "Error zipping CDR attachment: $!" unless $status == AZ_OK;
+
+      push @otherparts, build MIME::Entity
+        'Type'        => 'application/zip',
+        'Encoding'    => 'base64',
+        'Data'        => $zipdata,
+        'Disposition' => 'attachment',
+        'Filename'    => 'usage-'. $self->invnum. '.zip',
+      ;
+
+    } else { # } elsif ( $conf->config('voip-cdr_email_attach') eq 'csv' ) {
+
+      push @otherparts, build MIME::Entity
+        'Type'        => 'text/csv',
+        'Encoding'    => '7bit',
+        'Data'        => [ map { "$_\n" }
+                             $self->call_details('prepend_billed_number' => 1)
+                         ],
+        'Disposition' => 'attachment',
+        'Filename'    => 'usage-'. $self->invnum. '.csv',
+      ;
+
+    }
+
+  }
+
+  if ( $conf->exists($tc.'email_pdf') ) {
+
+    #attaching pdf too:
+    # multipart/mixed
+    #   multipart/related
+    #     multipart/alternative
+    #       text/plain
+    #       text/html
+    #     image/png
+    #   application/pdf
+
+    my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
+    push @otherparts, $pdf;
+  }
+
+  if (@otherparts) {
+    $return{'content-type'} = 'multipart/mixed'; # of the outer container
+    if ( $html ) {
+      $return{'mimeparts'} = [ $related, @otherparts ];
+      $return{'type'} = 'multipart/related'; # of the first part
+    } else {
+      $return{'mimeparts'} = [ $text_part, @otherparts ];
+      $return{'type'} = 'text/plain';
+    }
+  } elsif ( $html ) { # no PDF or CSV, strip the outer container
+    $return{'mimeparts'} = \@related_parts;
+    $return{'content-type'} = 'multipart/related';
+    $return{'type'} = 'multipart/alternative';
+  } else { # no HTML either
+    $return{'body'} = \@text;
+    $return{'content-type'} = 'text/plain';
+  }
+
+  %return;
+
+}
+
+=item mimebuild_pdf
+
+Returns a list suitable for passing to MIME::Entity->build(), representing
+this quotation or invoice as PDF attachment.
+
+=cut
+
+sub mimebuild_pdf {
+  my $self = shift;
+  (
+    'Type'        => 'application/pdf',
+    'Encoding'    => 'base64',
+    'Data'        => [ $self->print_pdf(@_) ],
+    'Disposition' => 'attachment',
+    'Filename'    => $self->pdf_filename,
+  );
+}
+
+=item postal_mail_fsinc
+
+Sends this invoice to the Freeside Internet Services, Inc. print and mail
+service.
+
+=cut
+
+use CAM::PDF;
+use IO::Socket::SSL;
+use LWP::UserAgent;
+use HTTP::Request::Common qw( POST );
+use Cpanel::JSON::XS;
+use MIME::Base64;
+sub postal_mail_fsinc {
+  my ( $self, %opt ) = @_;
+
+  my $url = 'https://ws.freeside.biz/print';
+
+  my $cust_main = $self->cust_main;
+  my $agentnum = $cust_main->agentnum;
+  my $bill_location = $cust_main->bill_location;
+
+  die "Extra charges for international mailing; contact support\@freeside.biz to enable\n"
+    if $bill_location->country ne 'US';
+
+  my $conf = new FS::Conf;
+
+  my @company_address = $conf->config('company_address', $agentnum);
+  my ( $company_address1, $company_address2, $company_city, $company_state, $company_zip );
+  if ( $company_address[2] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
+    $company_address1 = $company_address[0];
+    $company_address2 = $company_address[1];
+    $company_city  = $1;
+    $company_state = $2;
+    $company_zip   = $3;
+  } elsif ( $company_address[1] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
+    $company_address1 = $company_address[0];
+    $company_address2 = '';
+    $company_city  = $1;
+    $company_state = $2;
+    $company_zip   = $3;
+  } else {
+    die "Unparsable company_address; contact support\@freeside.biz\n";
+  }
+  $company_city =~ s/,$//;
+
+  my $file = $self->print_pdf(%opt, 'no_addresses' => 1);
+  my $pages = CAM::PDF->new($file)->numPages;
+
+  my $ua = LWP::UserAgent->new(
+    'ssl_opts' => {
+      verify_hostname => 0,
+      SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
+      SSL_version     => 'SSLv3',
+    }
+  );
+  my $response = $ua->request( POST $url, [
+    'support-key'      => scalar($conf->config('support-key')),
+    'file'             => encode_base64($file),
+    'pages'            => $pages,
+
+    #from:
+    'company_name'     => scalar( $conf->config('company_name', $agentnum) ),
+    'company_address1' => $company_address1,
+    'company_address2' => $company_address2,
+    'company_city'     => $company_city,
+    'company_state'    => $company_state,
+    'company_zip'      => $company_zip,
+    'company_country'  => 'US',
+    'company_phonenum' => scalar($conf->config('company_phonenum', $agentnum)),
+    'company_email'    => scalar($conf->config('invoice_from', $agentnum)),
+
+    #to:
+    'name'             => $cust_main->invoice_attn
+                            || $cust_main->contact_firstlast,
+    'company'          => $cust_main->company,
+    'address1'         => $bill_location->address1,
+    'address2'         => $bill_location->address2,
+    'city'             => $bill_location->city,
+    'state'            => $bill_location->state,
+    'zip'              => $bill_location->zip,
+    'country'          => $bill_location->country,
+  ]);
+
+  die "Print connection error: ". $response->message.
+      ' ('. $response->as_string. ")\n"
+    unless $response->is_success;
+
+  local $@;
+  my $content = eval { decode_json($response->content) };
+  die "Print JSON error : $@\n" if $@;
+
+  die $content->{error}."\n"
+    if $content->{error};
+
+  #TODO: store this so we can query for a status later
+  warn "Invoice printed, ID ". $content->{id}. "\n";
+
+  $content->{id};
+
+}
+
 =item _items_sections OPTIONS
 
 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 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 
+Section descriptions are returned in sort weight order.  Each consists
 of a hash containing:
 
 description: the package category name, escaped
@@ -1734,7 +2585,7 @@ 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 
+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.
 
@@ -1743,32 +2594,32 @@ sections.
 
 OPTIONS may include:
 
-by_location: a flag to divide the invoice into sections by location.  
-Each section hash will have a 'location' element containing a hashref of 
+by_location: a flag to divide the invoice into sections by location.
+Each section hash will have a 'location' element containing a hashref of
 the location fields (see L<FS::cust_location>).  The section description
-will be the location label, but the template can use any of the location 
+will be the location label, but the template can use any of the location
 fields to create a suitable label.
 
-by_category: a flag to divide the invoice into sections using display 
-records (see L<FS::cust_bill_pkg_display>).  This is the "traditional" 
+by_category: a flag to divide the invoice into sections using display
+records (see L<FS::cust_bill_pkg_display>).  This is the "traditional"
 behavior.  Each section hash will have a 'category' element containing
-the section name from the display record (which probably equals the 
+the section name from the display record (which probably equals the
 category name of the package, but may not in some cases).
 
 summary: a flag indicating that this is a summary-format invoice.
 Turning this on has the following effects:
 - Ignores display items with the 'summary' flag.
 - Places all sections in the "early" group even if they have post_total.
-- Creates sections for all non-disabled package categories, even if they 
+- 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 
+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 
+format: 'latex', 'html', or 'template' (i.e. text).  Not used, but
 passed through to C<_condense_section()>.
 
 =cut
@@ -1777,7 +2628,7 @@ use vars qw(%pkg_category_cache);
 sub _items_sections {
   my $self = shift;
   my %opt = @_;
-  
+
   my $escape = $opt{escape};
   my @extra_sections = @{ $opt{extra_sections} || [] };
 
@@ -1790,12 +2641,12 @@ sub _items_sections {
   my %not_tax = ();
 
   # About tax items + multisection invoices:
-  # If either invoice_*summary option is enabled, AND there is a 
-  # package category with the name of the tax, then there will be 
+  # If either invoice_*summary option is enabled, AND there is a
+  # package category with the name of the tax, then there will be
   # a display record assigning the tax item to that category.
   #
   # However, the taxes are always placed in the "Taxes, Surcharges,
-  # and Fees" section regardless of that.  The only effect of the 
+  # and Fees" section regardless of that.  The only effect of the
   # display record is to create a subtotal for the summary page.
 
   # cache these
@@ -1825,17 +2676,20 @@ sub _items_sections {
 
         my $section = $display->section;
         my $type    = $display->type;
-        $section = undef unless $opt{by_category};
+        # Set $section = undef if we're sectioning by location and this
+        # line item _has_ a location (i.e. isn't a fee).
+        $section = undef if $locationnum;
 
+        # set this flag if the section is not tax-only
         $not_tax{$locationnum}{$section} = 1
-          unless $cust_bill_pkg->pkgnum == 0;
+          if $cust_bill_pkg->pkgnum  or $cust_bill_pkg->feepart;
 
         # there's actually a very important piece of logic buried in here:
-        # incrementing $late_subtotal{$section} CREATES 
-        # $late_subtotal{$section}.  keys(%late_subtotal) is later used 
+        # incrementing $late_subtotal{$section} CREATES
+        # $late_subtotal{$section}.  keys(%late_subtotal) is later used
         # to define the list of late sections, and likewise keys(%subtotal).
-        # When _items_cust_bill_pkg is called to generate line items for 
-        # real, it will be called with 'section' => $section for each 
+        # When _items_cust_bill_pkg is called to generate line items for
+        # real, it will be called with 'section' => $section for each
         # of these.
         if ( $display->post_total && !$opt{summary} ) {
           if (! $type || $type eq 'S') {
@@ -1855,7 +2709,7 @@ sub _items_sections {
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
           }
-          
+
           if ($type && $type eq 'U') {
             $late_subtotal{$locationnum}{$section} += $usage
               unless scalar(@extra_sections);
@@ -1864,29 +2718,24 @@ sub _items_sections {
         } else { # it's a pre-total (normal) section
 
           # skip tax items unless they're explicitly included in a section
-          next if $cust_bill_pkg->pkgnum == 0 && ! $section;
+          next if $cust_bill_pkg->pkgnum == 0 and
+                  ! $cust_bill_pkg->feepart   and
+                  ! $section;
 
-          if (! $type || $type eq 'S') {
+          if ( $type eq 'S' ) {
             $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
               if $cust_bill_pkg->setup != 0
               || $cust_bill_pkg->setup_show_zero;
-          }
-
-          if (! $type) {
-            $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
-              if $cust_bill_pkg->recur != 0
-              || $cust_bill_pkg->recur_show_zero;
-          }
-
-          if ($type && $type eq 'R') {
+          } elsif ( $type eq 'R' ) {
             $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
-          }
-          
-          if ($type && $type eq 'U') {
+          } elsif ( $type eq 'U' ) {
             $subtotal{$locationnum}{$section} += $usage
               unless scalar(@extra_sections);
+          } elsif ( !$type ) {
+            $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
+                                               + $cust_bill_pkg->recur;
           }
 
         }
@@ -1916,31 +2765,31 @@ sub _items_sections {
       foreach my $sectionname (keys %{ $s->{$locationnum} }) {
         my $section = {
                         'subtotal'    => $s->{$locationnum}{$sectionname},
-                        'post_total'  => $post_total,
                         'sort_weight' => 0,
                       };
         if ( $locationnum ) {
           $section->{'locationnum'} = $locationnum;
           my $location = FS::cust_location->by_key($locationnum);
           $section->{'description'} = &{ $escape }($location->location_label);
-          # Better ideas? This will roughly group them by proximity, 
+          # Better ideas? This will roughly group them by proximity,
           # which alpha sorting on any of the address fields won't.
           # Sorting by locationnum is meaningless.
-          # We have to sort on _something_ or the order may change 
+          # We have to sort on _something_ or the order may change
           # randomly from one invoice to the next, which will confuse
           # people.
           $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
                                       $locationnum;
           $section->{'location'} = {
+            label_prefix => &{ $escape }($location->label_prefix),
             map { $_ => &{ $escape }($location->get($_)) }
-            $location->fields
+              $location->fields
           };
         } else {
           $section->{'category'} = $sectionname;
           $section->{'description'} = &{ $escape }($sectionname);
-          if ( _pkg_category($_) ) {
-            $section->{'sort_weight'} = _pkg_category($_)->weight;
-            if ( _pkg_category($_)->condense ) {
+          if ( _pkg_category($sectionname) ) {
+            $section->{'sort_weight'} = _pkg_category($sectionname)->weight;
+            if ( _pkg_category($sectionname)->condense ) {
               $section = { %$section, $self->_condense_section($opt{format}) };
             }
           }
@@ -1954,10 +2803,10 @@ sub _items_sections {
       } # foreach $sectionname
     } #foreach $locationnum
     push @these, @extra_sections if $post_total == 0;
-    # need an alpha sort for location sections, because postal codes can 
+    # need an alpha sort for location sections, because postal codes can
     # be non-numeric
     $sections[ $post_total ] = [ sort {
-      $opt{'by_location'} ? 
+      $opt{'by_location'} ?
         ($a->{sort_weight} cmp $b->{sort_weight}) :
         ($a->{sort_weight} <=> $b->{sort_weight})
       } @these ];
@@ -2202,22 +3051,110 @@ sub _condensed_total_line_generator {
 
 =item _items_pkg [ OPTIONS ]
 
-Return line item hashes for each package item on this invoice. Nearly 
-equivalent to 
+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).
+OPTIONS are passed through to _items_cust_bill_pkg, and should include
+'format' and 'escape_function' at minimum.
+
+To produce items for a specific invoice section, OPTIONS should include
+'section', a hashref containing 'category' and/or 'locationnum' keys.
+
+'section' may also contain a key named 'condensed'. If this is present
+and has a true value, _items_pkg will try 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_nontax {
   my $self = shift;
-  grep { $_->pkgnum } $self->cust_bill_pkg;
+  # The order of these is important.  Bundled line items will be merged into
+  # the most recent non-hidden item, so it needs to be the one with:
+  # - the same pkgnum
+  # - the same start date
+  # - no pkgpart_override
+  #
+  # So: sort by pkgnum,
+  # then by sdate
+  # then sort the base line item before any overrides
+  # then sort hidden before non-hidden add-ons
+  # then sort by override pkgpart (for consistency)
+  sort { $a->pkgnum <=> $b->pkgnum        or
+         $a->sdate  <=> $b->sdate         or
+         ($a->pkgpart_override ? 0 : -1)  or
+         ($b->pkgpart_override ? 0 : 1)   or
+         $b->hidden cmp $a->hidden        or
+         $a->pkgpart_override <=> $b->pkgpart_override
+       }
+  # and of course exclude taxes and fees
+  grep { $_->pkgnum > 0 } $self->cust_bill_pkg;
+}
+
+sub _items_fee {
+  my $self = shift;
+  my %options = @_;
+  my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
+  my $escape_function = $options{escape_function};
+
+  my @items;
+  foreach my $cust_bill_pkg (@cust_bill_pkg) {
+    # cache this, so we don't look it up again in every section
+    my $part_fee = $cust_bill_pkg->get('part_fee')
+       || $cust_bill_pkg->part_fee;
+    $cust_bill_pkg->set('part_fee', $part_fee);
+    if (!$part_fee) {
+      #die "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n"; # might make more sense
+      warn "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n";
+      next;
+    }
+    if ( exists($options{section}) and exists($options{section}{category}) )
+    {
+      my $categoryname = $options{section}{category};
+      # then filter for items that have that section
+      if ( $part_fee->categoryname ne $categoryname ) {
+        warn "skipping fee '".$part_fee->itemdesc."'--not in section $categoryname\n" if $DEBUG;
+        next;
+      }
+    } # otherwise include them all in the main section
+    # XXX what to do when sectioning by location?
+
+    my @ext_desc;
+    my %base_invnums; # invnum => invoice date
+    foreach ($cust_bill_pkg->cust_bill_pkg_fee) {
+      if ($_->base_invnum) {
+        my $base_bill = FS::cust_bill->by_key($_->base_invnum);
+        my $base_date = $self->time2str_local('short', $base_bill->_date)
+          if $base_bill;
+        $base_invnums{$_->base_invnum} = $base_date || '';
+      }
+    }
+    foreach (sort keys(%base_invnums)) {
+      next if $_ == $self->invnum;
+      # per convention, we must escape ext_description lines
+      push @ext_desc,
+        &{$escape_function}(
+          $self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_})
+        );
+    }
+    my $desc = $part_fee->itemdesc_locale($self->cust_main->locale);
+    # but not escape the base description line
+
+    my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+      if $options{section_with_taxes};
+
+    push @items,
+      { feepart     => $cust_bill_pkg->feepart,
+        amount      => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
+        description => $desc,
+        pkg_tax     => \@pkg_tax,
+        ext_description => \@ext_desc,
+        # sdate/edate?
+      };
+  }
+  @items;
 }
 
 sub _items_pkg {
@@ -2275,7 +3212,8 @@ sub _taxsort {
 
 sub _items_tax {
   my $self = shift;
-  my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
+  my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart }
+    $self->cust_bill_pkg;
   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
 
   if ( $self->conf->exists('always_show_tax') ) {
@@ -2303,17 +3241,19 @@ escape_function: the function used to escape strings.
 DEPRECATED? (expensive, mostly unused?)
 format_function: the function used to format CDRs.
 
-section: a hashref containing 'category' and/or 'locationnum'; if this 
+section: a hashref containing 'category' and/or 'locationnum'; if this
 is present, only returns line items that belong to that category and/or
 location (whichever is defined).
 
 multisection: a flag indicating that this is a multisection invoice,
 which does something complicated.
 
+section_with_taxes:  Look up and include applied taxes for each record
+
 Returns a list of hashrefs, each of which may contain:
 
-pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
-ext_description, which is an arrayref of detail lines to show below 
+pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and
+ext_description, which is an arrayref of detail lines to show below
 the package line.
 
 =cut
@@ -2336,28 +3276,60 @@ sub _items_cust_bill_pkg {
   }
   my $summary_page = $opt{summary_page} || ''; #unused
   my $multisection = defined($category) || defined($locationnum);
-  my $discount_show_always = 0;
+  # this variable is the value of the config setting, not whether it applies
+  # to this particular line item.
+  my $discount_show_always = $conf->exists('discount-show-always');
 
-  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
+  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
 
   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
-                                   # and location labels
 
-  my @b = ();
-  my ($s, $r, $u) = ( undef, undef, undef );
+  # for location labels: use default location on the invoice date
+  my $default_locationnum;
+  if ( $conf->exists('invoice-all_pkg_addresses') ) {
+    $default_locationnum = 0; # treat them all as non-default
+  } elsif ( $self->custnum ) {
+    my $h_cust_main;
+    my @h_search = FS::h_cust_main->sql_h_search($self->_date);
+    $h_cust_main = qsearchs({
+        'table'     => 'h_cust_main',
+        'hashref'   => { custnum => $self->custnum },
+        'extra_sql' => $h_search[1],
+        'addl_from' => $h_search[3],
+    }) || $cust_main;
+    $default_locationnum = $h_cust_main->ship_locationnum;
+  } elsif ( $self->prospectnum ) {
+    my $cust_location = qsearchs('cust_location',
+      { prospectnum => $self->prospectnum,
+        disabled => '' });
+    $default_locationnum = $cust_location->locationnum if $cust_location;
+  }
+
+  my @b = (); # accumulator for the line item hashes that we'll return
+  my ($s, $r, $u, $d) = ( undef, undef, undef, undef );
+            # the 'current' line item hashes for setup, recur, usage, discount
   foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
   {
-
-    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+    # if the current line item is waiting to go out, and the one we're about
+    # to start is not bundled, then push out the current one and start a new
+    # one.
+    if ( $d ) {
+      $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
+    }
+    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
       if ( $_ && !$cust_bill_pkg->hidden ) {
-        $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+        $_->{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} && $_->{recur_show_zero} )
-          || (   $_->{_is_setup} && $_->{setup_show_zero} )
+        if (exists($_->{unit_amount})) {
+          $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
+        }
+        push @b, { %$_ };
+        # we already decided to create this display line; don't reconsider it
+        # now.
+        #  if $_->{amount} != 0
+        #  || $discount_show_always
+        #  || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+        #  || (   $_->{_is_setup} && $_->{setup_show_zero} )
         ;
         $_ = undef;
       }
@@ -2366,14 +3338,14 @@ sub _items_cust_bill_pkg {
     if ( $locationnum ) {
       # this is a location section; skip packages that aren't at this
       # service location.
-      next if $cust_bill_pkg->pkgnum == 0;
-      next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum 
+      next if $cust_bill_pkg->pkgnum == 0; # skips fees...
+      next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum
               != $locationnum;
     }
 
     # Consider display records for this item to determine if it belongs
     # in this section.  Note that if there are no display records, there
-    # will be a default pseudo-record that includes all charge types 
+    # will be a default pseudo-record that includes all charge types
     # and has no section name.
     my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
                                   ? $cust_bill_pkg->cust_bill_pkg_display
@@ -2390,11 +3362,14 @@ sub _items_cust_bill_pkg {
                                 @cust_bill_pkg_display;
     } else {
       # otherwise, process all display records that aren't usage summaries
-      # (I don't think there should be usage summaries if you aren't using 
+      # (I don't think there should be usage summaries if you aren't using
       # category sections, but this is the historical behavior)
       @cust_bill_pkg_display = grep { !$_->summary }
                                 @cust_bill_pkg_display;
     }
+
+    my $classname = ''; # package class name, will fill in later
+
     foreach my $display (@cust_bill_pkg_display) {
 
       warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
@@ -2413,48 +3388,34 @@ sub _items_cust_bill_pkg {
                           'no_usage'        => $opt{'no_usage'},
                         );
 
-      if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
-
-        warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
-          if $DEBUG > 1;
-
-        if ( $cust_bill_pkg->setup != 0 ) {
-          my $description = $desc;
-          $description .= ' Setup'
-            if $cust_bill_pkg->recur != 0
-            || $discount_show_always
-            || $cust_bill_pkg->recur_show_zero;
-          push @b, {
-            'description' => $description,
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
-          };
-        }
-        if ( $cust_bill_pkg->recur != 0 ) {
-          push @b, {
-            'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
-          };
-        }
-
-      } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
+      if ( $cust_bill_pkg->pkgnum > 0 ) {
+        # a "normal" package line item (not a quotation, not a fee, not a tax)
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
           if $DEBUG > 1;
+
         my $cust_pkg = $cust_bill_pkg->cust_pkg;
+        my $part_pkg = $cust_pkg->part_pkg;
 
         # which pkgpart to show for display purposes?
         my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
 
-        # start/end dates for invoice formats that do nonstandard 
+        # start/end dates for invoice formats that do nonstandard
         # things with them
         my %item_dates = ();
         %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
-          unless $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
+          unless $part_pkg->option('disable_line_item_date_ranges',1);
+
+        # not normally used, but pass this to the template anyway
+        $classname = $part_pkg->classname;
+
+        my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+          if $opt{section_with_taxes};
 
         if (    (!$type || $type eq 'S')
              && (    $cust_bill_pkg->setup != 0
                   || $cust_bill_pkg->setup_show_zero
+                  || ($discount_show_always and $cust_bill_pkg->unitsetup > 0)
                 )
            )
          {
@@ -2462,29 +3423,51 @@ sub _items_cust_bill_pkg {
           warn "$me _items_cust_bill_pkg adding setup\n"
             if $DEBUG > 1;
 
+          # append the word 'Setup' to the setup line if there's going to be
+          # a recur line for the same package (i.e. not a one-time charge)
+          # XXX localization
           my $description = $desc;
           $description .= ' Setup'
             if $cust_bill_pkg->recur != 0
-            || $discount_show_always
+            || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
             || $cust_bill_pkg->recur_show_zero;
 
+          $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
+                                                              $self->agentnum )
+            if $part_pkg->is_prepaid #for prepaid, "display the validity period
+                                     # triggered by the recurring charge freq
+                                     # (RT#26274)
+            && $cust_bill_pkg->recur == 0
+            && ! $cust_bill_pkg->recur_show_zero;
+
           my @d = ();
-          my $svc_label;
-          unless ( $cust_pkg->part_pkg->hide_svc_detail
+          my @svc_labels = ();
+          my $svc_label = '';
+
+          unless ( $part_pkg->hide_svc_detail ) {
+
+            # still pass the svc_label through to the template, even if
+            # not displaying it as an ext_description
+            @svc_labels = map &{$escape_function}($_),
+              $cust_pkg->h_labels_short($self->_date,
+                                        undef,
+                                        'I',
+                                        $self->conf->{locale},
+                                       );
+            $svc_label = $svc_labels[0];
+
+          }
+
+          unless ( $part_pkg->hide_svc_detail
                 || $cust_bill_pkg->hidden )
           {
 
-            my @svc_labels = map &{$escape_function}($_),
-                        $cust_pkg->h_labels_short($self->_date, undef, 'I');
             push @d, @svc_labels
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
-            $svc_label = $svc_labels[0];
-
-            my $lnum = $cust_main ? $cust_main->ship_locationnum
-                                  : $self->prospect_main->locationnum;
             # show the location label if it's not the customer's default
             # location, and we're not grouping items by location already
-            if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
+            if ( $cust_pkg->locationnum != $default_locationnum
+                  and !defined($locationnum) ) {
               my $loc = $cust_pkg->location_label;
               $loc = substr($loc, 0, $maxlength). '...'
                 if $format eq 'latex' && length($loc) > $maxlength;
@@ -2502,6 +3485,7 @@ sub _items_cust_bill_pkg {
             push @{ $s->{ext_description} }, @d;
           } else {
             $s = {
+              billpkgnum      => $cust_bill_pkg->billpkgnum,
               _is_setup       => 1,
               description     => $description,
               pkgpart         => $pkgpart,
@@ -2512,16 +3496,25 @@ sub _items_cust_bill_pkg {
               quantity        => $cust_bill_pkg->quantity,
               ext_description => \@d,
               svc_label       => ($svc_label || ''),
+              locationnum     => $cust_pkg->locationnum, # sure, why not?
+              pkg_tax         => \@pkg_tax,
             };
           };
 
         }
 
+        # should we show a recur line?
+        # if type eq 'S', then NO, because we've been told not to.
+        # otherwise, show the recur line if:
+        # - there's a recurring charge
+        # - or recur_show_zero is on
+        # - or there's a positive unitrecur (so it's been discounted to zero)
+        #   and discount-show-always is on
         if (    ( !$type || $type eq 'R' || $type eq 'U' )
              && (
                      $cust_bill_pkg->recur != 0
-                  || $cust_bill_pkg->setup == 0
-                  || $discount_show_always
+                  || !defined($s)
+                  || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
                   || $cust_bill_pkg->recur_show_zero
                 )
            )
@@ -2532,46 +3525,19 @@ sub _items_cust_bill_pkg {
 
           my $is_summary = $display->summary;
           my $description = $desc;
-          if ( $type eq 'U' and ($is_summary or $cust_bill_pkg->hidden) ) {
+          if ( $type eq 'U' and defined($r) ) {
+            # don't just show the same description as the recur line
             $description = $self->mt('Usage charges');
           }
 
           my $part_pkg = $cust_pkg->part_pkg;
 
-          #pry be a bit more efficient to look some of this conf stuff up
-          # outside the loop
-          unless (
-            $conf->exists('disable_line_item_date_ranges')
-              || $part_pkg->option('disable_line_item_date_ranges',1)
-              || ! $cust_bill_pkg->sdate
-              || ! $cust_bill_pkg->edate
-          ) {
-            my $time_period;
-            my $date_style = '';
-            $date_style = $conf->config( 'cust_bill-line_item-date_style-non_monhtly',
-                                         $self->agentnum
-                                       )
-              if $part_pkg && $part_pkg->freq !~ /^1m?$/;
-            $date_style ||= $conf->config( 'cust_bill-line_item-date_style',
-                                            $self->agentnum
-                                         );
-            if ( defined($date_style) && $date_style eq 'month_of' ) {
-              $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
-            } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
-              my $desc = $conf->config( 'cust_bill-line_item-date_description',
-                                         $self->agentnum
-                                      );
-              $desc .= ' ' unless $desc =~ /\s$/;
-              $time_period = $desc. time2str('%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)";
-          }
+          $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
+                                                              $self->agentnum );
 
           my @d = ();
           my @seconds = (); # for display of usage info
+          my @svc_labels = ();
           my $svc_label = '';
 
           #at least until cust_bill_pkg has "past" ranges in addition to
@@ -2581,30 +3547,42 @@ sub _items_cust_bill_pkg {
           push @dates, $prev->sdate if $prev;
           push @dates, undef if !$prev;
 
+          unless ( $part_pkg->hide_svc_detail ) {
+            @svc_labels = map &{$escape_function}($_),
+              $cust_pkg->h_labels_short(@dates,
+                                        'I',
+                                        $self->conf->{locale});
+            $svc_label = $svc_labels[0];
+          }
+
+          # show service labels, unless...
+                    # the package is set not to display them
           unless ( $part_pkg->hide_svc_detail
+                    # or this is a tax-like line item
                 || $cust_bill_pkg->itemdesc
+                    # or this is a hidden (bundled) line item
                 || $cust_bill_pkg->hidden
+                    # or this is a usage summary line
                 || $is_summary && $type && $type eq 'U'
+                    # or this is a usage line and there's a recurring line
+                    # for the package in the same section (which will
+                    # have service labels already)
+                || ($type eq 'U' and defined($r))
               )
           {
 
             warn "$me _items_cust_bill_pkg adding service details\n"
               if $DEBUG > 1;
 
-            my @svc_labels = map &{$escape_function}($_),
-                        $cust_pkg->h_labels_short(@dates, 'I');
             push @d, @svc_labels
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
-            $svc_label = $svc_labels[0];
-
             warn "$me _items_cust_bill_pkg done adding service details\n"
               if $DEBUG > 1;
 
-            my $lnum = $cust_main ? $cust_main->ship_locationnum
-                                  : $self->prospect_main->locationnum;
             # show the location label if it's not the customer's default
             # location, and we're not grouping items by location already
-            if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
+            if ( $cust_pkg->locationnum != $default_locationnum
+                  and !defined($locationnum) ) {
               my $loc = $cust_pkg->location_label;
               $loc = substr($loc, 0, $maxlength). '...'
                 if $format eq 'latex' && length($loc) > $maxlength;
@@ -2613,17 +3591,17 @@ sub _items_cust_bill_pkg {
 
             # 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 
+            # 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') 
+            if ( $conf->exists('svc_acct-usage_seconds')
                  and ! $cust_bill_pkg->pkgpart_override ) {
-              foreach my $cust_svc ( 
-                  $cust_pkg->h_cust_svc(@dates, 'I') 
+              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 
+                # 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]);
@@ -2632,7 +3610,7 @@ sub _items_cust_bill_pkg {
               }
             } #if svc_acct-usage_seconds
 
-          }
+          } # if we are showing service labels
 
           unless ( $is_summary ) {
             warn "$me _items_cust_bill_pkg adding details\n"
@@ -2648,7 +3626,7 @@ sub _items_cust_bill_pkg {
 
           warn "$me _items_cust_bill_pkg calculating amount\n"
             if $DEBUG > 1;
-  
+
           my $amount = 0;
           if (!$type) {
             $amount = $cust_bill_pkg->recur;
@@ -2657,22 +3635,23 @@ sub _items_cust_bill_pkg {
           } elsif ($type eq 'U') {
             $amount = $cust_bill_pkg->usage;
           }
-  
-          my $unit_amount =
-            ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
-                                              : $amount;
 
           if ( !$type || $type eq 'R' ) {
 
             warn "$me _items_cust_bill_pkg adding recur\n"
               if $DEBUG > 1;
 
+            my $unit_amount =
+              ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
+                                                : $amount;
+
             if ( $cust_bill_pkg->hidden ) {
               $r->{amount}      += $amount;
               $r->{unit_amount} += $unit_amount;
               push @{ $r->{ext_description} }, @d;
             } else {
               $r = {
+                billpkgnum      => $cust_bill_pkg->billpkgnum,
                 description     => $description,
                 pkgpart         => $pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
@@ -2683,6 +3662,8 @@ sub _items_cust_bill_pkg {
                 %item_dates,
                 ext_description => \@d,
                 svc_label       => ($svc_label || ''),
+                locationnum     => $cust_pkg->locationnum,
+                pkg_tax         => \@pkg_tax,
               };
               $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
             }
@@ -2697,65 +3678,97 @@ sub _items_cust_bill_pkg {
               # line for the bundle, add this package's total amount and
               # usage details to it
               $u->{amount}      += $amount;
-              $u->{unit_amount} += $unit_amount,
               push @{ $u->{ext_description} }, @d;
             } elsif ( $amount ) {
               # create a new usage line
               $u = {
+                billpkgnum      => $cust_bill_pkg->billpkgnum,
                 description     => $description,
                 pkgpart         => $pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
                 amount          => $amount,
+                usage_item      => 1,
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
-                unit_amount     => $unit_amount,
-                quantity        => $cust_bill_pkg->quantity,
                 %item_dates,
                 ext_description => \@d,
+                locationnum     => $cust_pkg->locationnum,
+                pkg_tax         => \@pkg_tax,
               };
             } # else this has no usage, so don't create a usage section
           }
 
         } # recurring or usage with recurring charge
 
-      } else { #pkgnum tax or one-shot line item (??)
+      } else { # taxes and fees
 
         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,
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
-          };
-        }
-        if ( $cust_bill_pkg->recur != 0 ) {
-          push @b, {
-            'description' => "$desc (".
-                             time2str($date_format, $cust_bill_pkg->sdate). ' - '.
-                             time2str($date_format, $cust_bill_pkg->edate). ')',
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
-          };
-        }
+        # items of this kind should normally not have sdate/edate.
+        push @b, {
+          'description' => $desc,
+          'amount'      => sprintf('%.2f', $cust_bill_pkg->setup
+                                           + $cust_bill_pkg->recur)
+        };
 
-      }
+      } # if package line item / other line item
+
+      # decide whether to show active discounts here
+      if (
+          # case 1: we are showing a single line for the package
+          ( !$type )
+          # case 2: we are showing a setup line for a package that has
+          # no base recurring fee
+          or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
+          # case 3: we are showing a recur line for a package that has
+          # a base recurring fee
+          or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
+      ) {
+
+        my $item_discount = $cust_bill_pkg->_item_discount;
+        if ( $item_discount ) {
+          # $item_discount->{amount} is negative
+
+          if ( $d and $cust_bill_pkg->hidden ) {
+            $d->{setup_amount} += $item_discount->{setup_amount};
+            $d->{recur_amount} += $item_discount->{recur_amount};
+          } else {
+            $d = $item_discount;
+            $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} };
+          }
 
-    }
+          # update the active line (before the discount) to show the
+          # original price (whether this is a hidden line or not)
+
+          $s->{amount} -= $item_discount->{setup_amount} if $s;
+          $r->{amount} -= $item_discount->{recur_amount} if $r;
+
+        } # if there are any discounts
+      } # if this is an appropriate place to show discounts
 
-    $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
-                                && $conf->exists('discount-show-always'));
+    } # foreach $display
+
+  }
 
+  # discount amount is internally split up
+  if ( $d ) {
+    $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
   }
 
-  foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+  foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
     if ( $_  ) {
       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+        if exists($_->{amount});
       $_->{amount}      =~ s/^\-0\.00$/0.00/;
-      $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
-      push @b, { %$_ }
-        if $_->{amount} != 0
-        || $discount_show_always
-        || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
-        || (   $_->{_is_setup} && $_->{setup_show_zero} )
+      if (exists($_->{unit_amount})) {
+        $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
+      }
+
+      push @b, { %$_ };
+      #if $_->{amount} != 0
+      #  || $discount_show_always
+      #  || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+      #  || (   $_->{_is_setup} && $_->{setup_show_zero} )
     }
   }
 
@@ -2769,9 +3782,9 @@ sub _items_cust_bill_pkg {
 =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 
+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
@@ -2779,7 +3792,7 @@ a setup fee if the discount is allowed to apply to setup fees.
 sub _items_discounts_avail {
   my $self = shift;
 
-  #maybe move this method from cust_bill when quotations support discount_plans 
+  #maybe move this method from cust_bill when quotations support discount_plans
   return () unless $self->can('discount_plans');
   my %plans = $self->discount_plans;
 
@@ -2791,7 +3804,7 @@ sub _items_discounts_avail {
     my $plan = $plans{$months};
 
     my $term_total = sprintf('%.2f', $plan->discounted_total);
-    my $percent = sprintf('%.0f', 
+    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'). ' '.
@@ -2804,7 +3817,7 @@ sub _items_discounts_avail {
     +{
       description => $self->mt('Save [_1]% by paying for [_2] months',
                                 $percent, $months),
-      amount      => $self->mt('[_1] ([_2] per month)', 
+      amount      => $self->mt('[_1] ([_2] per month)',
                                 $term_total, $money_char.$permonth),
       ext_description => ($detail || ''),
     }
@@ -2813,4 +3826,68 @@ sub _items_discounts_avail {
 
 }
 
+=item has_sections AGENTNUM
+
+Return true if invoice_sections should be enabled for this bill.
+ (Inherited by both cust_bill and cust_bill_void)
+
+Determination:
+* False if not an invoice
+* True always if conf invoice_sections is enabled
+* True always if sections_by_location is enabled
+* True if conf invoice_sections_multilocation > 1,
+  and location_count >= invoice_sections_multilocation
+* Else, False
+
+=cut
+
+sub has_sections {
+  my ($self, $agentnum) = @_;
+
+  return 0 unless $self->invnum > 0;
+
+  $agentnum ||= $self->agentnum;
+  return 1 if $self->conf->config_bool('invoice_sections', $agentnum);
+  return 1 if $self->conf->exists('sections_by_location', $agentnum);
+
+  my $location_min = $self->conf->config(
+    'invoice_sections_multilocation', $agentnum,
+  );
+
+  return 1
+    if $location_min
+    && $self->location_count >= $location_min;
+
+  0;
+}
+
+
+=item location_count
+
+Return the number of locations billed on this invoice
+
+=cut
+
+sub location_count {
+  my ($self) = @_;
+  return 0 unless $self->invnum;
+
+  # SELECT COUNT( DISTINCT cust_pkg.locationnum )
+  # FROM cust_bill_pkg
+  # LEFT JOIN cust_pkg USING (pkgnum)
+  # WHERE invnum = 278
+  #   AND cust_bill_pkg.pkgnum > 0
+
+  my $result = qsearchs({
+    select    => 'COUNT(DISTINCT cust_pkg.locationnum) as location_count',
+    table     => 'cust_bill_pkg',
+    addl_from => 'LEFT JOIN cust_pkg USING (pkgnum)',
+    extra_sql => 'WHERE invnum = '.dbh->quote( $self->invnum )
+               . '  AND cust_bill_pkg.pkgnum > 0'
+  });
+  ref $result ? $result->location_count : 0;
+}
+
+
+
 1;