optionally display payments/credits on invoice based on date received, #24850
[freeside.git] / FS / FS / Template_Mixin.pm
index e597e73..f55fc66 100644 (file)
@@ -608,23 +608,12 @@ sub print_generic {
   # summary formats
   $invoice_data{'last_bill'} = {};
 
-  # returns the last unpaid bill, not the last bill
-  #my $last_bill = $pr_cust_bill[-1];
-
   if ( $self->custnum && $self->invnum ) {
 
-    # THIS returns the customer's last bill before  this one
-    my $last_bill = qsearchs({
-        'table'   => 'cust_bill',
-        'hashref' => { 'custnum' => $self->custnum,
-                       'invnum'  => { op => '<', value => $self->invnum },
-                     },
-        'order_by'  => ' ORDER BY invnum DESC LIMIT 1'
-    });
-    if ( $last_bill ) {
+    if ( $self->previous_bill ) {
+      my $last_bill = $self->previous_bill;
       $invoice_data{'last_bill'} = {
         '_date'     => $last_bill->_date, #unformatted
-        # all we need for now
       };
       my (@payments, @credits);
       # for formats that itemize previous payments
@@ -763,19 +752,6 @@ sub print_generic {
   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
@@ -786,7 +762,6 @@ sub print_generic {
   $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'),
@@ -800,17 +775,45 @@ sub print_generic {
   $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);
+  my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) ||
+                     $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
   $invoice_data{'multisection'} = $multisection;
-  my $late_sections = [];
+  my $late_sections;
   my $extra_sections = [];
   my $extra_lines = ();
 
+  # default section ('Charges')
   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)
@@ -820,13 +823,24 @@ sub print_generic {
     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')
        )
@@ -852,24 +866,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.
-    # XXX possibly other totals?
+    # and the default section total
     # 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
+  # (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') ) {
@@ -880,22 +899,18 @@ sub print_generic {
     foreach my $line_item ( $self->_items_previous ) {
 
       my $detail = {
-        ext_description => [],
+        ref             => $line_item->{'pkgnum'},
+        pkgpart         => $line_item->{'pkgpart'},
+        quantity        => 1,
+        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'},
@@ -966,7 +981,6 @@ sub print_generic {
     $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;
@@ -1023,12 +1037,9 @@ sub print_generic {
   $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"
@@ -1145,7 +1156,7 @@ sub print_generic {
         $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
           $other_money_char.  sprintf('%.2f', $self->charged );
       } 
-    }else{
+    } else {
       push @total_items, $total;
     }
     push @buf,['','-----------'];
@@ -1316,6 +1327,25 @@ 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;
+    }
+    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'} );
@@ -1684,7 +1714,7 @@ sub _date_pretty {
   time2str($date_format, $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.
@@ -1708,25 +1738,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.
 
-Arguments:
+The method returns two arrayrefs, one of "early" sections and one of "late"
+sections.
 
-LATE: an arrayref to push the "late" section hashes onto.  The "early"
-group is simply returned from the method.
+OPTIONS may include:
 
-SUMMARYPAGE: a flag indicating whether this is a summary-format invoice.
+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.
+
+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.
-- 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.
 
-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.
 
-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
@@ -1734,28 +1776,58 @@ passed through to C<_condense_section()>.
 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 = ();
 
+  # 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;
 
+      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) {
-        next if ( $display->summary && $summarypage );
+        next if ( $display->summary && $opt{summary} );
 
         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:
@@ -1765,55 +1837,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.
-        if ( $display->post_total && !$summarypage ) {
+        if ( $display->post_total && !$opt{summary} ) {
           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) {
-            $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') {
-            $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') {
-            $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') {
-            $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) {
-            $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') {
-            $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') {
-            $subtotal{$section} += $usage
-              unless scalar(@$extra_sections);
+            $subtotal{$locationnum}{$section} += $usage
+              unless scalar(@extra_sections);
           }
 
         }
@@ -1824,53 +1897,80 @@ sub _items_sections {
 
   %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
 
-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 {
@@ -2100,23 +2200,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 
@@ -2220,9 +2303,9 @@ escape_function: the function used to escape strings.
 DEPRECATED? (expensive, mostly unused?)
 format_function: the function used to format CDRs.
 
-section: a hashref containing 'description'; if this is present, 
-cust_bill_pkg_display records not belonging to this section are 
-ignored.
+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.
@@ -2246,9 +2329,13 @@ sub _items_cust_bill_pkg {
   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 $multisection = $opt{multisection} || '';
+  my $multisection = defined($category) || defined($locationnum);
   my $discount_show_always = 0;
 
   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
@@ -2276,6 +2363,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 );
@@ -2284,14 +2383,19 @@ sub _items_cust_bill_pkg {
          $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
       if $DEBUG > 1;
 
-    foreach my $display ( grep { defined($section)
-                            ? $_->section eq $section
-                            : 1
-                          }
-                          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"
@@ -2378,7 +2482,9 @@ sub _items_cust_bill_pkg {
 
             my $lnum = $cust_main ? $cust_main->ship_locationnum
                                   : $self->prospect_main->locationnum;
-            if ( ! $cust_pkg->locationnum or $cust_pkg->locationnum != $lnum ) {
+            # 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;
@@ -2425,8 +2531,10 @@ sub _items_cust_bill_pkg {
             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 ($is_summary or $cust_bill_pkg->hidden) ) {
+            $description = $self->mt('Usage charges');
+          }
 
           my $part_pkg = $cust_pkg->part_pkg;
 
@@ -2484,7 +2592,7 @@ sub _items_cust_bill_pkg {
               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];
@@ -2494,7 +2602,9 @@ sub _items_cust_bill_pkg {
 
             my $lnum = $cust_main ? $cust_main->ship_locationnum
                                   : $self->prospect_main->locationnum;
-            if ( $cust_pkg->locationnum != $lnum ) {
+            # 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;
@@ -2582,11 +2692,15 @@ sub _items_cust_bill_pkg {
             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->{unit_amount} += $unit_amount,
               push @{ $u->{ext_description} }, @d;
-            } else {
+            } elsif ( $amount ) {
+              # create a new usage line
               $u = {
                 description     => $description,
                 pkgpart         => $pkgpart,
@@ -2598,7 +2712,7 @@ sub _items_cust_bill_pkg {
                 %item_dates,
                 ext_description => \@d,
               };
-            }
+            } # else this has no usage, so don't create a usage section
           }
 
         } # recurring or usage with recurring charge