invoice sections by location, #23820
[freeside.git] / FS / FS / Template_Mixin.pm
index d35fd55..b5dbd34 100644 (file)
@@ -106,7 +106,7 @@ sub print_latex {
   $params{'time'} = $today if $today;
   $params{'template'} = $template if $template;
   $params{$_} = $opt{$_} 
-    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
+    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name no_date no_number );
 
   $template ||= $self->_agent_template
     if $self->can('_agent_template');
@@ -122,7 +122,7 @@ sub print_latex {
     UNLINK   => 0,
   ) or die "can't open temp file: $!\n";
 
-  my $agentnum = $self->cust_main->agentnum;
+  my $agentnum = $self->agentnum;
 
   if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
     print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
@@ -174,6 +174,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.
@@ -363,14 +369,6 @@ sub print_generic {
 
   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",
@@ -447,9 +445,15 @@ sub print_generic {
     '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,
-    'date'            => time2str($date_format, $self->_date),
+    'no_date'         => $params{'no_date'},
+    '_date'           => ( $params{'no_date'} ? '' : $self->_date ),
+    'date'            => ( $params{'no_date'}
+                             ? ''
+                             : time2str($date_format, $self->_date)
+                         ),
     'today'           => time2str($date_format_long, $today),
     'terms'           => $self->terms,
     'template'        => $template, #params{'template'},
@@ -584,27 +588,79 @@ sub print_generic {
   #my $balance_due = $self->owed + $pr_total - $cr_total;
   my $balance_due = $self->owed + $pr_total;
 
-  # the customer's current balance as shown on the invoice before this one
-  $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) );
+  #these are used on the summary page only
+
+    # the customer's current balance as shown on the invoice before this one
+    $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) );
 
-  # the change in balance from that invoice to this one
-  $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) );
+    # the change in balance from that invoice to this one
+    $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) );
 
-  # the sum of amount owed on all previous invoices
-  $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
+    # the sum of amount owed on all previous invoices
+    # ($pr_total is used elsewhere but not as $previous_balance)
+    $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
 
   # the sum of amount owed on all invoices
+  # (this is used in the summary & on the payment coupon)
   $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
 
   # 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
-    };
+
+  # 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 ) {
+      $invoice_data{'last_bill'} = {
+        '_date'     => $last_bill->_date, #unformatted
+        # all we need for now
+      };
+      my (@payments, @credits);
+      # for formats that itemize previous payments
+      foreach my $cust_pay ( qsearch('cust_pay', {
+                              'custnum' => $self->custnum,
+                              '_date'   => { op => '>=',
+                                             value => $last_bill->_date }
+                             } ) )
+      {
+        next if $cust_pay->_date > $self->_date;
+        push @payments, {
+            '_date'       => $cust_pay->_date,
+            'date'        => time2str($date_format, $cust_pay->_date),
+            'payinfo'     => $cust_pay->payby_payinfo_pretty,
+            'amount'      => sprintf('%.2f', $cust_pay->paid),
+        };
+        # not concerned about applications
+      }
+      foreach my $cust_credit ( qsearch('cust_credit', {
+                              'custnum' => $self->custnum,
+                              '_date'   => { op => '>=',
+                                             value => $last_bill->_date }
+                             } ) )
+      {
+        next if $cust_credit->_date > $self->_date;
+        push @credits, {
+            '_date'       => $cust_credit->_date,
+            'date'        => time2str($date_format, $cust_credit->_date),
+            'creditreason'=> $cust_credit->reason,
+            'amount'      => sprintf('%.2f', $cust_credit->amount),
+        };
+      }
+      $invoice_data{'previous_payments'} = \@payments;
+      $invoice_data{'previous_credits'}  = \@credits;
+    }
+
   }
 
   my $summarypage = '';
@@ -689,6 +745,11 @@ sub print_generic {
   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 = ();
@@ -727,10 +788,11 @@ sub print_generic {
 
 
   my $adjusttotal = 0;
-  my $adjust_section = { 'description' => 
-    $self->mt('Credits, Payments, and Adjustments'),
-                         'subtotal'    => 0,   # adjusted below
-                       };
+  my $adjust_section = {
+    'description'    => $self->mt('Credits, Payments, and Adjustments'),
+    'adjust_section' => 1,
+    'subtotal'       => 0,   # adjusted below
+  };
   my $adjust_weight = _pkg_category($adjust_section->{description})
                         ? _pkg_category($adjust_section->{description})->weight
                         : 0;
@@ -738,9 +800,10 @@ sub print_generic {
   $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;
-  my $late_sections = [];
+  my $late_sections;
   my $extra_sections = [];
   my $extra_lines = ();
 
@@ -758,13 +821,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')
        )
@@ -821,6 +895,7 @@ sub print_generic {
         ext_description => [],
       };
       $detail->{'ref'} = $line_item->{'pkgnum'};
+      $detail->{'pkgpart'} = $line_item->{'pkgpart'};
       $detail->{'quantity'} = 1;
       $detail->{'section'} = $multisection ? $previous_section
                                            : $default_section;
@@ -903,7 +978,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;
@@ -917,6 +991,7 @@ sub print_generic {
         ext_description => [],
       };
       $detail->{'ref'} = $line_item->{'pkgnum'};
+      $detail->{'pkgpart'} = $line_item->{'pkgpart'};
       $detail->{'quantity'} = $line_item->{'quantity'};
       $detail->{'section'} = $section;
       $detail->{'description'} = &$escape_function($line_item->{'description'});
@@ -934,6 +1009,7 @@ sub print_generic {
       $detail->{'sdate'} = $line_item->{'sdate'};
       $detail->{'edate'} = $line_item->{'edate'};
       $detail->{'seconds'} = $line_item->{'seconds'};
+      $detail->{'svc_label'} = $line_item->{'svc_label'};
   
       push @detail_items, $detail;
       push @buf, ( [ $detail->{'description'},
@@ -969,7 +1045,8 @@ sub print_generic {
   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'};
 
@@ -1004,7 +1081,7 @@ sub print_generic {
 
   }
   
-  if ( $taxtotal ) {
+  if ( @items_tax ) {
     my $total = {};
     $total->{'total_item'} = $self->mt('Sub-total');
     $total->{'total_amount'} =
@@ -1031,9 +1108,33 @@ sub print_generic {
              $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')
@@ -1064,126 +1165,128 @@ sub print_generic {
                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('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('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 ) {
+        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 ) {
-        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', $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')
@@ -1224,6 +1327,10 @@ sub print_generic {
     } } @discounts_avail;
   }
 
+  # debugging hook: call this with 'diag' => 1 to just get a hash of 
+  # the invoice variables
+  return \%invoice_data if ( $params{'diag'} );
+
   # All sections and items are built; now fill in templates.
   my @includelist = ();
   push @includelist, 'summary' if $summarypage;
@@ -1588,7 +1695,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.
@@ -1612,25 +1719,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.
+
+OPTIONS may include:
+
+by_location: a flag to divide the invoice into sections by location.  
+Each section hash will have a 'location' element containing a hashref of 
+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.
 
-LATE: an arrayref to push the "late" section hashes onto.  The "early"
-group is simply returned from the method.
+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).
 
-SUMMARYPAGE: a flag indicating whether this is a summary-format invoice.
+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
@@ -1638,79 +1757,117 @@ 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;
 
-        if ( $display->post_total && !$summarypage ) {
+        # there's actually a very important piece of logic buried in here:
+        # incrementing $late_subtotal{$section} CREATES 
+        # $late_subtotal{$section}.  keys(%late_subtotal) is later used 
+        # to define the list of late sections, and likewise keys(%subtotal).
+        # When _items_cust_bill_pkg is called to generate line items for 
+        # real, it will be called with 'section' => $section for each 
+        # of these.
+        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);
           }
 
         }
@@ -1721,53 +1878,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 {
@@ -1997,23 +2181,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 
@@ -2029,6 +2196,11 @@ separate quantities, for some reason).
 
 =cut
 
+sub _items_nontax {
+  my $self = shift;
+  grep { $_->pkgnum } $self->cust_bill_pkg;
+}
+
 sub _items_pkg {
   my $self = shift;
   my %options = @_;
@@ -2036,7 +2208,7 @@ sub _items_pkg {
   warn "$me _items_pkg searching for all package line items\n"
     if $DEBUG > 1;
 
-  my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
+  my @cust_bill_pkg = $self->_items_nontax;
 
   warn "$me _items_pkg filtering line items\n"
     if $DEBUG > 1;
@@ -2085,7 +2257,17 @@ sub _taxsort {
 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
@@ -2102,16 +2284,16 @@ 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.
 
 Returns a list of hashrefs, each of which may contain:
 
-pkgnum, description, amount, unit_amount, quantity, _is_setup, and 
+pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
 ext_description, which is an arrayref of detail lines to show below 
 the package line.
 
@@ -2128,9 +2310,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;
@@ -2158,6 +2344,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 );
@@ -2166,15 +2364,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 || !$summary_page } # bunk!
-                          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"
@@ -2182,7 +2384,7 @@ sub _items_cust_bill_pkg {
 
       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;
 
@@ -2222,9 +2424,14 @@ sub _items_cust_bill_pkg {
  
         my $cust_pkg = $cust_bill_pkg->cust_pkg;
 
+        # which pkgpart to show for display purposes?
+        my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
+
         # start/end dates for invoice formats that do nonstandard 
         # things with them
-        my %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate');
+        my %item_dates = ();
+        %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
+          unless $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
 
         if (    (!$type || $type eq 'S')
              && (    $cust_bill_pkg->setup != 0
@@ -2243,15 +2450,22 @@ sub _items_cust_bill_pkg {
             || $cust_bill_pkg->recur_show_zero;
 
           my @d = ();
+          my $svc_label;
           unless ( $cust_pkg->part_pkg->hide_svc_detail
                 || $cust_bill_pkg->hidden )
           {
 
-            push @d, map &{$escape_function}($_),
-                         $cust_pkg->h_labels_short($self->_date, undef, 'I')
+            my @svc_labels = map &{$escape_function}($_),
+                        $cust_pkg->h_labels_short($self->_date, undef, 'I');
+            push @d, @svc_labels
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
+            $svc_label = $svc_labels[0];
 
-            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;
@@ -2271,13 +2485,14 @@ sub _items_cust_bill_pkg {
             $s = {
               _is_setup       => 1,
               description     => $description,
-              #pkgpart         => $part_pkg->pkgpart,
+              pkgpart         => $pkgpart,
               pkgnum          => $cust_bill_pkg->pkgnum,
               amount          => $cust_bill_pkg->setup,
               setup_show_zero => $cust_bill_pkg->setup_show_zero,
               unit_amount     => $cust_bill_pkg->unitsetup,
               quantity        => $cust_bill_pkg->quantity,
               ext_description => \@d,
+              svc_label       => ($svc_label || ''),
             };
           };
 
@@ -2300,21 +2515,30 @@ sub _items_cust_bill_pkg {
           my $description = ($is_summary && $type && $type eq 'U')
                             ? "Usage charges" : $desc;
 
+          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')
-              || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1)
+              || $part_pkg->option('disable_line_item_date_ranges',1)
+              || ! $cust_bill_pkg->sdate
+              || ! $cust_bill_pkg->edate
           ) {
             my $time_period;
-            my $date_style = $conf->config( 'cust_bill-line_item-date_style',
-                                            $cust_main->agentnum
-                                          );
+            my $date_style = '';
+            $date_style = $conf->config( 'cust_bill-line_item-date_style-non_monhtly',
+                                         $self->agentnum
+                                       )
+              if $part_pkg && $part_pkg->freq !~ /^1m?$/;
+            $date_style ||= $conf->config( 'cust_bill-line_item-date_style',
+                                            $self->agentnum
+                                         );
             if ( defined($date_style) && $date_style eq 'month_of' ) {
               $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
             } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
               my $desc = $conf->config( 'cust_bill-line_item-date_description',
-                                         $cust_main->agentnum
+                                         $self->agentnum
                                       );
               $desc .= ' ' unless $desc =~ /\s$/;
               $time_period = $desc. time2str('%B', $cust_bill_pkg->sdate);
@@ -2327,6 +2551,7 @@ sub _items_cust_bill_pkg {
 
           my @d = ();
           my @seconds = (); # for display of usage info
+          my $svc_label = '';
 
           #at least until cust_bill_pkg has "past" ranges in addition to
           #the "future" sdate/edate ones... see #3032
@@ -2335,25 +2560,30 @@ sub _items_cust_bill_pkg {
           push @dates, $prev->sdate if $prev;
           push @dates, undef if !$prev;
 
-          unless ( $cust_pkg->part_pkg->hide_svc_detail
+          unless ( $part_pkg->hide_svc_detail
                 || $cust_bill_pkg->itemdesc
                 || $cust_bill_pkg->hidden
-                || $is_summary && $type && $type eq 'U' )
+                || $is_summary && $type && $type eq 'U'
+              )
           {
 
             warn "$me _items_cust_bill_pkg adding service details\n"
               if $DEBUG > 1;
 
-            push @d, map &{$escape_function}($_),
-                         $cust_pkg->h_labels_short(@dates, 'I')
-                                                   #$cust_bill_pkg->edate,
-                                                   #$cust_bill_pkg->sdate)
+            my @svc_labels = map &{$escape_function}($_),
+                        $cust_pkg->h_labels_short($self->_date, undef, 'I');
+            push @d, @svc_labels
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
+            $svc_label = $svc_labels[0];
 
             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;
@@ -2423,7 +2653,7 @@ sub _items_cust_bill_pkg {
             } else {
               $r = {
                 description     => $description,
-                #pkgpart         => $part_pkg->pkgpart,
+                pkgpart         => $pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
                 amount          => $amount,
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
@@ -2431,6 +2661,7 @@ sub _items_cust_bill_pkg {
                 quantity        => $cust_bill_pkg->quantity,
                 %item_dates,
                 ext_description => \@d,
+                svc_label       => ($svc_label || ''),
               };
               $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
             }
@@ -2447,7 +2678,7 @@ sub _items_cust_bill_pkg {
             } else {
               $u = {
                 description     => $description,
-                #pkgpart         => $part_pkg->pkgpart,
+                pkgpart         => $pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
                 amount          => $amount,
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,