Merge branch 'master' of https://github.com/jgoodman/Freeside
[freeside.git] / FS / FS / Template_Mixin.pm
index 7d8135d..131a236 100644 (file)
@@ -2,7 +2,7 @@ package FS::Template_Mixin;
 
 use strict;
 use vars qw( $DEBUG $me
 
 use strict;
 use vars qw( $DEBUG $me
-             $money_char $date_format $rdate_format $date_format_long );
+             $money_char );
              # but NOT $conf
 use vars qw( $invoice_lines @buf ); #yuck
 use List::Util qw(sum);
              # but NOT $conf
 use vars qw( $invoice_lines @buf ); #yuck
 use List::Util qw(sum);
@@ -15,9 +15,11 @@ use Locale::Country;
 use Cwd;
 use FS::UID;
 use FS::Record qw( qsearch qsearchs );
 use Cwd;
 use FS::UID;
 use FS::Record qw( qsearch qsearchs );
+use FS::Conf;
 use FS::Misc qw( generate_ps generate_pdf );
 use FS::pkg_category;
 use FS::pkg_class;
 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;
 use FS::L10N;
 
 $DEBUG = 0;
@@ -25,17 +27,53 @@ $me = '[FS::Template_Mixin]';
 FS::UID->install_callback( sub { 
   my $conf = new FS::Conf; #global
   $money_char       = $conf->config('money_char')       || '$';  
 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';
 } );
 
 } );
 
-=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.
 
 
 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.
 
 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 +88,19 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub print_text {
   my $self = shift;
 
 sub print_text {
   my $self = shift;
-  my( $today, $template, %opt );
+  my %params;
   if ( ref($_[0]) ) {
   if ( ref($_[0]) ) {
-    %opt = %{ shift() };
-    $today = delete($opt{'time'}) || '';
-    $template = delete($opt{template}) || '';
+    %params = %{ shift() };
   } else {
   } 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 );
 }
 
 
   $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
 
 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,15 +108,16 @@ an associated logo (with the .eps extension included).
 
 See print_ps and print_pdf for methods that return PostScript and PDF output.
 
 
 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.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
 
 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.
 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.
+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)
 
 
 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
 
@@ -92,22 +125,20 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub print_latex {
   my $self = shift;
 
 sub print_latex {
   my $self = shift;
-  my $conf = $self->conf;
-  my( $today, $template, %opt );
+  my %params;
+
   if ( ref($_[0]) ) {
   if ( ref($_[0]) ) {
-    %opt = %{ shift() };
-    $today = delete($opt{'time'}) || '';
-    $template = delete($opt{template}) || '';
+    %params = %{ shift() };
   } else {
   } 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 );
+  $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');
 
   $template ||= $self->_agent_template
     if $self->can('_agent_template');
 
@@ -122,9 +153,7 @@ sub print_latex {
     UNLINK   => 0,
   ) or die "can't open temp file: $!\n";
 
     UNLINK   => 0,
   ) or die "can't open temp file: $!\n";
 
-  my $cust_main = $self->cust_main;
-  my $prospect_main = $self->prospect_main;
-  my $agentnum = $cust_main ? $cust_main->agentnum : $prospect_main->agentnum;
+  my $agentnum = $self->agentnum;
 
   if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
     print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
 
   if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
     print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
@@ -176,6 +205,12 @@ sub print_latex {
 
 }
 
 
 }
 
+sub agentnum {
+  my $self = shift;
+  my $cust_main = $self->cust_main;
+  $cust_main ? $cust_main->agentnum : $self->prospect_main->agentnum;
+}
+
 =item print_generic OPTION => VALUE ...
 
 Internal method - returns a filled-in template for this invoice as a scalar.
 =item print_generic OPTION => VALUE ...
 
 Internal method - returns a filled-in template for this invoice as a scalar.
@@ -187,7 +222,8 @@ Non optional options include
 
 Optional options include
 
 
 Optional options include
 
-template - a value used as a suffix for a configuration template
+template - a value used as a suffix for a configuration template.  Please 
+don't use this.
 
 time - a value 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.
 
 time - a value 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.
@@ -210,6 +246,7 @@ locale - override customer's locale
 sub print_generic {
   my( $self, %params ) = @_;
   my $conf = $self->conf;
 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;
   my $today = $params{today} ? $params{today} : time;
   warn "$me print_generic called on $self with suffix $params{template}\n"
     if $DEBUG;
@@ -223,6 +260,8 @@ sub print_generic {
     unless $cust_main->payname
         && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
 
     unless $cust_main->payname
         && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
 
+  my $locale = $params{'locale'} || $cust_main->locale;
+
   my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
                      'html'     => [ '<%=', '%>' ],
                      'template' => [ '{', '}' ],
   my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
                      'html'     => [ '<%=', '%>' ],
                      'template' => [ '{', '}' ],
@@ -231,11 +270,18 @@ sub print_generic {
   warn "$me print_generic creating template\n"
     if $DEBUG > 1;
 
   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");
   #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");
+
+  # the base template
   my @invoice_template = map "$_\n", $conf->config($templatefile)
     or die "cannot load config data $templatefile";
 
   my @invoice_template = map "$_\n", $conf->config($templatefile)
     or die "cannot load config data $templatefile";
 
@@ -357,22 +403,6 @@ sub print_generic {
   my $escape_function_nonbsp = ($format eq 'html')
                                  ? \&_html_escape : $escape_function;
 
   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 %embolden_functions = ( 'latex'    => sub { return '\textbf{'. shift(). '}'
-                                               },
-                             'html'     => sub { return '<b>'. shift(). '</b>'
-                                               },
-                             'template' => sub { shift },
-                           );
-  my $embolden_function = $embolden_functions{$format};
-
   my %newline_tokens = (  'latex'     => '\\\\',
                           'html'      => '<br>',
                           'template'  => "\n",
   my %newline_tokens = (  'latex'     => '\\\\',
                           'html'      => '<br>',
                           'template'  => "\n",
@@ -384,6 +414,7 @@ sub print_generic {
 
   # generate template variables
   my $returnaddress;
 
   # generate template variables
   my $returnaddress;
+
   if (
          defined( $conf->config_orbase( "invoice_${format}returnaddress",
                                         $template
   if (
          defined( $conf->config_orbase( "invoice_${format}returnaddress",
                                         $template
@@ -449,15 +480,21 @@ sub print_generic {
     'agent'           => &$escape_function($cust_main->agent->agent),
 
     #invoice/quotation info
     'agent'           => &$escape_function($cust_main->agent->agent),
 
     #invoice/quotation info
-    'invnum'          => $self->invnum,
+    'no_number'       => $params{'no_number'},
+    'invnum'          => ( $params{'no_number'} ? '' : $self->invnum ),
     'quotationnum'    => $self->quotationnum,
     'quotationnum'    => $self->quotationnum,
-    'date'            => time2str($date_format, $self->_date),
-    'today'           => time2str($date_format_long, $today),
+    'no_date'         => $params{'no_date'},
+    '_date'           => ( $params{'no_date'} ? '' : $self->_date ),
+    'date'            => ( $params{'no_date'}
+                             ? ''
+                             : $self->time2str_local('long', $self->_date, $format)
+                         ),
+    'today'           => $self->time2str_local('long', $today, $format),
     'terms'           => $self->terms,
     'template'        => $template, #params{'template'},
     '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),
     'current_charges' => sprintf("%.2f", $self->charged),
-    'duedate'         => $self->due_date2str($rdate_format), #date_format?
+    'duedate'         => $self->due_date2str('rdate'), #date_format?
 
     #customer info
     'custnum'         => $cust_main->display_custnum,
 
     #customer info
     'custnum'         => $cust_main->display_custnum,
@@ -497,15 +534,9 @@ sub print_generic {
   );
  
   #localization
   );
  
   #localization
-  my $lh = FS::L10N->get_handle( $params{'locale'} || $cust_main->locale );
   $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
   $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
   # 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;
 
   my $min_sdate = 999999999999;
   my $max_edate = 0;
@@ -518,8 +549,10 @@ sub print_generic {
   }
 
   $invoice_data{'bill_period'} = '';
   }
 
   $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} = '';
     if ($max_edate != 0 && $min_sdate != 999999999999);
 
   $invoice_data{finance_section} = '';
@@ -534,11 +567,14 @@ sub print_generic {
   my $countrydefault = $conf->config('countrydefault') || 'US';
   foreach ( qw( address1 address2 city state zip country fax) ){
     my $method = 'ship_'.$_;
   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{'ship_country'} = ''
     if ( $invoice_data{'ship_country'} eq $countrydefault );
   
@@ -605,12 +641,49 @@ sub print_generic {
   # info from customer's last invoice before this one, for some 
   # summary formats
   $invoice_data{'last_bill'} = {};
   # info from customer's last invoice before this one, for some 
   # summary formats
   $invoice_data{'last_bill'} = {};
-  my $last_bill = $pr_cust_bill[-1];
-  if ( $last_bill ) {
-    $invoice_data{'last_bill'} = {
-      '_date'     => $last_bill->_date, #unformatted
-      # all we need for now
-    };
+
+  if ( $self->custnum && $self->invnum ) {
+
+    if ( $self->previous_bill ) {
+      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'        => $self->time2str_local('long', $cust_pay->_date, $format),
+            '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'        => $self->time2str_local('long', $cust_credit->_date, $format),
+            'creditreason'=> $cust_credit->reason,
+            'amount'      => sprintf('%.2f', $cust_credit->amount),
+        };
+      }
+      $invoice_data{'previous_payments'} = \@payments;
+      $invoice_data{'previous_credits'}  = \@credits;
+    }
+
   }
 
   my $summarypage = '';
   }
 
   my $summarypage = '';
@@ -695,6 +768,11 @@ sub print_generic {
   my $other_money_char = $other_money_chars{$format};
   $invoice_data{'dollar'} = $other_money_char;
 
   my $other_money_char = $other_money_chars{$format};
   $invoice_data{'dollar'} = $other_money_char;
 
+  my %minus_signs = ( 'latex'    => '$-$',
+                      'html'     => '&minus;',
+                      'template' => '- ' );
+  my $minus = $minus_signs{$format};
+
   my @detail_items = ();
   my @total_items = ();
   my @buf = ();
   my @detail_items = ();
   my @total_items = ();
   my @buf = ();
@@ -708,22 +786,10 @@ sub print_generic {
   warn "$me generating sections\n"
     if $DEBUG > 1;
 
   warn "$me generating sections\n"
     if $DEBUG > 1;
 
-  # Previous Charges section
-  # subtotal is the first return value from $self->previous
-  my $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');
-
   my $taxtotal = 0;
   my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
                       'subtotal'    => $taxtotal,   # adjusted below
   my $taxtotal = 0;
   my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
                       'subtotal'    => $taxtotal,   # adjusted below
+                      'tax_section' => 1,
                     };
   my $tax_weight = _pkg_category($tax_section->{description})
                         ? _pkg_category($tax_section->{description})->weight
                     };
   my $tax_weight = _pkg_category($tax_section->{description})
                         ? _pkg_category($tax_section->{description})->weight
@@ -731,7 +797,6 @@ sub print_generic {
   $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
   $tax_section->{'sort_weight'} = $tax_weight;
 
   $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'),
   my $adjusttotal = 0;
   my $adjust_section = {
     'description'    => $self->mt('Credits, Payments, and Adjustments'),
@@ -745,17 +810,45 @@ sub print_generic {
   $adjust_section->{'sort_weight'} = $adjust_weight;
 
   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
   $adjust_section->{'sort_weight'} = $adjust_weight;
 
   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
-  my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum);
+  my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) ||
+                     $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
   $invoice_data{'multisection'} = $multisection;
   $invoice_data{'multisection'} = $multisection;
-  my $late_sections = [];
+  my $late_sections;
   my $extra_sections = [];
   my $extra_lines = ();
 
   my $extra_sections = [];
   my $extra_lines = ();
 
+  # default section ('Charges')
   my $default_section = { 'description' => '',
                           'subtotal'    => '', 
                           'no_subtotal' => 1,
                         };
 
   my $default_section = { 'description' => '',
                           '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 
+  # 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');
+
+  } else {
+    # otherwise put them in the main section
+    $previous_section = $default_section;
+  }
+
   if ( $multisection ) {
     ($extra_sections, $extra_lines) =
       $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
   if ( $multisection ) {
     ($extra_sections, $extra_lines) =
       $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
@@ -765,13 +858,24 @@ sub print_generic {
     push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
 
     push @detail_items, @$extra_lines if $extra_lines;
     push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
 
     push @detail_items, @$extra_lines if $extra_lines;
-    push @sections,
-      $self->_items_sections( $late_sections,      # this could stand a refactor
-                              $summarypage,
-                              $escape_function_nonbsp,
-                              $extra_sections,
-                              $format,             #bah
+
+    # 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 
+    # now, treat them as mutually exclusive.
+    my %section_method = ( by_category => 1 );
+    if ( $conf->exists($tc.'sections_by_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
                             );
                             );
+    push @sections, @$early;
+    $late_sections = $late;
+
     if (    $conf->exists('svc_phone_sections')
          && $self->can('_items_svc_phone_sections')
        )
     if (    $conf->exists('svc_phone_sections')
          && $self->can('_items_svc_phone_sections')
        )
@@ -797,24 +901,29 @@ sub print_generic {
     # make a default section
     push @sections, $default_section;
     # and calculate the finance charge total, since it won't get done otherwise.
     # make a default section
     push @sections, $default_section;
     # and calculate the finance charge total, since it won't get done otherwise.
-    # XXX possibly other totals?
+    # and the default section total
     # XXX possibly finance_pkgclass should not be used in this manner?
     # XXX possibly finance_pkgclass should not be used in this manner?
-    if ( $conf->exists('finance_pkgclass') ) {
-      my @finance_charges;
-      foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
-        if ( grep { $_->section eq $invoice_data{finance_section} }
-             $cust_bill_pkg->cust_bill_pkg_display ) {
-          # I think these are always setup fees, but just to be sure...
-          push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
-        }
+    my @finance_charges;
+    my @charges;
+    foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+      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...
+        push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
+      } else {
+        push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
       }
       }
-      $invoice_data{finance_amount} = 
-        sprintf('%.2f', sum( @finance_charges ) || 0);
     }
     }
+    $invoice_data{finance_amount} = 
+      sprintf('%.2f', sum( @finance_charges ) || 0);
+    $default_section->{subtotal} = $other_money_char.
+                                    sprintf('%.2f', sum( @charges ) || 0);
   }
 
   # previous invoice balances in the Previous Charges section if there
   # is one, otherwise in the main detail section
   }
 
   # 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)
   if ( $self->can('_items_previous') &&
        $self->enable_previous &&
        ! $conf->exists('previous_balance-summary_only') ) {
   if ( $self->can('_items_previous') &&
        $self->enable_previous &&
        ! $conf->exists('previous_balance-summary_only') ) {
@@ -825,22 +934,18 @@ sub print_generic {
     foreach my $line_item ( $self->_items_previous ) {
 
       my $detail = {
     foreach my $line_item ( $self->_items_previous ) {
 
       my $detail = {
-        ext_description => [],
+        ref             => $line_item->{'pkgnum'},
+        pkgpart         => $line_item->{'pkgpart'},
+        #quantity        => 1, # not really correct
+        section         => $previous_section, # which might be $default_section
+        description     => &$escape_function($line_item->{'description'}),
+        ext_description => [ map { &$escape_function($_) } 
+                             @{ $line_item->{'ext_description'} || [] }
+                           ],
+        amount          => ( $old_latex ? '' : $money_char).
+                            $line_item->{'amount'},
+        product_code    => $line_item->{'pkgpart'} || 'N/A',
       };
       };
-      $detail->{'ref'} = $line_item->{'pkgnum'};
-      $detail->{'pkgpart'} = $line_item->{'pkgpart'};
-      $detail->{'quantity'} = 1;
-      $detail->{'section'} = $multisection ? $previous_section
-                                           : $default_section;
-      $detail->{'description'} = &$escape_function($line_item->{'description'});
-      if ( exists $line_item->{'ext_description'} ) {
-        @{$detail->{'ext_description'}} = map {
-          &$escape_function($_);
-        } @{$line_item->{'ext_description'}};
-      }
-      $detail->{'amount'} = ( $old_latex ? '' : $money_char).
-                            $line_item->{'amount'};
-      $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
 
       push @detail_items, $detail;
       push @buf, [ $detail->{'description'},
 
       push @detail_items, $detail;
       push @buf, [ $detail->{'description'},
@@ -871,9 +976,6 @@ sub print_generic {
 
   foreach my $section (@sections, @$late_sections) {
 
 
   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
     # begin some normalization
     $section->{'subtotal'} = $section->{'amount'}
       if $multisection
@@ -911,7 +1013,6 @@ sub print_generic {
     $options{'summary_page'} = $summarypage;
     $options{'skip_usage'} =
       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
     $options{'summary_page'} = $summarypage;
     $options{'skip_usage'} =
       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
-    $options{'multisection'} = $multisection;
 
     warn "$me   searching for line items\n"
       if $DEBUG > 1;
 
     warn "$me   searching for line items\n"
       if $DEBUG > 1;
@@ -968,18 +1069,16 @@ sub print_generic {
   $invoice_data{current_less_finance} =
     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
 
   $invoice_data{current_less_finance} =
     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
 
-  # create a major section for previous balance if we have major sections,
-  # or if previous_section is in summary form
-  if ( ( $multisection && $self->enable_previous )
-    || $conf->exists('previous_balance-summary_only') )
-  {
-    unshift @sections, $previous_section if $pr_total;
+  # 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;
   }
 
   warn "$me adding taxes\n"
     if $DEBUG > 1;
 
   }
 
   warn "$me adding taxes\n"
     if $DEBUG > 1;
 
-  foreach my $tax ( $self->_items_tax ) {
+  my @items_tax = $self->_items_tax;
+  foreach my $tax ( @items_tax ) {
 
     $taxtotal += $tax->{'amount'};
 
 
     $taxtotal += $tax->{'amount'};
 
@@ -1014,7 +1113,7 @@ sub print_generic {
 
   }
   
 
   }
   
-  if ( $taxtotal ) {
+  if ( @items_tax ) {
     my $total = {};
     $total->{'total_item'} = $self->mt('Sub-total');
     $total->{'total_amount'} =
     my $total = {};
     $total->{'total_item'} = $self->mt('Sub-total');
     $total->{'total_amount'} =
@@ -1041,9 +1140,33 @@ sub print_generic {
              $money_char. sprintf("%10.2f",$self->charged) ];
   push @buf,['',''];
 
              $money_char. sprintf("%10.2f",$self->charged) ];
   push @buf,['',''];
 
-  # calculate total, possibly including total owed on previous
-  # invoices
-  {
+
+  ###
+  # Totals
+  ###
+
+  my %embolden_functions = (
+    'latex'    => sub { return '\textbf{'. shift(). '}' },
+    'html'     => sub { return '<b>'. shift(). '</b>' },
+    'template' => sub { shift },
+  );
+  my $embolden_function = $embolden_functions{$format};
+
+  if ( $self->can('_items_total') ) { # quotations
+
+    $self->_items_total(\@total_items);
+
+    foreach ( @total_items ) {
+      $_->{'total_item'}   = &$embolden_function( $_->{'total_item'} );
+      $_->{'total_amount'} = &$embolden_function( $other_money_char.
+                                                   $_->{'total_amount'}
+                                                );
+    }
+
+  } 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')
     my $total = {};
     my $item = 'Total';
     $item = $conf->config('previous_balance-exclude_from_total')
@@ -1065,7 +1188,7 @@ sub print_generic {
         $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
           $other_money_char.  sprintf('%.2f', $self->charged );
       } 
         $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
           $other_money_char.  sprintf('%.2f', $self->charged );
       } 
-    }else{
+    } else {
       push @total_items, $total;
     }
     push @buf,['','-----------'];
       push @total_items, $total;
     }
     push @buf,['','-----------'];
@@ -1074,127 +1197,134 @@ sub print_generic {
                sprintf( '%10.2f', $amount )
               ];
     push @buf,['',''];
                sprintf( '%10.2f', $amount )
               ];
     push @buf,['',''];
-  }
 
 
-  # if we're showing previous invoices, also show 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) ) {
+    # if we're showing previous invoices, also show 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( 'template' => $template, 'trim_len' => 60 )
+      ) {
+
+        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'},
+            product_code => '',
+            section      => $adjust_section,
+          };
+        } else {
+          push @total_items, $total;
+        }
 
 
-      my $total;
-      $total->{'total_item'} = &$escape_function($credit->{'description'});
-      $credittotal += $credit->{'amount'};
-      $total->{'total_amount'} = '-'. $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'},
-          product_code => '',
-          section      => $adjust_section,
-        };
-      } else {
-        push @total_items, $total;
       }
       }
+      $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
 
 
-    }
-    $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
-
-    #credits (again)
-    foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
-      push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
-    }
+      #credits (again)
+      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 ) {
-      my $total = {};
-      $total->{'total_item'} = &$escape_function($payment->{'description'});
-      $paymenttotal += $payment->{'amount'};
-      $total->{'total_amount'} = '-'. $other_money_char. $payment->{'amount'};
-      $adjusttotal += $payment->{'amount'};
+      # payments
+      my $paymenttotal = 0;
+      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'},
+            product_code => '',
+            section      => $adjust_section,
+          };
+        }else{
+          push @total_items, $total;
+        }
+        push @buf, [ $payment->{'description'},
+                     $money_char. sprintf("%10.2f", $payment->{'amount'}),
+                   ];
+      }
+      $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
+    
       if ( $multisection ) {
       if ( $multisection ) {
-        my $money = $old_latex ? '' : $money_char;
-        push @detail_items, {
-          ext_description => [],
-          ref          => '',
-          quantity     => '',
-          description  => &$escape_function($payment->{'description'}),
-          amount       => $money. $payment->{'amount'},
-          product_code => '',
-          section      => $adjust_section,
-        };
-      }else{
-        push @total_items, $total;
+        $adjust_section->{'subtotal'} = $other_money_char.
+                                        sprintf('%.2f', $adjusttotal);
+        push @sections, $adjust_section
+          unless $adjust_section->{sort_weight};
       }
       }
-      push @buf, [ $payment->{'description'},
-                   $money_char. sprintf("%10.2f", $payment->{'amount'}),
-                 ];
-    }
-    $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
-  
-    if ( $multisection ) {
-      $adjust_section->{'subtotal'} = $other_money_char.
-                                      sprintf('%.2f', $adjusttotal);
-      push @sections, $adjust_section
-        unless $adjust_section->{sort_weight};
-    }
 
 
-    # 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 
-                                             #  ? $self->charged +
-                                             #    $self->billing_balance
-                                             #  :
-                                                 $self->owed + $pr_total
-                                    )
-        );
-      if ( $multisection && !$adjust_section->{sort_weight} ) {
-        $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
-                                         $total->{'total_amount'};
-      }else{
-        push @total_items, $total;
+      # 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 
+                                               #  ? $self->charged +
+                                               #    $self->billing_balance
+                                               #  :
+                                                   $self->owed + $pr_total
+                                      )
+          );
+        if ( $multisection && !$adjust_section->{sort_weight} ) {
+          $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
+                                           $total->{'total_amount'};
+        }else{
+          push @total_items, $total;
+        }
+        push @buf,['','-----------'];
+        push @buf,[$self->balance_due_msg, $money_char. 
+          sprintf("%10.2f", $balance_due ) ];
       }
       }
-      push @buf,['','-----------'];
-      push @buf,[$self->balance_due_msg, $money_char. 
-        sprintf("%10.2f", $balance_due ) ];
-    }
 
 
-    if ( $conf->exists('previous_balance-show_credit')
-        and $cust_main->balance < 0 ) {
-      my $credit_total = {
-        'total_item'    => &$embolden_function($self->credit_balance_msg),
-        'total_amount'  => &$embolden_function(
-          $other_money_char. sprintf('%.2f', -$cust_main->balance)
-        ),
-      };
-      if ( $multisection ) {
-        $adjust_section->{'posttotal'} .= $newline_token .
-          $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
-      }
-      else {
-        push @total_items, $credit_total;
+      if ( $conf->exists('previous_balance-show_credit')
+          and $cust_main->balance < 0 ) {
+        my $credit_total = {
+          'total_item'    => &$embolden_function($self->credit_balance_msg),
+          'total_amount'  => &$embolden_function(
+            $other_money_char. sprintf('%.2f', -$cust_main->balance)
+          ),
+        };
+        if ( $multisection ) {
+          $adjust_section->{'posttotal'} .= $newline_token .
+            $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
+        }
+        else {
+          push @total_items, $credit_total;
+        }
+        push @buf,['','-----------'];
+        push @buf,[$self->credit_balance_msg, $money_char. 
+          sprintf("%10.2f", -$cust_main->balance ) ];
       }
       }
-      push @buf,['','-----------'];
-      push @buf,[$self->credit_balance_msg, $money_char. 
-        sprintf("%10.2f", -$cust_main->balance ) ];
     }
     }
-  }
+
+  } #end of default total adding ! can('_items_total')
 
   if ( $multisection ) {
     if (    $conf->exists('svc_phone_sections')
 
   if ( $multisection ) {
     if (    $conf->exists('svc_phone_sections')
@@ -1235,6 +1365,25 @@ sub print_generic {
     } } @discounts_avail;
   }
 
     } } @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;
+    }
+    push @summary_subtotals, $s;
+  }
+  $invoice_data{summary_subtotals} = \@summary_subtotals;
+
   # debugging hook: call this with 'diag' => 1 to just get a hash of 
   # the invoice variables
   return \%invoice_data if ( $params{'diag'} );
   # debugging hook: call this with 'diag' => 1 to just get a hash of 
   # the invoice variables
   return \%invoice_data if ( $params{'diag'} );
@@ -1432,12 +1581,9 @@ sub print_html {
   my %params;
   if ( ref($_[0]) ) {
     %params = %{ shift() }; 
   my %params;
   if ( ref($_[0]) ) {
     %params = %{ shift() }; 
-  }else{
-    $params{'time'} = shift;
-    $params{'template'} = shift;
-    $params{'cid'} = shift;
+  } else {
+    %params = @_;
   }
   }
-
   $params{'format'} = 'html';
   
   $self->print_generic( %params );
   $params{'format'} = 'html';
   
   $self->print_generic( %params );
@@ -1560,7 +1706,7 @@ sub due_date {
 
 sub due_date2str {
   my $self = shift;
 
 sub due_date2str {
   my $self = shift;
-  $self->due_date ? time2str(shift, $self->due_date) : '';
+  $self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
 }
 
 sub balance_due_msg {
 }
 
 sub balance_due_msg {
@@ -1569,7 +1715,7 @@ sub balance_due_msg {
   return $msg unless $self->terms;
   if ( $self->due_date ) {
     $msg .= ' - ' . $self->mt('Please pay by'). ' '.
   return $msg unless $self->terms;
   if ( $self->due_date ) {
     $msg .= ' - ' . $self->mt('Please pay by'). ' '.
-      $self->due_date2str($date_format);
+      $self->due_date2str('short');
   } elsif ( $self->terms ) {
     $msg .= ' - '. $self->terms;
   }
   } elsif ( $self->terms ) {
     $msg .= ' - '. $self->terms;
   }
@@ -1582,7 +1728,7 @@ sub balance_due_date {
   my $duedate = '';
   if (    $conf->exists('invoice_default_terms') 
        && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
   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) );
+    $duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) );
   }
   $duedate;
 }
   }
   $duedate;
 }
@@ -1600,10 +1746,10 @@ Returns a string with the date, for example: "3/20/2008"
 
 sub _date_pretty {
   my $self = shift;
 
 sub _date_pretty {
   my $self = shift;
-  time2str($date_format, $self->_date);
+  $self->time2str_local('short', $self->_date);
 }
 
 }
 
-=item _items_sections LATE SUMMARYPAGE ESCAPE EXTRA_SECTIONS FORMAT
+=item _items_sections OPTIONS
 
 Generate section information for all items appearing on this invoice.
 This will only be called for multi-section invoices.
 
 Generate section information for all items appearing on this invoice.
 This will only be called for multi-section invoices.
@@ -1627,25 +1773,37 @@ 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.
 
 returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
 coderefs to generate parts of the invoice.  This is not advised.
 
-Arguments:
+The method returns two arrayrefs, one of "early" sections and one of "late"
+sections.
+
+OPTIONS may include:
 
 
-LATE: an arrayref to push the "late" section hashes onto.  The "early"
-group is simply returned from the method.
+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 
+fields to create a suitable label.
 
 
-SUMMARYPAGE: a flag indicating whether this is a summary-format invoice.
+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 
+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.
 Turning this on has the following effects:
 - Ignores display items with the 'summary' flag.
-- Combines all items into the "early" group.
+- Places all sections in the "early" group even if they have post_total.
 - 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.
 
 - 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.
+escape: an escape function to use for section titles.
 
 
-EXTRA_SECTIONS: an arrayref of additional sections to return after the 
+extra_sections: an arrayref of additional sections to return after the 
 sorted list.  If there are any of these, section subtotals exclude 
 usage charges.
 
 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
 passed through to C<_condense_section()>.
 
 =cut
@@ -1653,28 +1811,58 @@ passed through to C<_condense_section()>.
 use vars qw(%pkg_category_cache);
 sub _items_sections {
   my $self = shift;
 use vars qw(%pkg_category_cache);
 sub _items_sections {
   my $self = shift;
-  my $late = shift;
-  my $summarypage = shift;
-  my $escape = shift;
-  my $extra_sections = shift;
-  my $format = shift;
+  my %opt = @_;
+  
+  my $escape = $opt{escape};
+  my @extra_sections = @{ $opt{extra_sections} || [] };
 
 
+  # $subtotal{$locationnum}{$categoryname} = amount.
+  # if we're not using by_location, $locationnum is undef.
+  # if we're not using by_category, you guessed it, $categoryname is undef.
+  # if we're not using either one, we shouldn't be here in the first place...
   my %subtotal = ();
   my %late_subtotal = ();
   my %not_tax = ();
 
   my %subtotal = ();
   my %late_subtotal = ();
   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 
+  # 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 
+  # display record is to create a subtotal for the summary page.
+
+  # cache these
+  my $pkg_hash = $self->cust_pkg_hash;
+
   foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
   {
 
       my $usage = $cust_bill_pkg->usage;
 
   foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
   {
 
       my $usage = $cust_bill_pkg->usage;
 
+      my $locationnum;
+      if ( $opt{by_location} ) {
+        if ( $cust_bill_pkg->pkgnum ) {
+          $locationnum = $pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum;
+        } else {
+          $locationnum = '';
+        }
+      } else {
+        $locationnum = undef;
+      }
+
+      # as in _items_cust_pkg, if a line item has no display records,
+      # cust_bill_pkg_display() returns a default record for it
+
       foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
       foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
-        next if ( $display->summary && $summarypage );
+        next if ( $display->summary && $opt{summary} );
 
         my $section = $display->section;
         my $type    = $display->type;
 
         my $section = $display->section;
         my $type    = $display->type;
+        $section = undef unless $opt{by_category};
 
 
-        $not_tax{$section} = 1
+        $not_tax{$locationnum}{$section} = 1
           unless $cust_bill_pkg->pkgnum == 0;
 
         # there's actually a very important piece of logic buried in here:
           unless $cust_bill_pkg->pkgnum == 0;
 
         # there's actually a very important piece of logic buried in here:
@@ -1684,55 +1872,56 @@ sub _items_sections {
         # When _items_cust_bill_pkg is called to generate line items for 
         # real, it will be called with 'section' => $section for each 
         # of these.
         # 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 && !$summarypage ) {
+        if ( $display->post_total && !$opt{summary} ) {
           if (! $type || $type eq 'S') {
           if (! $type || $type eq 'S') {
-            $late_subtotal{$section} += $cust_bill_pkg->setup
+            $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
               if $cust_bill_pkg->setup != 0
               || $cust_bill_pkg->setup_show_zero;
           }
 
           if (! $type) {
               if $cust_bill_pkg->setup != 0
               || $cust_bill_pkg->setup_show_zero;
           }
 
           if (! $type) {
-            $late_subtotal{$section} += $cust_bill_pkg->recur
+            $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
           }
 
           if ($type && $type eq 'R') {
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
           }
 
           if ($type && $type eq 'R') {
-            $late_subtotal{$section} += $cust_bill_pkg->recur - $usage
+            $late_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') {
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
           }
           
           if ($type && $type eq 'U') {
-            $late_subtotal{$section} += $usage
-              unless scalar(@$extra_sections);
+            $late_subtotal{$locationnum}{$section} += $usage
+              unless scalar(@extra_sections);
           }
 
           }
 
-        } else {
+        } 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;
 
           if (! $type || $type eq 'S') {
           next if $cust_bill_pkg->pkgnum == 0 && ! $section;
 
           if (! $type || $type eq 'S') {
-            $subtotal{$section} += $cust_bill_pkg->setup
+            $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
               if $cust_bill_pkg->setup != 0
               || $cust_bill_pkg->setup_show_zero;
           }
 
           if (! $type) {
               if $cust_bill_pkg->setup != 0
               || $cust_bill_pkg->setup_show_zero;
           }
 
           if (! $type) {
-            $subtotal{$section} += $cust_bill_pkg->recur
+            $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
           }
 
           if ($type && $type eq 'R') {
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
           }
 
           if ($type && $type eq 'R') {
-            $subtotal{$section} += $cust_bill_pkg->recur - $usage
+            $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') {
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
           }
           
           if ($type && $type eq 'U') {
-            $subtotal{$section} += $usage
-              unless scalar(@$extra_sections);
+            $subtotal{$locationnum}{$section} += $usage
+              unless scalar(@extra_sections);
           }
 
         }
           }
 
         }
@@ -1743,53 +1932,80 @@ sub _items_sections {
 
   %pkg_category_cache = ();
 
 
   %pkg_category_cache = ();
 
-  push @$late, map { { 'description' => &{$escape}($_),
-                       'subtotal'    => $late_subtotal{$_},
-                       'post_total'  => 1,
-                       'sort_weight' => ( _pkg_category($_)
-                                            ? _pkg_category($_)->weight
-                                            : 0
-                                       ),
-                       ((_pkg_category($_) && _pkg_category($_)->condense)
-                                           ? $self->_condense_section($format)
-                                           : ()
-                       ),
-                   } }
-                 sort _sectionsort keys %late_subtotal;
-
-  my @sections;
-  if ( $summarypage ) {
-    @sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled }
-                map { $_->categoryname } qsearch('pkg_category', {});
-    push @sections, '' if exists($subtotal{''});
-  } else {
-    @sections = keys %subtotal;
+  # summary invoices need subtotals for all non-disabled package categories,
+  # even if they're zero
+  # but currently assume that there are no location sections, or at least
+  # that the summary page doesn't care about them
+  if ( $opt{summary} ) {
+    foreach my $category (qsearch('pkg_category', {disabled => ''})) {
+      $subtotal{''}{$category->categoryname} ||= 0;
+    }
+    $subtotal{''}{''} ||= 0;
   }
 
   }
 
-  my @early = map { { 'description' => &{$escape}($_),
-                      'subtotal'    => $subtotal{$_},
-                      'summarized'  => $not_tax{$_} ? '' : 'Y',
-                      'tax_section' => $not_tax{$_} ? '' : 'Y',
-                      'sort_weight' => ( _pkg_category($_)
-                                           ? _pkg_category($_)->weight
-                                           : 0
-                                       ),
-                       ((_pkg_category($_) && _pkg_category($_)->condense)
-                                           ? $self->_condense_section($format)
-                                           : ()
-                       ),
-                    }
-                  } @sections;
-  push @early, @$extra_sections if $extra_sections;
-
-  sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
-
+  my @sections;
+  foreach my $post_total (0,1) {
+    my @these;
+    my $s = $post_total ? \%late_subtotal : \%subtotal;
+    foreach my $locationnum (keys %$s) {
+      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, 
+          # 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 
+          # randomly from one invoice to the next, which will confuse
+          # people.
+          $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
+                                      $locationnum;
+          $section->{'location'} = {
+            map { $_ => &{ $escape }($location->get($_)) }
+            $location->fields
+          };
+        } else {
+          $section->{'category'} = $sectionname;
+          $section->{'description'} = &{ $escape }($sectionname);
+          if ( _pkg_category($_) ) {
+            $section->{'sort_weight'} = _pkg_category($_)->weight;
+            if ( _pkg_category($_)->condense ) {
+              $section = { %$section, $self->_condense_section($opt{format}) };
+            }
+          }
+        }
+        if ( !$post_total and !$not_tax{$locationnum}{$sectionname} ) {
+          # then it's a tax-only section
+          $section->{'summarized'} = 'Y';
+          $section->{'tax_section'} = 'Y';
+        }
+        push @these, $section;
+      } # foreach $sectionname
+    } #foreach $locationnum
+    push @these, @extra_sections if $post_total == 0;
+    # need an alpha sort for location sections, because postal codes can 
+    # be non-numeric
+    $sections[ $post_total ] = [ sort {
+      $opt{'by_location'} ? 
+        ($a->{sort_weight} cmp $b->{sort_weight}) :
+        ($a->{sort_weight} <=> $b->{sort_weight})
+      } @these ];
+  } #foreach $post_total
+
+  return @sections; # early, late
 }
 
 #helper subs for above
 
 }
 
 #helper subs for above
 
-sub _sectionsort {
-  _pkg_category($a)->weight <=> _pkg_category($b)->weight;
+sub cust_pkg_hash {
+  my $self = shift;
+  $self->{cust_pkg} ||= { map { $_->pkgnum => $_ } $self->cust_pkg };
 }
 
 sub _pkg_category {
 }
 
 sub _pkg_category {
@@ -2019,23 +2235,6 @@ sub _condensed_total_line_generator {
 
 }
 
 
 }
 
-#  sub _items { # seems to be unused
-#    my $self = shift;
-#  
-#    #my @display = scalar(@_)
-#    #              ? @_
-#    #              : qw( _items_previous _items_pkg );
-#    #              #: qw( _items_pkg );
-#    #              #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
-#    my @display = qw( _items_previous _items_pkg );
-#  
-#    my @b = ();
-#    foreach my $display ( @display ) {
-#      push @b, $self->$display(@_);
-#    }
-#    @b;
-#  }
-
 =item _items_pkg [ OPTIONS ]
 
 Return line item hashes for each package item on this invoice. Nearly 
 =item _items_pkg [ OPTIONS ]
 
 Return line item hashes for each package item on this invoice. Nearly 
@@ -2112,7 +2311,17 @@ sub _taxsort {
 sub _items_tax {
   my $self = shift;
   my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
 sub _items_tax {
   my $self = shift;
   my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
-  $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
+  my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
+
+  if ( $self->conf->exists('always_show_tax') ) {
+    my $itemdesc = $self->conf->config('always_show_tax') || 'Tax';
+    if (0 == grep { $_->{description} eq $itemdesc } @items) {
+      push @items,
+        { 'description' => $itemdesc,
+          'amount'      => 0.00 };
+    }
+  }
+  @items;
 }
 
 =item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
 }
 
 =item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
@@ -2129,9 +2338,9 @@ escape_function: the function used to escape strings.
 DEPRECATED? (expensive, mostly unused?)
 format_function: the function used to format CDRs.
 
 DEPRECATED? (expensive, mostly unused?)
 format_function: the function used to format CDRs.
 
-section: a hashref containing 'description'; if this is present, 
-cust_bill_pkg_display records not belonging to this section are 
-ignored.
+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.
 
 multisection: a flag indicating that this is a multisection invoice,
 which does something complicated.
@@ -2155,9 +2364,13 @@ sub _items_cust_bill_pkg {
   my $format_function = $opt{format_function} || '';
   my $no_usage = $opt{no_usage} || '';
   my $unsquelched = $opt{unsquelched} || ''; #unused
   my $format_function = $opt{format_function} || '';
   my $no_usage = $opt{no_usage} || '';
   my $unsquelched = $opt{unsquelched} || ''; #unused
-  my $section = $opt{section}->{description} if $opt{section};
+  my ($section, $locationnum, $category);
+  if ( $opt{section} ) {
+    $category = $opt{section}->{category};
+    $locationnum = $opt{section}->{locationnum};
+  }
   my $summary_page = $opt{summary_page} || ''; #unused
   my $summary_page = $opt{summary_page} || ''; #unused
-  my $multisection = $opt{multisection} || '';
+  my $multisection = defined($category) || defined($locationnum);
   my $discount_show_always = 0;
 
   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
   my $discount_show_always = 0;
 
   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
@@ -2185,6 +2398,18 @@ 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 
+              != $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 
+    # 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
                                   : ( $cust_bill_pkg );
     my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
                                   ? $cust_bill_pkg->cust_bill_pkg_display
                                   : ( $cust_bill_pkg );
@@ -2193,14 +2418,19 @@ sub _items_cust_bill_pkg {
          $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
       if $DEBUG > 1;
 
          $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
       if $DEBUG > 1;
 
-    foreach my $display ( grep { defined($section)
-                            ? $_->section eq $section
-                            : 1
-                          }
-                          grep { !$_->summary || $multisection }
-                          @cust_bill_pkg_display
-                        )
-      {
+    if ( defined($category) ) {
+      # then this is a package category section; process all display records
+      # that belong to this section.
+      @cust_bill_pkg_display = grep { $_->section eq $category }
+                                @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 
+      # category sections, but this is the historical behavior)
+      @cust_bill_pkg_display = grep { !$_->summary }
+                                @cust_bill_pkg_display;
+    }
+    foreach my $display (@cust_bill_pkg_display) {
 
       warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
            $display->billpkgdisplaynum. "\n"
 
       warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
            $display->billpkgdisplaynum. "\n"
@@ -2208,7 +2438,7 @@ sub _items_cust_bill_pkg {
 
       my $type = $display->type;
 
 
       my $type = $display->type;
 
-      my $desc = $cust_bill_pkg->desc;
+      my $desc = $cust_bill_pkg->desc( $cust_main ? $cust_main->locale : '' );
       $desc = substr($desc, 0, $maxlength). '...'
         if $format eq 'latex' && length($desc) > $maxlength;
 
       $desc = substr($desc, 0, $maxlength). '...'
         if $format eq 'latex' && length($desc) > $maxlength;
 
@@ -2222,6 +2452,8 @@ sub _items_cust_bill_pkg {
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
           if $DEBUG > 1;
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
           if $DEBUG > 1;
+        # quotation_pkgs are never fees, so don't worry about the case where
+        # part_pkg is undefined
 
         if ( $cust_bill_pkg->setup != 0 ) {
           my $description = $desc;
 
         if ( $cust_bill_pkg->setup != 0 ) {
           my $description = $desc;
@@ -2241,12 +2473,13 @@ sub _items_cust_bill_pkg {
           };
         }
 
           };
         }
 
-      } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
+      } elsif ( $cust_bill_pkg->pkgnum > 0 ) { # and it's not a quotation_pkg
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
           if $DEBUG > 1;
  
         my $cust_pkg = $cust_bill_pkg->cust_pkg;
 
         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;
 
         # which pkgpart to show for display purposes?
         my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
@@ -2255,7 +2488,7 @@ sub _items_cust_bill_pkg {
         # things with them
         my %item_dates = ();
         %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
         # 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);
 
         if (    (!$type || $type eq 'S')
              && (    $cust_bill_pkg->setup != 0
 
         if (    (!$type || $type eq 'S')
              && (    $cust_bill_pkg->setup != 0
@@ -2273,6 +2506,14 @@ sub _items_cust_bill_pkg {
             || $discount_show_always
             || $cust_bill_pkg->recur_show_zero;
 
             || $discount_show_always
             || $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 @d = ();
           my $svc_label;
           unless ( $cust_pkg->part_pkg->hide_svc_detail
@@ -2285,8 +2526,11 @@ sub _items_cust_bill_pkg {
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
             $svc_label = $svc_labels[0];
 
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
             $svc_label = $svc_labels[0];
 
-            if ( ! $cust_pkg->locationnum or
-                   $cust_pkg->locationnum != $cust_main->ship_locationnum  ) {
+            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) ) {
               my $loc = $cust_pkg->location_label;
               $loc = substr($loc, 0, $maxlength). '...'
                 if $format eq 'latex' && length($loc) > $maxlength;
               my $loc = $cust_pkg->location_label;
               $loc = substr($loc, 0, $maxlength). '...'
                 if $format eq 'latex' && length($loc) > $maxlength;
@@ -2333,42 +2577,16 @@ sub _items_cust_bill_pkg {
             if $DEBUG > 1;
 
           my $is_summary = $display->summary;
             if $DEBUG > 1;
 
           my $is_summary = $display->summary;
-          my $description = ($is_summary && $type && $type eq 'U')
-                            ? "Usage charges" : $desc;
+          my $description = $desc;
+          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;
 
 
           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',
-                                         $cust_main->agentnum
-                                       )
-              if $part_pkg && $part_pkg->freq !~ /^1m?$/;
-            $date_style ||= $conf->config( 'cust_bill-line_item-date_style',
-                                            $cust_main->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',
-                                         $cust_main->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 @d = ();
           my @seconds = (); # for display of usage info
@@ -2381,10 +2599,19 @@ sub _items_cust_bill_pkg {
           push @dates, $prev->sdate if $prev;
           push @dates, undef if !$prev;
 
           push @dates, $prev->sdate if $prev;
           push @dates, undef if !$prev;
 
+          # show service labels, unless...
+                    # the package is set not to display them
           unless ( $part_pkg->hide_svc_detail
           unless ( $part_pkg->hide_svc_detail
+                    # or this is a tax-like line item
                 || $cust_bill_pkg->itemdesc
                 || $cust_bill_pkg->itemdesc
+                    # or this is a hidden (bundled) line item
                 || $cust_bill_pkg->hidden
                 || $cust_bill_pkg->hidden
+                    # or this is a usage summary line
                 || $is_summary && $type && $type eq 'U'
                 || $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))
               )
           {
 
               )
           {
 
@@ -2392,7 +2619,7 @@ sub _items_cust_bill_pkg {
               if $DEBUG > 1;
 
             my @svc_labels = map &{$escape_function}($_),
               if $DEBUG > 1;
 
             my @svc_labels = map &{$escape_function}($_),
-                        $cust_pkg->h_labels_short($self->_date, undef, 'I');
+                        $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];
             push @d, @svc_labels
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
             $svc_label = $svc_labels[0];
@@ -2400,7 +2627,11 @@ sub _items_cust_bill_pkg {
             warn "$me _items_cust_bill_pkg done adding service details\n"
               if $DEBUG > 1;
 
             warn "$me _items_cust_bill_pkg done adding service details\n"
               if $DEBUG > 1;
 
-            if ( $cust_pkg->locationnum != $cust_main->ship_locationnum ) {
+            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) ) {
               my $loc = $cust_pkg->location_label;
               $loc = substr($loc, 0, $maxlength). '...'
                 if $format eq 'latex' && length($loc) > $maxlength;
               my $loc = $cust_pkg->location_label;
               $loc = substr($loc, 0, $maxlength). '...'
                 if $format eq 'latex' && length($loc) > $maxlength;
@@ -2428,7 +2659,7 @@ sub _items_cust_bill_pkg {
               }
             } #if svc_acct-usage_seconds
 
               }
             } #if svc_acct-usage_seconds
 
-          }
+          } # if we are showing service labels
 
           unless ( $is_summary ) {
             warn "$me _items_cust_bill_pkg adding details\n"
 
           unless ( $is_summary ) {
             warn "$me _items_cust_bill_pkg adding details\n"
@@ -2454,15 +2685,15 @@ sub _items_cust_bill_pkg {
             $amount = $cust_bill_pkg->usage;
           }
   
             $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;
 
           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;
             if ( $cust_bill_pkg->hidden ) {
               $r->{amount}      += $amount;
               $r->{unit_amount} += $unit_amount;
@@ -2488,50 +2719,43 @@ sub _items_cust_bill_pkg {
             warn "$me _items_cust_bill_pkg adding usage\n"
               if $DEBUG > 1;
 
             warn "$me _items_cust_bill_pkg adding usage\n"
               if $DEBUG > 1;
 
-            if ( $cust_bill_pkg->hidden ) {
+            if ( $cust_bill_pkg->hidden and defined($u) ) {
+              # if this is a hidden package and there's already a usage
+              # line for the bundle, add this package's total amount and
+              # usage details to it
               $u->{amount}      += $amount;
               $u->{amount}      += $amount;
-              $u->{unit_amount} += $unit_amount,
               push @{ $u->{ext_description} }, @d;
               push @{ $u->{ext_description} }, @d;
-            } else {
+            } elsif ( $amount ) {
+              # create a new usage line
               $u = {
                 description     => $description,
                 pkgpart         => $pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
                 amount          => $amount,
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
               $u = {
                 description     => $description,
                 pkgpart         => $pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
                 amount          => $amount,
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
-                unit_amount     => $unit_amount,
-                quantity        => $cust_bill_pkg->quantity,
                 %item_dates,
                 ext_description => \@d,
               };
                 %item_dates,
                 ext_description => \@d,
               };
-            }
+            } # else this has no usage, so don't create a usage section
           }
 
         } # recurring or usage with recurring charge
 
           }
 
         } # 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;
 
 
         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 quotation / package line item / other line item
 
 
-    }
+    } # foreach $display
 
     $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
                                 && $conf->exists('discount-show-always'));
 
     $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
                                 && $conf->exists('discount-show-always'));
@@ -2541,8 +2765,11 @@ sub _items_cust_bill_pkg {
   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
     if ( $_  ) {
       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
     if ( $_  ) {
       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+        if exists($_->{amount});
       $_->{amount}      =~ s/^\-0\.00$/0.00/;
       $_->{amount}      =~ s/^\-0\.00$/0.00/;
-      $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+      $_->{unit_amount} = sprintf('%.2f', $_->{unit_amount})
+        if exists($_->{unit_amount});
+
       push @b, { %$_ }
         if $_->{amount} != 0
         || $discount_show_always
       push @b, { %$_ }
         if $_->{amount} != 0
         || $discount_show_always