RT# 79636 Taxes per section when using invoice_sections
[freeside.git] / FS / FS / Template_Mixin.pm
index 84e7b69..99019e9 100644 (file)
@@ -10,14 +10,16 @@ use vars qw( $invoice_lines @buf ); #yuck
 use List::Util qw(sum); #can't import first, it conflicts with cust_main.first
 use Date::Format;
 use Date::Language;
+use Time::Local qw( timelocal );
 use Text::Template 1.20;
 use File::Temp 0.14;
+use Archive::Zip qw( :ERROR_CODES :CONSTANTS );
+use IO::Scalar;
 use HTML::Entities;
-use Locale::Country;
 use Cwd;
 use FS::UID;
 use FS::Misc qw( send_email );
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs dbh );
 use FS::Conf;
 use FS::Misc qw( generate_ps generate_pdf );
 use FS::pkg_category;
@@ -146,6 +148,10 @@ sub print_latex {
   $template ||= $self->_agent_template
     if $self->can('_agent_template');
 
+  #the new way
+  $self->set('mode', $params{mode})
+    if $params{mode};
+
   my $pkey = $self->primary_key;
   my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX';
 
@@ -560,6 +566,7 @@ sub print_generic {
     'notice_name'     => $notice_name, # escape?
     'current_charges' => sprintf("%.2f", $self->charged),
     'duedate'         => $self->due_date2str('rdate'), #date_format?
+    'duedate_long'    => $self->due_date2str('long'),
 
     #customer info
     'custnum'         => $cust_main->display_custnum,
@@ -570,7 +577,7 @@ sub print_generic {
     )),
 
     #global config
-    'ship_enable'     => $conf->exists('invoice-ship_address'),
+    'ship_enable'     => $cust_main->invoice_ship_address || $conf->exists('invoice-ship_address'),
     'unitprices'      => $conf->exists('invoice-unitprice'),
     'smallernotes'    => $conf->exists('invoice-smallernotes'),
     'smallerfooter'   => $conf->exists('invoice-smallerfooter'),
@@ -649,7 +656,7 @@ sub print_generic {
   if ( $cust_main->country eq $countrydefault ) {
     $invoice_data{'country'} = '';
   } else {
-    $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
+    $invoice_data{'country'} = &$escape_function($cust_main->bill_country_full);
   }
 
   my @address = ();
@@ -685,7 +692,12 @@ sub print_generic {
   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
   #my $balance_due = $self->owed + $pr_total - $cr_total;
-  my $balance_due = $self->owed + $pr_total;
+  my $balance_due = $self->owed;
+  if ( $self->enable_previous ) {
+    $balance_due += $pr_total;
+  }
+  # otherwise the previous balance is not shown, so including it in the
+  # balance due is just confusing
 
   # the sum of amount owed on all invoices
   # (this is used in the summary & on the payment coupon)
@@ -698,12 +710,18 @@ sub print_generic {
     # XXX should be an FS::cust_bill method to set the defaults, instead
     # of checking the type here
 
+    # info from customer's last invoice before this one, for some 
+    # summary formats
+    $invoice_data{'last_bill'} = {};
     my $last_bill = $self->previous_bill;
     if ( $last_bill ) {
 
       # "balance_date_range" unfortunately is unsuitable for this, since it
       # cares about application dates.  We want to know the sum of all 
       # _top-level transactions_ dated before the last invoice.
+      #
+      # still do this for the "Previous Balance" line of the summary block
       my @sql =
         map "$_ WHERE _date <= ? AND custnum = ?", (
           "SELECT      COALESCE( SUM(charged), 0 ) FROM cust_bill",
@@ -736,19 +754,31 @@ sub print_generic {
       # longer stored in the database)
       $invoice_data{'true_previous_balance'} = $last_bill_balance;
 
-      # the change in balance from immediately after that invoice
-      # to immediately before this one
-      my $before_this_bill_balance = 0;
+      # Now, get all applications of credits/payments dated on or after the
+      # previous bill, to invoices before the current bill. (The
+      # credit/payment date restriction prevents these from intersecting
+      # the "Previous Balance" set.)
+      # These are "adjustments". The past due balance will be shown as
+      # Previous Balance - Adjustments.
+      my $adjustments = 0;
+      @sql = map {
+        "SELECT COALESCE(SUM(y.amount),0) FROM $_ JOIN cust_bill USING (invnum)
+         WHERE cust_bill._date < ?
+           AND x._date >= ?
+           AND cust_bill.custnum = ?"
+        } "cust_credit AS x JOIN cust_credit_bill y USING (crednum)",
+          "cust_pay    AS x JOIN cust_bill_pay    y USING (paynum)"
+      ;
       foreach (@sql) {
         my $delta = FS::Record->scalar_sql(
           $_,
-          $self->_date - 1,
+          $self->_date,
+          $last_bill->_date,
           $self->custnum,
         );
-        $before_this_bill_balance += $delta;
+        $adjustments += $delta;
       }
-      $invoice_data{'balance_adjustments'} =
-        sprintf("%.2f", $last_bill_balance - $before_this_bill_balance);
+      $invoice_data{'balance_adjustments'} = sprintf("%.2f", $adjustments);
 
       warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n",
                    $invoice_data{'balance_adjustments'}
@@ -758,9 +788,7 @@ sub print_generic {
       # ($pr_total is used elsewhere but not as $previous_balance)
       $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
 
-      $invoice_data{'last_bill'} = {
-        '_date'     => $last_bill->_date, #unformatted
-      };
+      $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', {
@@ -802,11 +830,7 @@ sub print_generic {
       $invoice_data{'previous_payments'} = [];
       $invoice_data{'previous_credits'} = [];
     }
-
-    # info from customer's last invoice before this one, for some 
-    # summary formats
-    $invoice_data{'last_bill'} = {};
-  
     if ( $conf->exists('invoice_usesummary', $agentnum) ) {
       $invoice_data{'summarypage'} = $summarypage = 1;
     }
@@ -820,35 +844,36 @@ sub print_generic {
   my @include = ( [ $tc,        'notes' ],
                   [ 'invoice_', 'footer' ],
                   [ 'invoice_', 'smallfooter', ],
+                  [ 'invoice_', 'watermark' ],
                 );
   push @include, [ $tc,        'coupon', ]
     unless $params{'no_coupon'};
 
   foreach my $i (@include) {
 
+    # load the configuration for this sub-template
+
     my($base, $include) = @$i;
 
     my $inc_file = $conf->key_orbase("$base$format$include", $template);
-    my @inc_src;
-
-    if ( $conf->exists($inc_file, $agentnum)
-         && length( $conf->config($inc_file, $agentnum) ) ) {
-
-      @inc_src = $conf->config($inc_file, $agentnum);
-
-    } else {
-
-      $inc_file = $conf->key_orbase("${base}latex$include", $template);
 
-      my $convert_map = $convert_maps{$format}{$include};
-
-      @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
-                       s/--\@\]/$delimiters{$format}[1]/g;
-                       $_;
-                     } 
-                 &$convert_map( $conf->config($inc_file, $agentnum) );
+    my @inc_src = $conf->config($inc_file, $agentnum);
+    if (!@inc_src) {
+      my $converter = $convert_maps{$format}{$include};
+      if ( $converter ) {
+        # then attempt to convert LaTeX to the requested format
+        $inc_file = $conf->key_orbase($base.'latex'.$include, $template);
+        @inc_src = &$converter( $conf->config($inc_file, $agentnum) );
+        foreach (@inc_src) {
+          # this isn't included in the convert_maps
+          my ($open, $close) = @{ $delimiters{$format} };
+          s/\[\@--/$open/g;
+          s/--\@\]/$close/g;
+        }
+      }
+    } # else @inc_src is empty and that's fine
 
-    }
+    # make a Text::Template out of it
 
     my $inc_tt = new Text::Template (
       TYPE       => 'ARRAY',
@@ -862,6 +887,8 @@ sub print_generic {
       die $error;
     }
 
+    # fill in variables
+
     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
 
     $invoice_data{$include} =~ s/\n+$//
@@ -909,9 +936,10 @@ sub print_generic {
     if $DEBUG > 1;
 
   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
-  my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) ||
-                     $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
+  my $multisection = $self->has_sections;
   $invoice_data{'multisection'} = $multisection;
+  my $section_with_taxes = 1
+    if $conf->exists('invoice_sections_with_taxes');
   my $late_sections;
   my $extra_sections = [];
   my $extra_lines = ();
@@ -1038,7 +1066,7 @@ sub print_generic {
   # start setting up summary subtotals
   my @summary_subtotals;
   my $method = $conf->config('summary_subtotals_method');
-  if ( $method and $method ne $conf->config($tc.'sections_method') ) {
+  if ( ( ref($self) ne 'FS::quotation' ) and $method and $method ne $conf->config($tc.'sections_method') ) {
     # then re-section them by the correct method
     my %section_method = ( by_category => 1 );
     if ( $conf->config('summary_subtotals_method') eq 'location' ) {
@@ -1129,14 +1157,27 @@ sub print_generic {
       if ( $invoice_data{finance_section} &&
            $section->{'description'} eq $invoice_data{finance_section} );
 
-    $section->{'subtotal'} = $other_money_char.
-                             sprintf('%.2f', $section->{'subtotal'})
-      if $multisection;
+    if ( $multisection ) {
+
+      if ( ref($section->{'subtotal'}) ) {
 
-    # continue some normalization
-    $section->{'amount'}   = $section->{'subtotal'}
-      if $multisection;
+        $section->{'subtotal'} =
+          sprintf("$other_money_char%.2f to $other_money_char%.2f",
+                    $section->{'subtotal'}[0],
+                    $section->{'subtotal'}[1]
+                 );
 
+      } else {
+
+        $section->{'subtotal'} = $other_money_char.
+                                 sprintf('%.2f', $section->{'subtotal'})
+
+      }
+
+      # continue some normalization
+      $section->{'amount'}   = $section->{'subtotal'}
+
+    }
 
     if ( $section->{'description'} ) {
       push @buf, ( [ &$escape_function($section->{'description'}), '' ],
@@ -1161,6 +1202,9 @@ sub print_generic {
     warn "$me   searching for line items\n"
       if $DEBUG > 1;
 
+    my %section_tax_lines;
+    my %seen_tax_lines;
+
     foreach my $line_item ( $self->_items_pkg(%options),
                             $self->_items_fee(%options) ) {
 
@@ -1184,10 +1228,55 @@ sub print_generic {
         $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
       }
       $line_item->{'ext_description'} ||= [];
+
+      if ( $section_with_taxes && ref $line_item->{pkg_tax} ) {
+        for my $line_tax ( @{$ line_item->{pkg_tax} } ) {
+
+          # It is rarely possible for the same tax record to be presented here
+          # multiple times.  See cust_bill_pkg::_pkg_tax_list for more info
+          next if $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} };
+          $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} } = 1;
+
+          $section_tax_lines{ $line_tax->{taxname} } += $line_tax->{amount};
+        }
+      }
+
       push @detail_items, $line_item;
     }
 
+    # If conf flag invoice_sections_with_taxes:
+    # - Add @detail_items for taxes into each section
+    # - Update section subtotal to include taxes
+    if ( $section_with_taxes && %section_tax_lines ) {
+      for my $taxname ( keys %section_tax_lines ) {
+
+        push @detail_items, {
+          section => $section,
+          amount  => sprintf($money_char."%.2f",$section_tax_lines{$taxname}),
+          description => &$escape_function($taxname),
+        };
+
+        # Append taxes to total.  If line format resembles "$5.00 to $12.00"
+        # append to the second value.
+        if ($section->{subtotal} =~ /to/) {
+          my @subtotal = split /\s/, $section->{subtotal};
+          $subtotal[2] =~ s/[^\d\.]//g;
+          $subtotal[2] = sprintf(
+            $money_char."%.2f",
+            ( $subtotal[2] + $section_tax_lines{$taxname} )
+          );
+          $section->{subtotal} = join ' ', @subtotal;
+        } else {
+        $section->{subtotal} =~ s/[^\d\.]//g;
+          $section->{subtotal} = sprintf(
+            $money_char . "%.2f",
+            ( $section->{subtotal} + $section_tax_lines{$taxname} )
+          );
+        }
+
+      }
+    }
+
     if ( $section->{'description'} ) {
       push @buf, ( ['','-----------'],
                    [ $section->{'description'}. ' sub-total',
@@ -1267,7 +1356,7 @@ sub print_generic {
               ];
 
   }
-  
   if ( @items_tax ) {
     my $total = {};
     $total->{'total_item'} = $self->mt('Sub-total');
@@ -1276,17 +1365,31 @@ sub print_generic {
 
     if ( $multisection ) {
       if ( $taxtotal > 0 ) {
+        # there are taxes, so prepare the section to be displayed.
+        # $taxtotal already includes any line items that were already in the
+        # section (fees, taxes that are charged as packages for some reason).
+        # also set 'summarized' to false so that this isn't a summary-only
+        # section.
         $tax_section->{'subtotal'} = $other_money_char.
                                      sprintf('%.2f', $taxtotal);
         $tax_section->{'pretotal'} = 'New charges sub-total '.
                                      $total->{'total_amount'};
         $tax_section->{'description'} = $self->mt($tax_description);
+        $tax_section->{'summarized'} = '';
+
+        if ( $conf->exists('invoice_sections_with_taxes')) {
+
+          # remove tax section if taxes are itemized within other sections
+          @sections = grep{ $_ ne $tax_section } @sections;
 
-        # append it if it's not already there
-        if ( !grep $tax_section, @sections ) {
+        } elsif ( !grep $tax_section, @sections ) {
+
+          # append it if it's not already there
           push @sections, $tax_section;
           push @summary_subtotals, $tax_section;
+
         }
+
       }
 
     } else {
@@ -1295,15 +1398,6 @@ sub print_generic {
   }
   $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
 
-  push @buf,['','-----------'];
-  push @buf,[$self->mt( 
-              (!$self->enable_previous)
-               ? 'Total Charges'
-               : 'Total New Charges'
-             ),
-             $money_char. sprintf("%10.2f",$self->charged) ];
-  push @buf,['',''];
-
   ###
   # Totals
   ###
@@ -1315,51 +1409,46 @@ sub print_generic {
   );
   my $embolden_function = $embolden_functions{$format};
 
-  if ( $self->can('_items_total') ) { # quotations
-
-    $self->_items_total(\@total_items);
+  if ( $multisection ) {
 
-    foreach ( @total_items ) {
-      $_->{'total_item'}   = &$embolden_function( $_->{'total_item'} );
-      $_->{'total_amount'} = &$embolden_function( $other_money_char.
-                                                   $_->{'total_amount'}
-                                                );
+    if ( $adjust_section->{'sort_weight'} ) {
+      $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
+        $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
+    } else{
+      $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
+        $other_money_char.  sprintf('%.2f', $self->charged );
     }
 
-  } else { #normal invoice case
+  }
+  
+  if ( $self->can('_items_total') ) { # should always be true now
 
-    # calculate total, possibly including total owed on previous
-    # invoices
-    my $total = {};
-    my $item = 'Total';
-    $item = $conf->config('previous_balance-exclude_from_total')
-         || 'Total New Charges'
-      if $conf->exists('previous_balance-exclude_from_total');
-    my $amount = $self->charged;
-    if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) {
-      $amount += $pr_total;
-    }
+    # even for multisection, need plain text version
 
-    $total->{'total_item'} = &$embolden_function($self->mt($item));
-    $total->{'total_amount'} =
-      &$embolden_function( $other_money_char.  sprintf( '%.2f', $amount ) );
-    if ( $multisection ) {
-      if ( $adjust_section->{'sort_weight'} ) {
-        $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
-          $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
+    my @new_total_items = $self->_items_total;
+
+    push @buf,['','-----------'];
+
+    foreach ( @new_total_items ) {
+      my ($item, $amount) = ($_->{'total_item'}, $_->{'total_amount'});
+      $_->{'total_item'}   = &$embolden_function( $item );
+
+      if ( ref($amount) ) {
+        $_->{'total_amount'} = &$embolden_function(
+                                 $other_money_char.$amount->[0]. ' to '.
+                                 $other_money_char.$amount->[1]
+                               );
       } else {
-        $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
-          $other_money_char.  sprintf('%.2f', $self->charged );
-      } 
-    } else {
-      push @total_items, $total;
+      $_->{'total_amount'} = &$embolden_function( $other_money_char.$amount );
+      }
+
+      # but if it's multisection, don't append to @total_items. the adjust
+      # section has all this stuff
+      push @total_items, $_ if !$multisection;
+      push @buf, [ $item, $money_char.sprintf('%10.2f',$amount) ];
     }
-    push @buf,['','-----------'];
-    push @buf,[$item,
-               $money_char.
-               sprintf( '%10.2f', $amount )
-              ];
-    push @buf,['',''];
+
+    push @buf, [ '', '' ];
 
     # if we're showing previous invoices, also show previous
     # credits and payments 
@@ -1367,12 +1456,11 @@ sub print_generic {
           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 )
+        $self->_items_credits( 'template' => $template, 'trim_len' => 40 )
       ) {
 
         my $total;
@@ -1459,7 +1547,7 @@ sub print_generic {
         if ( $multisection && !$adjust_section->{sort_weight} ) {
           $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
                                            $total->{'total_amount'};
-        }else{
+        } else {
           push @total_items, $total;
         }
         push @buf,['','-----------'];
@@ -1535,7 +1623,7 @@ sub print_generic {
   # usage subtotals
   if ( $conf->exists('usage_class_summary')
        and $self->can('_items_usage_class_summary') ) {
-    my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function);
+    my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function, 'money_char' => $other_money_char);
     if ( @usage_subtotals ) {
       unshift @sections, $usage_subtotals[0]->{section}; # do not summarize
       unshift @detail_items, @usage_subtotals;
@@ -1545,7 +1633,7 @@ sub print_generic {
   # invoice history "section" (not really a section)
   # not to be included in any subtotals, completely independent of 
   # everything...
-  if ( $conf->exists('previous_invoice_history') ) {
+  if ( $conf->exists('previous_invoice_history') and $cust_main->isa('FS::cust_main') ) {
     my %history;
     my %monthorder;
     foreach my $cust_bill ( $cust_main->cust_bill ) {
@@ -1632,24 +1720,24 @@ sub print_generic {
   die "no invoice_lines() functions in template?"
     if ( $format eq 'template' && !$wasfunc );
 
-  if ($format eq 'template') {
+  if ( $invoice_lines ) {
+    $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
+    $invoice_data{'total_pages'}++
+      if scalar(@buf) % $invoice_lines;
+  }
 
-    if ( $invoice_lines ) {
-      $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
-      $invoice_data{'total_pages'}++
-        if scalar(@buf) % $invoice_lines;
+  #setup subroutine for the template
+  $invoice_data{invoice_lines} = sub {
+    my $lines = shift || scalar(@buf);
+    map { 
+      scalar(@buf)
+        ? shift @buf
+        : [ '', '' ];
     }
+    ( 1 .. $lines );
+  };
 
-    #setup subroutine for the template
-    $invoice_data{invoice_lines} = sub {
-      my $lines = shift || scalar(@buf);
-      map { 
-        scalar(@buf)
-          ? shift @buf
-          : [ '', '' ];
-      }
-      ( 1 .. $lines );
-    };
+  if ($format eq 'template') {
 
     my $lines;
     my @collect;
@@ -1663,6 +1751,13 @@ sub print_generic {
 
   } else { # this is where we actually create the invoice
 
+    if ( $params{no_addresses} ) {
+      delete $invoice_data{$_} foreach qw(
+        payname company address1 address2 city state zip country
+      );
+      $invoice_data{returnaddress} = '~';
+    }
+
     warn "filling in template for invoice ". $self->invnum. "\n"
       if $DEBUG;
     warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
@@ -1905,6 +2000,12 @@ sub due_date {
   my $duedate = '';
   if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
     $duedate = $self->_date() + ( $1 * 86400 );
+  } elsif ( $self->terms =~ /^End of Month$/ ) {
+    my ($mon,$year) = (localtime($self->_date) )[4,5];
+    $mon++;
+    until ( $mon < 12 ) { $mon -= 12; $year++; }
+    my $nextmonth_first = timelocal(0,0,0,1,$mon,$year);
+    $duedate = $nextmonth_first - 86400;
   }
   $duedate;
 }
@@ -1925,12 +2026,22 @@ sub due_date2str {
 sub balance_due_msg {
   my $self = shift;
   my $msg = $self->mt('Balance Due');
-  return $msg unless $self->terms;
-  if ( $self->due_date ) {
-    $msg .= ' - ' . $self->mt('Please pay by'). ' '.
-      $self->due_date2str('short');
-  } elsif ( $self->terms ) {
-    $msg .= ' - '. $self->terms;
+  return $msg unless $self->terms; # huh?
+  if ( !$self->conf->exists('invoice_show_prior_due_date')
+       or $self->conf->exists('invoice_sections') ) {
+    # if enabled, the due date is shown with Total New Charges (see 
+    # _items_total) and not here
+    # (yes, or if invoice_sections is enabled; this is just for compatibility)
+    if ( $self->due_date ) {
+      my $please_pay_by =
+        $self->conf->config('invoice_pay_by_msg', $self->agentnum)
+        || 'Please pay by [_1]';
+      $msg .= ' - ' . $self->mt($please_pay_by, $self->due_date2str('short')).
+              ' '
+       unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum);
+    } elsif ( $self->terms ) {
+      $msg .= ' - '. $self->mt($self->terms);
+    }
   }
   $msg;
 }
@@ -2038,10 +2149,6 @@ sender address, required
 
 alternate template name, optional
 
-=item print_text
-
-text attachment arrayref, optional
-
 =item subject
 
 email subject, optional
@@ -2057,6 +2164,7 @@ Returns an argument list to be passed to L<FS::Misc::send_email>.
 =cut
 
 use MIME::Entity;
+use Encode;
 
 sub generate_email {
 
@@ -2085,61 +2193,70 @@ sub generate_email {
 
   my $tc = $self->template_conf;
 
-  if ( $conf->exists($tc.'html') ) {
+  my @text; # array of lines
+  my $html; # a big string
+  my @related_parts; # will contain the text/HTML alternative, and images
+  my $related; # will contain the multipart/related object
 
-    warn "$me creating HTML/text multipart message"
-      if $DEBUG;
+  if ( $conf->exists($tc. 'email_pdf') ) {
+    if ( my $msgnum = $conf->config($tc.'email_pdf_msgnum') ) {
 
-    $return{'nobody'} = 1;
+      warn "$me using '${tc}email_pdf_msgnum' in multipart message"
+        if $DEBUG;
 
-    my $alternative = build MIME::Entity
-      'Type'        => 'multipart/alternative',
-      #'Encoding'    => '7bit',
-      'Disposition' => 'inline'
-    ;
+      my $msg_template = FS::msg_template->by_key($msgnum)
+        or die "${tc}email_pdf_msgnum $msgnum not found\n";
+      my %prepared = $msg_template->prepare(
+        cust_main => $self->cust_main,
+        object    => $self
+      );
+
+      @text = split(/(?=\n)/, $prepared{'text_body'});
+      $html = $prepared{'html_body'};
 
-    my $data = '';
-    if ( $conf->exists($tc. 'email_pdf')
-         and scalar($conf->config($tc. 'email_pdf_note')) ) {
+    } elsif ( my @note = $conf->config($tc.'email_pdf_note') ) {
 
       warn "$me using '${tc}email_pdf_note' in multipart message"
         if $DEBUG;
-      $data = [ map { $_ . "\n" }
-                    $conf->config($tc.'email_pdf_note')
-              ];
+      @text = $conf->config($tc.'email_pdf_note');
+      $html = join('<BR>', @text);
+  
+    } # else use the plain text invoice
+  }
+
+  if (!@text) {
+
+    if ( $conf->config($tc.'template') ) {
+
+      warn "$me generating plain text invoice"
+        if $DEBUG;
+
+      # 'print_text' argument is no longer used
+      @text = map Encode::encode_utf8($_), $self->print_text(\%args);
 
     } else {
 
-      warn "$me not using '${tc}email_pdf_note' in multipart message"
+      warn "$me no plain text version exists; sending empty message body"
         if $DEBUG;
-      if ( ref($args{'print_text'}) eq 'ARRAY' ) {
-        $data = $args{'print_text'};
-      } elsif ( $conf->exists($tc.'template') ) { #plaintext invoice_template
-        $data = [ $self->print_text(\%args) ];
-      }
 
     }
 
-    if ( $data ) {
-      $alternative->attach(
-        'Type'        => 'text/plain',
-        'Encoding'    => 'quoted-printable',
-        'Charset'     => 'UTF-8',
-        #'Encoding'    => '7bit',
-        'Data'        => $data,
-        'Disposition' => 'inline',
-      );
-    }
+  }
 
-    my $htmldata;
-    my $image = '';
-    my $barcode = '';
-    if ( $conf->exists($tc.'email_pdf')
-         and scalar($conf->config($tc.'email_pdf_note')) ) {
+  my $text_part = build MIME::Entity (
+    'Type'        => 'text/plain',
+    'Encoding'    => 'quoted-printable',
+    'Charset'     => 'UTF-8',
+    #'Encoding'    => '7bit',
+    'Data'        => \@text,
+    'Disposition' => 'inline',
+  );
 
-      $htmldata = join('<BR>', $conf->config($tc.'email_pdf_note') );
+  if (!$html) {
 
-    } else {
+    if ( $conf->exists($tc.'html') ) {
+      warn "$me generating HTML invoice"
+        if $DEBUG;
 
       $args{'from'} =~ /\@([\w\.\-]+)/;
       my $from = $1 || 'example.com';
@@ -2158,7 +2275,7 @@ sub generate_email {
       }
       my $image_data = $conf->config_binary( $logo, $agentnum);
 
-      $image = build MIME::Entity
+      push @related_parts, build MIME::Entity
         'Type'       => 'image/png',
         'Encoding'   => 'base64',
         'Data'       => $image_data,
@@ -2168,7 +2285,7 @@ sub generate_email {
    
       if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) {
         my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
-        $barcode = build MIME::Entity
+        push @related_parts, build MIME::Entity
           'Type'       => 'image/png',
           'Encoding'   => 'base64',
           'Data'       => $self->invoice_barcode(0),
@@ -2178,7 +2295,26 @@ sub generate_email {
         $args{'barcode_cid'} = $barcode_content_id;
       }
 
-      $htmldata = $self->print_html({ 'cid'=>$content_id, %args });
+      $html = $self->print_html({ 'cid'=>$content_id, %args });
+    }
+
+  }
+
+  if ( $html ) {
+
+    warn "$me creating HTML/text multipart message"
+      if $DEBUG;
+
+    $return{'nobody'} = 1;
+
+    my $alternative = build MIME::Entity
+      'Type'        => 'multipart/alternative',
+      #'Encoding'    => '7bit',
+      'Disposition' => 'inline'
+    ;
+
+    if ( @text ) {
+      $alternative->add_part($text_part);
     }
 
     $alternative->attach(
@@ -2191,7 +2327,7 @@ sub generate_email {
                          '    </title>',
                          '  </head>',
                          '  <body bgcolor="#e8e8e8">',
-                         $htmldata,
+                         Encode::encode_utf8($html),
                          '  </body>',
                          '</html>',
                        ],
@@ -2199,10 +2335,50 @@ sub generate_email {
       #'Filename'    => 'invoice.pdf',
     );
 
+    unshift @related_parts, $alternative;
+
+    $related = build MIME::Entity 'Type'     => 'multipart/related',
+                                  'Encoding' => '7bit';
+
+    #false laziness w/Misc::send_email
+    $related->head->replace('Content-type',
+      $related->mime_type.
+      '; boundary="'. $related->head->multipart_boundary. '"'.
+      '; type=multipart/alternative'
+    );
+
+    $related->add_part($_) foreach @related_parts;
+
+  }
+
+  my @otherparts = ();
+  if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
 
-    my @otherparts = ();
-    if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
+    if ( $conf->config('voip-cdr_email_attach') eq 'zip' ) {
+
+      my $data = join('', map "$_\n",
+                   $self->call_details(prepend_billed_number=>1)
+                 );
 
+      my $zip = new Archive::Zip;
+      my $file = $zip->addString( $data, 'usage-'.$self->invnum.'.csv' );
+      $file->desiredCompressionMethod( COMPRESSION_DEFLATED );
+
+      my $zipdata = '';
+      my $SH = IO::Scalar->new(\$zipdata);
+      my $status = $zip->writeToFileHandle($SH);
+      die "Error zipping CDR attachment: $!" unless $status == AZ_OK;
+
+      push @otherparts, build MIME::Entity
+        'Type'        => 'application/zip',
+        'Encoding'    => 'base64',
+        'Data'        => $zipdata,
+        'Disposition' => 'attachment',
+        'Filename'    => 'usage-'. $self->invnum. '.zip',
+      ;
+
+    } else { # } elsif ( $conf->config('voip-cdr_email_attach') eq 'csv' ) {
       push @otherparts, build MIME::Entity
         'Type'        => 'text/csv',
         'Encoding'    => '7bit',
@@ -2215,88 +2391,39 @@ sub generate_email {
 
     }
 
-    if ( $conf->exists($tc.'email_pdf') ) {
-
-      #attaching pdf too:
-      # multipart/mixed
-      #   multipart/related
-      #     multipart/alternative
-      #       text/plain
-      #       text/html
-      #     image/png
-      #   application/pdf
-
-      my $related = build MIME::Entity 'Type'     => 'multipart/related',
-                                       'Encoding' => '7bit';
-
-      #false laziness w/Misc::send_email
-      $related->head->replace('Content-type',
-        $related->mime_type.
-        '; boundary="'. $related->head->multipart_boundary. '"'.
-        '; type=multipart/alternative'
-      );
-
-      $related->add_part($alternative);
-
-      $related->add_part($image) if $image;
-
-      my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
-
-      $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
-
-    } else {
-
-      #no other attachment:
-      # multipart/related
-      #   multipart/alternative
-      #     text/plain
-      #     text/html
-      #   image/png
-
-      $return{'content-type'} = 'multipart/related';
-      if ($conf->exists('invoice-barcode') && $barcode) {
-        $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
-      } else {
-        $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
-      }
-      $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
-      #$return{'disposition'} = 'inline';
-
-    }
-  
-  } else {
+  }
 
-    if ( $conf->exists($tc.'email_pdf') ) {
-      warn "$me creating PDF attachment"
-        if $DEBUG;
+  if ( $conf->exists($tc.'email_pdf') ) {
 
-      #mime parts arguments a la MIME::Entity->build().
-      $return{'mimeparts'} = [
-        { $self->mimebuild_pdf(\%args) }
-      ];
-    }
-  
-    if ( $conf->exists($tc.'email_pdf')
-         and scalar($conf->config($tc.'email_pdf_note')) ) {
+    #attaching pdf too:
+    # multipart/mixed
+    #   multipart/related
+    #     multipart/alternative
+    #       text/plain
+    #       text/html
+    #     image/png
+    #   application/pdf
 
-      warn "$me using '${tc}email_pdf_note'"
-        if $DEBUG;
-      $return{'body'} = [ map { $_ . "\n" }
-                              $conf->config($tc.'email_pdf_note')
-                        ];
+    my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
+    push @otherparts, $pdf;
+  }
 
+  if (@otherparts) {
+    $return{'content-type'} = 'multipart/mixed'; # of the outer container
+    if ( $html ) {
+      $return{'mimeparts'} = [ $related, @otherparts ];
+      $return{'type'} = 'multipart/related'; # of the first part
     } else {
-
-      warn "$me not using '${tc}email_pdf_note'"
-        if $DEBUG;
-      if ( ref($args{'print_text'}) eq 'ARRAY' ) {
-        $return{'body'} = $args{'print_text'};
-      } else {
-        $return{'body'} = [ $self->print_text(\%args) ];
-      }
-
+      $return{'mimeparts'} = [ $text_part, @otherparts ];
+      $return{'type'} = 'text/plain';
     }
-
+  } elsif ( $html ) { # no PDF or CSV, strip the outer container
+    $return{'mimeparts'} = \@related_parts;
+    $return{'content-type'} = 'multipart/related';
+    $return{'type'} = 'multipart/alternative';
+  } else { # no HTML either
+    $return{'body'} = \@text;
+    $return{'content-type'} = 'text/plain';
   }
 
   %return;
@@ -2321,6 +2448,110 @@ sub mimebuild_pdf {
   );
 }
 
+=item postal_mail_fsinc
+
+Sends this invoice to the Freeside Internet Services, Inc. print and mail
+service.
+
+=cut
+
+use CAM::PDF;
+use IO::Socket::SSL;
+use LWP::UserAgent;
+use HTTP::Request::Common qw( POST );
+use JSON::XS;
+use MIME::Base64;
+sub postal_mail_fsinc {
+  my ( $self, %opt ) = @_;
+
+  my $url = 'https://ws.freeside.biz/print';
+
+  my $cust_main = $self->cust_main;
+  my $agentnum = $cust_main->agentnum;
+  my $bill_location = $cust_main->bill_location;
+
+  die "Extra charges for international mailing; contact support\@freeside.biz to enable\n"
+    if $bill_location->country ne 'US';
+
+  my $conf = new FS::Conf;
+
+  my @company_address = $conf->config('company_address', $agentnum);
+  my ( $company_address1, $company_address2, $company_city, $company_state, $company_zip );
+  if ( $company_address[2] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
+    $company_address1 = $company_address[0];
+    $company_address2 = $company_address[1];
+    $company_city  = $1;
+    $company_state = $2;
+    $company_zip   = $3;
+  } elsif ( $company_address[1] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
+    $company_address1 = $company_address[0];
+    $company_address2 = '';
+    $company_city  = $1;
+    $company_state = $2;
+    $company_zip   = $3;
+  } else {
+    die "Unparsable company_address; contact support\@freeside.biz\n";
+  }
+  $company_city =~ s/,$//;
+
+  my $file = $self->print_pdf(%opt, 'no_addresses' => 1);
+  my $pages = CAM::PDF->new($file)->numPages;
+
+  my $ua = LWP::UserAgent->new(
+    'ssl_opts' => { 
+      verify_hostname => 0,
+      SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
+      SSL_version     => 'SSLv3',
+    }
+  );
+  my $response = $ua->request( POST $url, [
+    'support-key'      => scalar($conf->config('support-key')),
+    'file'             => encode_base64($file),
+    'pages'            => $pages,
+
+    #from:
+    'company_name'     => scalar( $conf->config('company_name', $agentnum) ),
+    'company_address1' => $company_address1,
+    'company_address2' => $company_address2,
+    'company_city'     => $company_city,
+    'company_state'    => $company_state,
+    'company_zip'      => $company_zip,
+    'company_country'  => 'US',
+    'company_phonenum' => scalar($conf->config('company_phonenum', $agentnum)),
+    'company_email'    => scalar($conf->config('invoice_from', $agentnum)),
+
+    #to:
+    'name'             => ( $cust_main->payname
+                              && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/
+                                ? $cust_main->payname
+                                : $cust_main->contact_firstlast
+                          ),
+    'company'          => $cust_main->company,
+    'address1'         => $bill_location->address1,
+    'address2'         => $bill_location->address2,
+    'city'             => $bill_location->city,
+    'state'            => $bill_location->state,
+    'zip'              => $bill_location->zip,
+    'country'          => $bill_location->country,
+  ]);
+
+  die "Print connection error: ". $response->message.
+      ' ('. $response->as_string. ")\n"
+    unless $response->is_success;
+
+  local $@;
+  my $content = eval { decode_json($response->content) };
+  die "Print JSON error : $@\n" if $@;
+
+  die $content->{error}."\n"
+    if $content->{error};
+
+  #TODO: store this so we can query for a status later
+  warn "Invoice printed, ID ". $content->{id}. "\n";
+
+  $content->{id};
+}
+
 =item _items_sections OPTIONS
 
 Generate section information for all items appearing on this invoice.
@@ -2521,7 +2752,6 @@ sub _items_sections {
       foreach my $sectionname (keys %{ $s->{$locationnum} }) {
         my $section = {
                         'subtotal'    => $s->{$locationnum}{$sectionname},
-                        'post_total'  => $post_total,
                         'sort_weight' => 0,
                       };
         if ( $locationnum ) {
@@ -2882,6 +3112,7 @@ sub _items_fee {
     my %base_invnums; # invnum => invoice date
     foreach ($cust_bill_pkg->cust_bill_pkg_fee) {
       if ($_->base_invnum) {
+        # XXX what if base_bill has been voided?
         my $base_bill = FS::cust_bill->by_key($_->base_invnum);
         my $base_date = $self->time2str_local('short', $base_bill->_date)
           if $base_bill;
@@ -2899,11 +3130,15 @@ sub _items_fee {
     my $desc = $part_fee->itemdesc_locale($self->cust_main->locale);
     # but not escape the base description line
 
+    my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+      if $self->conf->exists('invoice_sections_with_taxes');
+
     push @items,
       { feepart     => $cust_bill_pkg->feepart,
         amount      => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
         description => $desc,
-        ext_description => \@ext_desc
+        pkg_tax     => \@pkg_tax,
+        ext_description => \@ext_desc,
         # sdate/edate?
       };
   }
@@ -3004,6 +3239,8 @@ which does something complicated.
 preref_callback: coderef run for each line item, code should return HTML to be
 displayed before that line item (quotations only)
 
+section_with_taxes:  Look up and include applied taxes for each record
+
 Returns a list of hashrefs, each of which may contain:
 
 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
@@ -3030,12 +3267,34 @@ sub _items_cust_bill_pkg {
   }
   my $summary_page = $opt{summary_page} || ''; #unused
   my $multisection = defined($category) || defined($locationnum);
-  my $discount_show_always = 0;
+  # this variable is the value of the config setting, not whether it applies
+  # to this particular line item.
+  my $discount_show_always = $conf->exists('discount-show-always');
 
-  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
+  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
 
   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
-                                   # and location labels
+
+  # for location labels: use default location on the invoice date
+  my $default_locationnum;
+  if ( $conf->exists('invoice-all_pkg_addresses') ) {
+    $default_locationnum = 0; # treat them all as non-default
+  } elsif ( $self->custnum ) {
+    my $h_cust_main;
+    my @h_search = FS::h_cust_main->sql_h_search($self->_date);
+    $h_cust_main = qsearchs({
+        'table'     => 'h_cust_main',
+        'hashref'   => { custnum => $self->custnum },
+        'extra_sql' => $h_search[1],
+        'addl_from' => $h_search[3],
+    }) || $cust_main;
+    $default_locationnum = $h_cust_main->ship_locationnum;
+  } elsif ( $self->prospectnum ) {
+    my $cust_location = qsearchs('cust_location',
+      { prospectnum => $self->prospectnum,
+        disabled => '' });
+    $default_locationnum = $cust_location->locationnum if $cust_location;
+  }
 
   my @b = (); # accumulator for the line item hashes that we'll return
   my ($s, $r, $u, $d) = ( undef, undef, undef, undef );
@@ -3052,11 +3311,13 @@ sub _items_cust_bill_pkg {
         if (exists($_->{unit_amount})) {
           $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
         }
-        push @b, { %$_ }
-          if $_->{amount} != 0
-          || $discount_show_always
-          || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
-          || (   $_->{_is_setup} && $_->{setup_show_zero} )
+        push @b, { %$_ };
+        # we already decided to create this display line; don't reconsider it
+        # now.
+        #  if $_->{amount} != 0
+        #  || $discount_show_always
+        #  || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+        #  || (   $_->{_is_setup} && $_->{setup_show_zero} )
         ;
         $_ = undef;
       }
@@ -3115,15 +3376,21 @@ sub _items_cust_bill_pkg {
                           'no_usage'        => $opt{'no_usage'},
                         );
 
+      my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+        if $self->conf->exists('invoice_sections_with_taxes');
+
       if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
+        # XXX this should be pulled out into quotation_pkg
 
         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
 
+        my @details = $cust_bill_pkg->details;
+
         # and I guess they're never bundled either?
-        if ( $cust_bill_pkg->setup != 0 ) {
+        if (( $cust_bill_pkg->setup != 0 ) || ( $cust_bill_pkg->setup_show_zero )) {
           my $description = $desc;
           $description .= ' Setup'
             if $cust_bill_pkg->recur != 0
@@ -3137,13 +3404,15 @@ sub _items_cust_bill_pkg {
             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
             'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup),
             'quantity'    => $cust_bill_pkg->quantity,
+            'pkg_tax'     => \@pkg_tax,
+            'ext_description' => \@details,
             'preref_html' => ( $opt{preref_callback}
                                  ? &{ $opt{preref_callback} }( $cust_bill_pkg )
                                  : ''
                              ),
           };
         }
-        if ( $cust_bill_pkg->recur != 0 ) {
+        if (( $cust_bill_pkg->recur != 0 ) || ( $cust_bill_pkg->recur_show_zero )) {
           #push @b, {
           $r = {
             'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
@@ -3151,6 +3420,8 @@ sub _items_cust_bill_pkg {
             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
             'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur),
             'quantity'    => $cust_bill_pkg->quantity,
+            'pkg_tax'     => \@pkg_tax,
+            'ext_description' => \@details,
            'preref_html'  => ( $opt{preref_callback}
                                  ? &{ $opt{preref_callback} }( $cust_bill_pkg )
                                  : ''
@@ -3182,6 +3453,7 @@ sub _items_cust_bill_pkg {
         if (    (!$type || $type eq 'S')
              && (    $cust_bill_pkg->setup != 0
                   || $cust_bill_pkg->setup_show_zero
+                  || ($discount_show_always and $cust_bill_pkg->unitsetup > 0)
                 )
            )
          {
@@ -3189,10 +3461,13 @@ sub _items_cust_bill_pkg {
           warn "$me _items_cust_bill_pkg adding setup\n"
             if $DEBUG > 1;
 
+          # append the word 'Setup' to the setup line if there's going to be
+          # a recur line for the same package (i.e. not a one-time charge) 
+          # XXX localization
           my $description = $desc;
           $description .= ' Setup'
             if $cust_bill_pkg->recur != 0
-            || $discount_show_always
+            || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
             || $cust_bill_pkg->recur_show_zero;
 
           $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
@@ -3209,8 +3484,11 @@ sub _items_cust_bill_pkg {
           # always pass the svc_label through to the template, even if 
           # not displaying it as an ext_description
           my @svc_labels = map &{$escape_function}($_),
-                      $cust_pkg->h_labels_short($self->_date, undef, 'I');
-
+            $cust_pkg->h_labels_short($self->_date,
+                                      undef,
+                                      'I',
+                                      $self->conf->{locale},
+                                     );
           $svc_label = $svc_labels[0];
 
           unless ( $cust_pkg->part_pkg->hide_svc_detail
@@ -3219,11 +3497,10 @@ sub _items_cust_bill_pkg {
 
             push @d, @svc_labels
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
-            my $lnum = $cust_main ? $cust_main->ship_locationnum
-                                  : $self->prospect_main->locationnum;
             # show the location label if it's not the customer's default
             # location, and we're not grouping items by location already
-            if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
+            if ( $cust_pkg->locationnum != $default_locationnum
+                  and !defined($locationnum) ) {
               my $loc = $cust_pkg->location_label;
               $loc = substr($loc, 0, $maxlength). '...'
                 if $format eq 'latex' && length($loc) > $maxlength;
@@ -3249,6 +3526,7 @@ sub _items_cust_bill_pkg {
               setup_show_zero => $cust_bill_pkg->setup_show_zero,
               unit_amount     => $cust_bill_pkg->unitsetup,
               quantity        => $cust_bill_pkg->quantity,
+              pkg_tax         => \@pkg_tax,
               ext_description => \@d,
               svc_label       => ($svc_label || ''),
               locationnum     => $cust_pkg->locationnum, # sure, why not?
@@ -3257,11 +3535,18 @@ sub _items_cust_bill_pkg {
 
         }
 
+        # should we show a recur line?
+        # if type eq 'S', then NO, because we've been told not to.
+        # otherwise, show the recur line if:
+        # - there's a recurring charge
+        # - or recur_show_zero is on
+        # - or there's a positive unitrecur (so it's been discounted to zero)
+        #   and discount-show-always is on
         if (    ( !$type || $type eq 'R' || $type eq 'U' )
              && (
                      $cust_bill_pkg->recur != 0
-                  || $cust_bill_pkg->setup == 0
-                  || $discount_show_always
+                  || !defined($s)
+                  || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
                   || $cust_bill_pkg->recur_show_zero
                 )
            )
@@ -3294,7 +3579,9 @@ sub _items_cust_bill_pkg {
           push @dates, undef if !$prev;
 
           my @svc_labels = map &{$escape_function}($_),
-                      $cust_pkg->h_labels_short(@dates, 'I');
+            $cust_pkg->h_labels_short(@dates,
+                                      'I',
+                                      $self->conf->{locale});
           $svc_label = $svc_labels[0];
 
           # show service labels, unless...
@@ -3321,11 +3608,10 @@ sub _items_cust_bill_pkg {
             warn "$me _items_cust_bill_pkg done adding service details\n"
               if $DEBUG > 1;
 
-            my $lnum = $cust_main ? $cust_main->ship_locationnum
-                                  : $self->prospect_main->locationnum;
             # show the location label if it's not the customer's default
             # location, and we're not grouping items by location already
-            if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
+            if ( $cust_pkg->locationnum != $default_locationnum
+                  and !defined($locationnum) ) {
               my $loc = $cust_pkg->location_label;
               $loc = substr($loc, 0, $maxlength). '...'
                 if $format eq 'latex' && length($loc) > $maxlength;
@@ -3401,6 +3687,7 @@ sub _items_cust_bill_pkg {
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
                 unit_amount     => $unit_amount,
                 quantity        => $cust_bill_pkg->quantity,
+                pkg_tax         => \@pkg_tax,
                 %item_dates,
                 ext_description => \@d,
                 svc_label       => ($svc_label || ''),
@@ -3429,6 +3716,7 @@ sub _items_cust_bill_pkg {
                 amount          => $amount,
                 usage_item      => 1,
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
+                pkg_tax         => \@pkg_tax,
                 %item_dates,
                 ext_description => \@d,
                 locationnum     => $cust_pkg->locationnum,
@@ -3504,9 +3792,6 @@ sub _items_cust_bill_pkg {
 
     } # foreach $display
 
-    $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
-                                && $conf->exists('discount-show-always'));
-
   }
 
   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
@@ -3518,11 +3803,11 @@ sub _items_cust_bill_pkg {
         $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
       }
 
-      push @b, { %$_ }
-        if $_->{amount} != 0
-        || $discount_show_always
-        || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
-        || (   $_->{_is_setup} && $_->{setup_show_zero} )
+      push @b, { %$_ };
+      #if $_->{amount} != 0
+      #  || $discount_show_always
+      #  || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+      #  || (   $_->{_is_setup} && $_->{setup_show_zero} )
     }
   }
 
@@ -3580,4 +3865,66 @@ sub _items_discounts_avail {
 
 }
 
+=item has_sections AGENTNUM
+
+Return true if invoice_sections should be enabled for this bill.
+ (Inherited by both cust_bill and cust_bill_void)
+
+Determination:
+* False if not an invoice
+* True always if conf invoice_sections is enabled
+* True always if sections_by_location is enabled
+* True if conf invoice_sections_multilocation > 1,
+  and location_count >= invoice_sections_multilocation
+* Else, False
+
+=cut
+
+sub has_sections {
+  my ($self, $agentnum) = @_;
+
+  return 0 unless $self->invnum > 0;
+
+  $agentnum ||= $self->cust_main->agentnum;
+  return 1 if $self->conf->exists('invoice_sections', $agentnum);
+  return 1 if $self->conf->exists('sections_by_location', $agentnum);
+
+  my $location_min = $self->conf->config(
+    'invoice_sections_multilocation', $agentnum,
+  );
+
+  return 1
+    if $location_min
+    && $self->location_count >= $location_min;
+
+  0;
+}
+
+
+=item location_count
+
+Return the number of locations billed on this invoice
+
+=cut
+
+sub location_count {
+  my ($self) = @_;
+  return 0 unless $self->invnum;
+
+  # SELECT COUNT( DISTINCT cust_pkg.locationnum )
+  # FROM cust_bill_pkg
+  # LEFT JOIN cust_pkg USING (pkgnum)
+  # WHERE invnum = 278
+  #   AND cust_bill_pkg.pkgnum > 0
+
+  my $result = qsearchs({
+    select    => 'COUNT(DISTINCT cust_pkg.locationnum) as location_count',
+    table     => 'cust_bill_pkg',
+    addl_from => 'LEFT JOIN cust_pkg USING (pkgnum)',
+    extra_sql => 'WHERE invnum = '.dbh->quote( $self->invnum )
+               . '  AND cust_bill_pkg.pkgnum > 0'
+  });
+  ref $result ? $result->location_count : 0;
+}
+
 1;