fix missing plaintext invoice alternative, RT#81486
[freeside.git] / FS / FS / Template_Mixin.pm
index c5a416b..9320e90 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;
@@ -27,9 +29,9 @@ use FS::L10N;
 
 $DEBUG = 0;
 $me = '[FS::Template_Mixin]';
-FS::UID->install_callback( sub { 
+FS::UID->install_callback( sub {
   my $conf = new FS::Conf; #global
-  $money_char  = $conf->config('money_char')  || '$';  
+  $money_char  = $conf->config('money_char')  || '$';
   $date_format = $conf->config('date_format') || '%x'; #/YY
 } );
 
@@ -119,7 +121,7 @@ default is now.  It isn't the date of the invoice; that's the `_date' field.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
-I<template>, if specified, is the name of a suffix for alternate invoices.  
+I<template>, if specified, is the name of a suffix for alternate invoices.
 This is strongly deprecated; see L<FS::invoice_conf> for the right way to
 customize invoice templates for different purposes.
 
@@ -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';
 
@@ -169,7 +175,7 @@ sub print_latex {
   close $lh;
   $params{'logo_file'} = $lh->filename;
 
-  if( $conf->exists('invoice-barcode') 
+  if( $conf->exists('invoice-barcode')
         && $self->can('invoice_barcode')
         && $self->invnum ) { # don't try to barcode statements
       my $png_file = $self->invoice_barcode($dir);
@@ -181,7 +187,7 @@ sub print_latex {
       $eps_file = $1;
 
       my $curr_dir = cwd();
-      chdir($dir); 
+      chdir($dir);
       # after painfuly long experimentation, it was determined that sam2p won't
       #        accept : and other chars in the path, no matter how hard I tried to
       # escape them, hence the chdir (and chdir back, just to be safe)
@@ -194,7 +200,7 @@ sub print_latex {
   }
 
   my @filled_in = $self->print_generic( %params );
-  
+
   my $fh = new File::Temp( TEMPLATE => $tmp_template,
                            DIR      => $dir,
                            SUFFIX   => '.tex',
@@ -293,7 +299,7 @@ before that line item (quotations only)
 
 =item template
 
-Dprecated.  Used as a suffix for a configuration template.  Please 
+Deprecated.  Used as a suffix for a configuration template.  Please
 don't use this, it deprecated in favor of more flexible alternatives.
 
 =back
@@ -338,6 +344,8 @@ sub print_generic {
   $templatefile .= "_$template"
     if length($template) && $conf->exists($templatefile."_$template");
 
+  $self->set('_template',$template);
+
   # the base template
   my @invoice_template = map "$_\n", $conf->config($templatefile)
     or die "cannot load config data $templatefile";
@@ -349,7 +357,7 @@ sub print_generic {
          "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
          #$old_latex = 'true';
          #@invoice_template = _translate_old_latex_format(@invoice_template);
-  } 
+  }
 
   warn "$me print_generic creating T:T object\n"
     if $DEBUG > 1;
@@ -368,7 +376,7 @@ sub print_generic {
 
 
   # additional substitution could possibly cause breakage in existing templates
-  my %convert_maps = ( 
+  my %convert_maps = (
     'latex' => {
                  'notes'         => sub { map "$_", @_ },
                  'footer'        => sub { map "$_", @_ },
@@ -380,7 +388,7 @@ sub print_generic {
     'html'  => {
                  'notes' =>
                    sub {
-                     map { 
+                     map {
                        s/%%(.*)$/<!-- $1 -->/g;
                        s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
                        s/\\begin\{enumerate\}/<ol>/g;
@@ -400,7 +408,7 @@ sub print_generic {
                    sub { map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
                  'returnaddress' =>
                    sub {
-                     map { 
+                     map {
                        s/~/&nbsp;/g;
                        s/\\\\\*?\s*$/<BR>/;
                        s/\\hyphenation\{[\w\s\-]+}//;
@@ -414,7 +422,7 @@ sub print_generic {
     'template' => {
                  'notes' =>
                    sub {
-                     map { 
+                     map {
                        s/%%.*$//g;
                        s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
                        s/\\begin\{enumerate\}//g;
@@ -432,7 +440,7 @@ sub print_generic {
                    sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
                  'returnaddress' =>
                    sub {
-                     map { 
+                     map {
                        s/~/ /g;
                        s/\\\\\*?\s*$/\n/;             # dubious
                        s/\\hyphenation\{[\w\s\-]+}//;
@@ -542,7 +550,7 @@ sub print_generic {
     'quotationnum'    => $self->quotationnum,
     'no_date'         => $params{'no_date'},
     '_date'           => ( $params{'no_date'} ? '' : $self->_date ),
-      # workaround for inconsistent behavior in the early plain text 
+      # workaround for inconsistent behavior in the early plain text
       # templates; see RT#28271
     'date'            => ( $params{'no_date'}
                              ? ''
@@ -557,6 +565,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,
@@ -574,7 +583,7 @@ sub print_generic {
     'smallernotes'    => $conf->exists('invoice-smallernotes'),
     'smallerfooter'   => $conf->exists('invoice-smallerfooter'),
     'balance_due_below_line' => $conf->exists('balance_due_below_line'),
-   
+
     #layout info -- would be fancy to calc some of this and bury the template
     #               here in the code
     'topmargin'             => scalar($conf->config('invoice_latextopmargin', $agentnum)),
@@ -596,7 +605,10 @@ sub print_generic {
     'total_pages'     => 1,
 
   );
+
+  #quotations have $name
+  $invoice_data{'name'} = $invoice_data{'payname'};
+
   #localization
   $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
   # prototype here to silence warnings
@@ -614,7 +626,7 @@ sub print_generic {
 
   $invoice_data{'bill_period'} = '';
   $invoice_data{'bill_period'} =
-      $self->time2str_local('%e %h', $min_sdate, $format) 
+      $self->time2str_local('%e %h', $min_sdate, $format)
       . " to " .
       $self->time2str_local('%e %h', $max_edate, $format)
     if ($max_edate != 0 && $min_sdate != 999999999999);
@@ -624,7 +636,7 @@ sub print_generic {
     my $pkg_class =
       qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
     $invoice_data{finance_section} = $pkg_class->categoryname;
-  } 
+  }
   $invoice_data{finance_amount} = '0.00';
   $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
 
@@ -641,14 +653,15 @@ sub print_generic {
   $invoice_data{'ship_contact'} = $escape_function->($cust_main->contact);
   $invoice_data{'ship_country'} = ''
     if ( $invoice_data{'ship_country'} eq $countrydefault );
-  
+
   $invoice_data{'cid'} = $params{'cid'}
     if $params{'cid'};
 
-  if ( $cust_main->country eq $countrydefault ) {
-    $invoice_data{'country'} = '';
+  if ( $cust_main->bill_locationnum
+       && $cust_main->bill_location->country ne $countrydefault ) {
+    $invoice_data{'country'} = &$escape_function($cust_main->bill_country_full);
   } else {
-    $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
+    $invoice_data{'country'} = '';
   }
 
   my @address = ();
@@ -681,13 +694,34 @@ sub print_generic {
   $invoice_data{'barcode_cid'} = $params{'barcode_cid'}
     if $params{'barcode_cid'};
 
-  my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
-#  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
-  #my $balance_due = $self->owed + $pr_total - $cr_total;
-  my $balance_due = $self->owed + $pr_total;
 
-  # the sum of amount owed on all invoices
-  # (this is used in the summary & on the payment coupon)
+  # re: rt:78190
+  #   using owed_on_invoice() instead of owed() here for $balance_due
+  #   using _items_previous_total() instead of ->previous() for $pr_total
+  #
+  #   owed_on_invoice() is aware of configuration flags that affect how an
+  #     invoice is rendered.  May not return actual current balance. Will
+  #     return balance appropriate for the invoice being rendered, based
+  #     on which past due items, current charges, and future payments are
+  #     displayed.
+  #
+  #   Going forward, usage of owed(), or bypassing cust_bill helper methods
+  #     when generating invoice lines may lead to incorrect or misleading
+  #     math on invoices.
+  #
+  #   Helper methods that are aware of invoicing conf flags:
+  #   - owed_on_invoice          # use instead of owed()
+  #   - _items_previous()        # use instead of previous()
+  #   - _items_credits()         # use instead of cust_credit()
+  #   - _items_payments()
+  #   - _items_total()
+  #   - _items_previous_total()  # use instead of previous()
+  #   - _items_payments_total()
+  #   - _items_credits_total()   # use instead of cust_credit()
+
+  my $pr_total    = $self->_items_previous_total();
+
+  my $balance_due = $self->owed_on_invoice();
   $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
 
   # flag telling this invoice to have a first-page summary
@@ -697,114 +731,101 @@ 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 
+    # 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.
-      my @sql =
-        map "$_ WHERE _date <= ? AND custnum = ?", (
-          "SELECT      COALESCE( SUM(charged), 0 ) FROM cust_bill",
-          "SELECT -1 * COALESCE( SUM(amount),  0 ) FROM cust_credit",
-          "SELECT -1 * COALESCE( SUM(paid),    0 ) FROM cust_pay",
-          "SELECT      COALESCE( SUM(refund),  0 ) FROM cust_refund",
-        );
 
-      # the customer's current balance immediately after generating the last 
-      # bill
+    #    my $last_bill = $self->previous_bill;
+    # if ( $last_bill ) {
 
-      my $last_bill_balance = $last_bill->charged;
-      foreach (@sql) {
-        my $delta = FS::Record->scalar_sql(
-          $_,
-          $last_bill->_date - 1,
-          $self->custnum,
-        );
-        $last_bill_balance += $delta;
-      }
-
-      $last_bill_balance = sprintf("%.2f", $last_bill_balance);
-
-      warn sprintf("LAST BILL: INVNUM %d, DATE %s, BALANCE %.2f\n\n",
-        $last_bill->invnum,
-        $self->time2str_local('%D', $last_bill->_date),
-        $last_bill_balance
-      ) if $DEBUG > 0;
-      # ("true_previous_balance" is a terrible name, but at least it's no
-      # 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;
-      foreach (@sql) {
-        my $delta = FS::Record->scalar_sql(
-          $_,
-          $self->_date - 1,
-          $self->custnum,
-        );
-        $before_this_bill_balance += $delta;
-      }
-      $invoice_data{'balance_adjustments'} =
-        sprintf("%.2f", $last_bill_balance - $before_this_bill_balance);
+    # Populate template stash for previous balance and payments
+    if ($pr_total) {
+      # Used on summary page as "Previous Balance"
+      $invoice_data{'true_previous_balance'} = sprintf("%.2f", $pr_total);
 
-      warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n",
-                   $invoice_data{'balance_adjustments'}
-      ) if $DEBUG > 0;
+      # Used on summary page as "Payments"
+      $invoice_data{'balance_adjustments'} = sprintf("%.2f",
+        $self->_items_payments_total() + $self->_items_credits_total()
+      );
 
-      # the sum of amount owed on all previous invoices
-      # ($pr_total is used elsewhere but not as $previous_balance)
+      # Used in invoice template as "Previous Balance"
       $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
 
-      $invoice_data{'last_bill'}{'_date'} = $last_bill->_date; #unformatted
-      my (@payments, @credits);
-      # for formats that itemize previous payments
-      foreach my $cust_pay ( qsearch('cust_pay', {
-                              'custnum' => $self->custnum,
-                              '_date'   => { op => '>=',
-                                             value => $last_bill->_date }
-                             } ) )
-      {
-        next if $cust_pay->_date > $self->_date;
-        push @payments, {
-            '_date'       => $cust_pay->_date,
-            'date'        => $self->time2str_local('long', $cust_pay->_date, $format),
-            'payinfo'     => $cust_pay->payby_payinfo_pretty,
-            'amount'      => sprintf('%.2f', $cust_pay->paid),
-        };
-        # not concerned about applications
-      }
-      foreach my $cust_credit ( qsearch('cust_credit', {
-                              'custnum' => $self->custnum,
-                              '_date'   => { op => '>=',
-                                             value => $last_bill->_date }
-                             } ) )
-      {
-        next if $cust_credit->_date > $self->_date;
-        push @credits, {
-            '_date'       => $cust_credit->_date,
-            'date'        => $self->time2str_local('long', $cust_credit->_date, $format),
-            'creditreason'=> $cust_credit->reason,
-            'amount'      => sprintf('%.2f', $cust_credit->amount),
-        };
+      # $invoice_data{last_bill}{_date}:
+      # Not used in default templates, but may be in use by someone
+      #
+      # ! May be a problem field if they are using it... this field
+      #   stores the date of the previous invoice... it is possible to
+      #   carry a balance, but have the immediately previous invoice paid off.
+      #   In this case, this field might be presenting bad data?  Not
+      #   altering the problematic behavior, because someone might be
+      #   expecting this bad behavior in their templates for some other
+      #   purpose, such as a "your last bill was dated %_date%"
+      my $last_bill = $self->previous_bill;
+      $invoice_data{'last_bill'}{'_date'}
+        = ref $last_bill
+        ? $last_bill->_date()
+        : undef;
+
+      # $invoice_data{previous_payments}
+      # Not used in default templates, but may be in use by someone
+      #
+      # Returns an array of hrefs representing payments, each with keys:
+      #  - _date:       epoch timestamp
+      #  - date:        text formatted date
+      #  - amount:      money formatted amount string
+      #  - payinfo:     string from payby_payinfo_pretty()
+      #  - paynum:      id for cust_pay
+      #  - description: Text description for bill line item
+      #
+      my @payments = $self->_items_payments();
+      $invoice_data{previous_payments} = \@payments;
+
+      # $invoice_data{previous_credits}
+      # Not used in default templates, but may be in use by someone
+      #
+      # Returns an array of hrefs representing credits, each with keys:
+      #  - _date:        epoch timestamp
+      #  - date:         text formatted date
+      #  - amount:       money formatted amount string
+      #  - crednum:      id for cust_credit
+      #  - description:  Text description for bill line item
+      #  - creditreason: reason() from cust_credit
+      #
+      my @credits = $self->_items_credits();
+      $invoice_data{previous_credits} = \@credits;
+
+      # Populate formatted date field
+      for my $pmt_href (@payments, @credits) {
+        $pmt_href->{date} = $self->time2str_local(
+          'long',
+          $pmt_href->{_date},
+          $format
+        );
       }
-      $invoice_data{'previous_payments'} = \@payments;
-      $invoice_data{'previous_credits'}  = \@credits;
+
     } else {
-      # there is no $last_bill
+      # There are no outstanding invoices    = YAPH
       $invoice_data{'true_previous_balance'} =
       $invoice_data{'balance_adjustments'}   =
       $invoice_data{'previous_balance'}      = '0.00';
-      $invoice_data{'previous_payments'} = [];
-      $invoice_data{'previous_credits'} = [];
+      $invoice_data{'previous_payments'}     =
+      $invoice_data{'previous_credits'}      = [];
+    }
+
+    # Condencing a lot of debug staements here
+    if ($DEBUG) {
+      warn "\$invoice_data{$_}: $invoice_data{$_}"
+        for qw(
+          true_previous_balance
+          balance_adjustments
+          previous_balance
+          previous_payments
+          previous_credits
+        );
     }
-    if ( $conf->exists('invoice_usesummary', $agentnum) ) {
+
+    if ( $conf->config_bool('invoice_usesummary', $agentnum) ) {
       $invoice_data{'summarypage'} = $summarypage = 1;
     }
 
@@ -817,35 +838,36 @@ sub print_generic {
   my @include = ( [ $tc,        'notes' ],
                   [ 'invoice_', 'footer' ],
                   [ 'invoice_', 'smallfooter', ],
+                  [ 'invoice_', 'watermark' ],
                 );
   push @include, [ $tc,        'coupon', ]
     unless $params{'no_coupon'};
 
   foreach my $i (@include) {
 
+    # load the configuration for this sub-template
+
     my($base, $include) = @$i;
 
     my $inc_file = $conf->key_orbase("$base$format$include", $template);
-    my @inc_src;
-
-    if ( $conf->exists($inc_file, $agentnum)
-         && length( $conf->config($inc_file, $agentnum) ) ) {
-
-      @inc_src = $conf->config($inc_file, $agentnum);
-
-    } else {
 
-      $inc_file = $conf->key_orbase("${base}latex$include", $template);
-
-      my $convert_map = $convert_maps{$format}{$include};
-
-      @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
-                       s/--\@\]/$delimiters{$format}[1]/g;
-                       $_;
-                     } 
-                 &$convert_map( $conf->config($inc_file, $agentnum) );
+    my @inc_src = $conf->config($inc_file, $agentnum);
+    if (!@inc_src) {
+      my $converter = $convert_maps{$format}{$include};
+      if ( $converter ) {
+        # then attempt to convert LaTeX to the requested format
+        $inc_file = $conf->key_orbase($base.'latex'.$include, $template);
+        @inc_src = &$converter( $conf->config($inc_file, $agentnum) );
+        foreach (@inc_src) {
+          # this isn't included in the convert_maps
+          my ($open, $close) = @{ $delimiters{$format} };
+          s/\[\@--/$open/g;
+          s/--\@\]/$close/g;
+        }
+      }
+    } # else @inc_src is empty and that's fine
 
-    }
+    # make a Text::Template out of it
 
     my $inc_tt = new Text::Template (
       TYPE       => 'ARRAY',
@@ -859,19 +881,23 @@ sub print_generic {
       die $error;
     }
 
+    # fill in variables
+
     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
 
     $invoice_data{$include} =~ s/\n+$//
       if ($format eq 'latex');
   }
 
-  # let invoices use either of these as needed
-  $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') 
-    ? $cust_main->payinfo : '';
-  $invoice_data{'po_line'} = 
-    (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
-      ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
-      : $nbsp;
+# if (well, probably when) we still need PO numbers in the brave new world of
+# 4.x, then we'll have to add them back as their own customer fields
+#  # let invoices use either of these as needed
+#  $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL')
+#    ? $cust_main->payinfo : '';
+#  $invoice_data{'po_line'} =
+#    (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
+#      ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
+#      : $nbsp;
 
   my %money_chars = ( 'latex'    => '',
                       'html'     => $conf->config('money_char') || '$',
@@ -906,39 +932,50 @@ 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);
-  $invoice_data{'multisection'} = $multisection;
+  my $multisection = $self->has_sections;
+  if ( $multisection ) {
+    $invoice_data{multisection} = $conf->config($tc.'sections_method') || 1;
+  }
   my $late_sections;
   my $extra_sections = [];
   my $extra_lines = ();
 
   # default section ('Charges')
   my $default_section = { 'description' => '',
-                          'subtotal'    => '', 
+                          'subtotal'    => '',
                           'no_subtotal' => 1,
                         };
 
   # Previous Charges section
   # subtotal is the first return value from $self->previous
   my $previous_section;
-  # if the invoice has major sections, or if we're summarizing previous 
+  # if the invoice has major sections, or if we're summarizing previous
   # charges with a single line, or if we've been specifically told to put them
   # in a section, create a section for previous charges:
   if ( $multisection or
        $conf->exists('previous_balance-summary_only') or
        $conf->exists('previous_balance-section') ) {
-    
+
     $previous_section =  { 'description' => $self->mt('Previous Charges'),
                            'subtotal'    => $other_money_char.
                                             sprintf('%.2f', $pr_total),
                            'summarized'  => '', #why? $summarypage ? 'Y' : '',
                          };
-    $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. 
-      join(' / ', map { $cust_main->balance_date_range(@$_) }
-                  $self->_prior_month30s
-          )
-      if $conf->exists('invoice_include_aging');
+
+    # Include balance aging line and template variables
+    my @aged_balances = $self->_items_aging_balances();
+    ( $invoice_data{aged_balance_current},
+      $invoice_data{aged_balance_30d},
+      $invoice_data{aged_balance_60d},
+      $invoice_data{aged_balance_90d}
+    ) = @aged_balances;
+
+    if ($conf->exists('invoice_include_aging')) {
+      $previous_section->{posttotal} = sprintf(
+        '0 / 30 / 60 / 90 days overdue %.2f / %.2f / %.2f / %.2f',
+        @aged_balances,
+      );
+    }
 
   } else {
     # otherwise put them in the main section
@@ -971,7 +1008,7 @@ sub print_generic {
     push @detail_items, @$extra_lines if $extra_lines;
 
     # the code is written so that both methods can be used together, but
-    # we haven't yet changed the template to take advantage of that, so for 
+    # we haven't yet changed the template to take advantage of that, so for
     # now, treat them as mutually exclusive.
     my %section_method = ( by_category => 1 );
     if ( $conf->config($tc.'sections_method') eq 'location' ) {
@@ -1017,7 +1054,7 @@ sub print_generic {
     my @finance_charges;
     my @charges;
     foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
-      if ( $invoice_data{finance_section} and 
+      if ( $invoice_data{finance_section} and
         grep { $_->section eq $invoice_data{finance_section} }
            $cust_bill_pkg->cust_bill_pkg_display ) {
         # I think these are always setup fees, but just to be sure...
@@ -1026,7 +1063,7 @@ sub print_generic {
         push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
       }
     }
-    $invoice_data{finance_amount} = 
+    $invoice_data{finance_amount} =
       sprintf('%.2f', sum( @finance_charges ) || 0);
     $default_section->{subtotal} = $other_money_char.
                                     sprintf('%.2f', sum( @charges ) || 0);
@@ -1055,7 +1092,7 @@ sub print_generic {
     }
   } else {
     # subtotal sectioning is the same as for the actual invoice sections
-    @summary_subtotals = @sections;
+    @summary_subtotals = grep $_->{subtotal}, @sections;
   }
 
   # Hereafter, push sections to both @sections and @summary_subtotals
@@ -1080,7 +1117,7 @@ sub print_generic {
         #quantity        => 1, # not really correct
         section         => $previous_section, # which might be $default_section
         description     => &$escape_function($line_item->{'description'}),
-        ext_description => [ map { &$escape_function($_) } 
+        ext_description => [ map { &$escape_function($_) }
                              @{ $line_item->{'ext_description'} || [] }
                            ],
         amount          => $money_char . $line_item->{'amount'},
@@ -1095,20 +1132,20 @@ sub print_generic {
 
   }
 
-  if ( @pr_cust_bill && $self->enable_previous ) {
+  if ( $pr_total && $self->enable_previous ) {
     push @buf, ['','-----------'];
     push @buf, [ $self->mt('Total Previous Balance'),
                  $money_char. sprintf("%10.2f", $pr_total) ];
     push @buf, ['',''];
   }
+
   if ( $conf->exists('svc_phone-did-summary') && $self->can('_did_summary') ) {
       warn "$me adding DID summary\n"
         if $DEBUG > 1;
 
       my ($didsummary,$minutes) = $self->_did_summary;
       my $didsummary_desc = 'DID Activity Summary (since last invoice)';
-      push @detail_items, 
+      push @detail_items,
        { 'description' => $didsummary_desc,
            'ext_description' => [ $didsummary, $minutes ],
        };
@@ -1126,14 +1163,27 @@ sub print_generic {
       if ( $invoice_data{finance_section} &&
            $section->{'description'} eq $invoice_data{finance_section} );
 
-    $section->{'subtotal'} = $other_money_char.
-                             sprintf('%.2f', $section->{'subtotal'})
-      if $multisection;
+    if ( $multisection ) {
+
+      if ( ref($section->{'subtotal'}) ) {
+
+        $section->{'subtotal'} =
+          sprintf("$other_money_char%.2f to $other_money_char%.2f",
+                    $section->{'subtotal'}[0],
+                    $section->{'subtotal'}[1]
+                 );
+
+      } else {
+
+        $section->{'subtotal'} = $other_money_char.
+                                 sprintf('%.2f', $section->{'subtotal'})
+
+      }
 
-    # continue some normalization
-    $section->{'amount'}   = $section->{'subtotal'}
-      if $multisection;
+      # continue some normalization
+      $section->{'amount'}   = $section->{'subtotal'}
 
+    }
 
     if ( $section->{'description'} ) {
       push @buf, ( [ &$escape_function($section->{'description'}), '' ],
@@ -1146,6 +1196,9 @@ sub print_generic {
 
     my %options = ();
     $options{'section'} = $section if $multisection;
+    $options{'section_with_taxes'} = 1
+      if $multisection
+      && $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum);
     $options{'format'} = $format;
     $options{'escape_function'} = $escape_function;
     $options{'no_usage'} = 1 unless $unsquelched;
@@ -1154,13 +1207,28 @@ sub print_generic {
     $options{'skip_usage'} =
       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
     $options{'preref_callback'} = $params{'preref_callback'};
+    $options{'disable_line_item_date_ranges'} =
+      $conf->exists('disable_line_item_date_ranges');
 
     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) ) {
 
+      # When bill is sectioned by location, fees may be displayed within the
+      # appropriate location section.  Suppress this fee from the taxes/fees
+      # end section, so it doesn't appear to be charged twice and make the
+      # subtotals seem incorrect
+      next
+        if $line_item->{locationnum}
+        && ref $options{section}
+        && !exists $options{section}->{locationnum}
+        && $self->has_sections
+        && $conf->config($tc.'sections_method') eq 'location';
+
       warn "$me     adding line item ".
            join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
         if $DEBUG > 1;
@@ -1181,20 +1249,67 @@ sub print_generic {
         $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
       }
       $line_item->{'ext_description'} ||= [];
+
+      if ( $options{section_with_taxes} && ref $line_item->{pkg_tax} ) {
+        for my $line_tax ( @{$ line_item->{pkg_tax} } ) {
+
+          # It is rarely possible for the same tax record to be presented here
+          # multiple times.  See cust_bill_pkg::_pkg_tax_list for more info
+          next if $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} };
+          $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} } = 1;
+
+          $section_tax_lines{ $line_tax->{taxname} } += $line_tax->{amount};
+        }
+      }
+
       push @detail_items, $line_item;
     }
 
+    # If conf flag invoice_sections_with_taxes:
+    # - Add @detail_items for taxes into each section
+    # - Update section subtotal to include taxes
+    if ( $options{section_with_taxes} && %section_tax_lines ) {
+      for my $taxname ( keys %section_tax_lines ) {
+
+        push @detail_items, {
+          section => $section,
+          amount  => sprintf($money_char."%.2f",$section_tax_lines{$taxname}),
+          description => &$escape_function($taxname),
+        };
+
+        # Append taxes to total.  If line format resembles "$5.00 to $12.00"
+        # append to the second value.
+
+        # $section->{subtotal} = '$5.00 to 12.00'; # for testing:
+        if ($section->{subtotal} =~ /to/) {
+          my @subtotal = split /\s/, $section->{subtotal};
+          $subtotal[2] =~ s/[^\d\.]//g;
+          $subtotal[2] = sprintf(
+            $money_char."%.2f",
+            ( $subtotal[2] + $section_tax_lines{$taxname} )
+          );
+          $section->{subtotal} = join ' ', @subtotal;
+        } else {
+          $section->{subtotal} =~ s/[^\d\.]//g;
+          $section->{subtotal} = sprintf(
+            $money_char . "%.2f",
+            ( $section->{subtotal} + $section_tax_lines{$taxname} )
+          );
+        }
+
+      }
+    }
+
     if ( $section->{'description'} ) {
       push @buf, ( ['','-----------'],
                    [ $section->{'description'}. ' sub-total',
-                      $section->{'subtotal'} # already formatted this 
+                      $section->{'subtotal'} # already formatted this
                    ],
                    [ '', '' ],
                    [ '', '' ],
                  );
     }
-  
+
   }
 
   $invoice_data{current_less_finance} =
@@ -1210,12 +1325,13 @@ sub print_generic {
     if $DEBUG > 1;
 
   # create a tax section if we don't yet have one
+  my @items_tax = $self->_items_tax;
   my $tax_description = 'Taxes, Surcharges, and Fees';
   my $tax_section =
     List::Util::first { $_->{description} eq $tax_description } @sections;
   if (!$tax_section) {
     $tax_section = { 'description' => $tax_description };
-    push @sections, $tax_section if $multisection;
+    push @sections, $tax_section if $multisection and @items_tax > 0;
   }
   $tax_section->{tax_section} = 1; # mark this section as containing taxes
   # if this is an existing tax section, we're merging the tax items into it.
@@ -1230,28 +1346,36 @@ sub print_generic {
   #$tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
   #$tax_section->{'sort_weight'} = $tax_weight;
 
-  my @items_tax = $self->_items_tax;
+  my $invoice_sections_with_taxes = $conf->config_bool(
+    'invoice_sections_with_taxes', $cust_main->agentnum
+  );
+
   foreach my $tax ( @items_tax ) {
 
-    $taxtotal += $tax->{'amount'};
 
     my $description = &$escape_function( $tax->{'description'} );
     my $amount      = sprintf( '%.2f', $tax->{'amount'} );
 
     if ( $multisection ) {
+      if ( !$invoice_sections_with_taxes ) {
+
+        $taxtotal += $tax->{'amount'};
+
+        push @detail_items, {
+          ext_description => [],
+          ref          => '',
+          quantity     => '',
+          description  => $description,
+          amount       => $money_char. $amount,
+          product_code => '',
+          section      => $tax_section,
+        };
 
-      push @detail_items, {
-        ext_description => [],
-        ref          => '',
-        quantity     => '',
-        description  => $description,
-        amount       => $money_char. $amount,
-        product_code => '',
-        section      => $tax_section,
-      };
-
+      }
     } else {
 
+      $taxtotal += $tax->{'amount'};
+
       push @total_items, {
         'total_item'   => $description,
         'total_amount' => $other_money_char. $amount,
@@ -1264,7 +1388,7 @@ sub print_generic {
               ];
 
   }
+
   if ( @items_tax ) {
     my $total = {};
     $total->{'total_item'} = $self->mt('Sub-total');
@@ -1272,20 +1396,35 @@ sub print_generic {
       $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
 
     if ( $multisection ) {
+
+      if ( $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum) ) {
+        # If all tax items are displayed in location/category sections,
+        # remove the empty tax section
+        @sections = grep{ $_ ne $tax_section } @sections
+          unless grep{ $_->{section} eq $tax_section } @detail_items;
+      }
+
       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'} = '';
 
-        # append it if it's not already there
-        if ( !grep $tax_section, @sections ) {
-          push @sections, $tax_section;
-          push @summary_subtotals, $tax_section;
-        }
-      }
+        # append tax section unless it's already there
+        push @sections, $tax_section
+          unless grep {$_ eq $tax_section} @sections;
 
+        push @summary_subtotals, $tax_section
+          unless grep {$_ eq $tax_section} @summary_subtotals;
+
+      }
     } else {
       unshift @total_items, $total;
     }
@@ -1314,7 +1453,7 @@ sub print_generic {
     }
 
   }
-  
+
   if ( $self->can('_items_total') ) { # should always be true now
 
     # even for multisection, need plain text version
@@ -1326,7 +1465,16 @@ sub print_generic {
     foreach ( @new_total_items ) {
       my ($item, $amount) = ($_->{'total_item'}, $_->{'total_amount'});
       $_->{'total_item'}   = &$embolden_function( $item );
-      $_->{'total_amount'} = &$embolden_function( $other_money_char.$amount );
+
+      if ( ref($amount) ) {
+        $_->{'total_amount'} = &$embolden_function(
+                                 $other_money_char.$amount->[0]. ' to '.
+                                 $other_money_char.$amount->[1]
+                               );
+      } else {
+        $_->{'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;
@@ -1336,12 +1484,12 @@ sub print_generic {
     push @buf, [ '', '' ];
 
     # if we're showing previous invoices, also show previous
-    # credits and payments 
-    if ( $self->enable_previous 
+    # credits and payments
+    if ( $self->enable_previous
           and $self->can('_items_credits')
           and $self->can('_items_payments') )
       {
-    
+
       # credits
       my $credittotal = 0;
       foreach my $credit (
@@ -1403,7 +1551,7 @@ sub print_generic {
                    ];
       }
       $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
-    
+
       if ( $multisection ) {
         $adjust_section->{'subtotal'} = $other_money_char.
                                         sprintf('%.2f', $credittotal + $paymenttotal);
@@ -1412,21 +1560,21 @@ sub print_generic {
         #in @extra_sections instead of @sections. obviously.
         push @sections, $adjust_section
           unless $adjust_section->{sort_weight};
-        # do not summarize; adjustments there are shown according to 
+        # do not summarize; adjustments there are shown according to
         # different rules
       }
 
       # create Balance Due message
-      { 
+      {
         my $total;
         $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
         $total->{'total_amount'} =
           &$embolden_function(
-            $other_money_char. sprintf('%.2f', #why? $summarypage 
+            $other_money_char. sprintf('%.2f', #why? $summarypage
                                                #  ? $self->charged +
                                                #    $self->billing_balance
                                                #  :
-                                                   $self->owed + $pr_total
+                                                   $balance_due
                                       )
           );
         if ( $multisection && !$adjust_section->{sort_weight} ) {
@@ -1436,7 +1584,7 @@ sub print_generic {
           push @total_items, $total;
         }
         push @buf,['','-----------'];
-        push @buf,[$self->balance_due_msg, $money_char. 
+        push @buf,[$self->balance_due_msg, $money_char.
           sprintf("%10.2f", $balance_due ) ];
       }
 
@@ -1456,7 +1604,7 @@ sub print_generic {
           push @total_items, $credit_total;
         }
         push @buf,['','-----------'];
-        push @buf,[$self->credit_balance_msg, $money_char. 
+        push @buf,[$self->credit_balance_msg, $money_char.
           sprintf("%10.2f", -$cust_main->balance ) ];
       }
     }
@@ -1472,7 +1620,7 @@ sub print_generic {
       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
       $total->{'total_amount'} =
         &$embolden_function(
-          $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
+          $other_money_char. sprintf('%.2f', $balance_due)
         );
       my $last_section = pop @sections;
       $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
@@ -1484,7 +1632,7 @@ sub print_generic {
   }
 
   # make a discounts-available section, even without multisection
-  if ( $conf->exists('discount-show_available') 
+  if ( $conf->exists('discount-show_available')
        and my @discounts_avail = $self->_items_discounts_avail ) {
     my $discount_section = {
       'description' => $self->mt('Discounts Available'),
@@ -1508,7 +1656,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;
@@ -1516,7 +1664,7 @@ sub print_generic {
   }
 
   # invoice history "section" (not really a section)
-  # not to be included in any subtotals, completely independent of 
+  # not to be included in any subtotals, completely independent of
   # everything...
   if ( $conf->exists('previous_invoice_history') and $cust_main->isa('FS::cust_main') ) {
     my %history;
@@ -1547,7 +1695,7 @@ sub print_generic {
   }
   $invoice_data{location_info} = \%location_info;
 
-  # debugging hook: call this with 'diag' => 1 to just get a hash of 
+  # debugging hook: call this with 'diag' => 1 to just get a hash of
   # the invoice variables
   return \%invoice_data if ( $params{'diag'} );
 
@@ -1572,7 +1720,7 @@ sub print_generic {
       @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
                        s/--\@\]/$delimiters{$format}[1]/g;
                        $_;
-                     } 
+                     }
                  &$convert_map( $conf->config($inc_file, $agentnum) );
 
     }
@@ -1605,24 +1753,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;
@@ -1636,6 +1784,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"
@@ -1647,22 +1802,9 @@ sub print_generic {
 
 sub notice_name { '('.shift->table.')'; }
 
-sub template_conf { 'invoice_'; }
-
-# helper routine for generating date ranges
-sub _prior_month30s {
-  my $self = shift;
-  my @ranges = (
-   [ 1,       2592000 ], # 0-30 days ago
-   [ 2592000, 5184000 ], # 30-60 days ago
-   [ 5184000, 7776000 ], # 60-90 days ago
-   [ 7776000, 0       ], # 90+   days ago
-  );
-
-  map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
-          $_->[1] ? $self->_date - $_->[1] - 1 : '',
-      ] }
-  @ranges;
+# this is not supposed to happen
+sub template_conf { warn "bare FS::Template_Mixin::template_conf";
+  'invoice_';
 }
 
 =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
@@ -1743,23 +1885,23 @@ sub print_html {
   my $self = shift;
   my %params;
   if ( ref($_[0]) ) {
-    %params = %{ shift() }; 
+    %params = %{ shift() };
   } else {
     %params = @_;
   }
   $params{'format'} = 'html';
-  
+
   $self->print_generic( %params );
 }
 
 # quick subroutine for print_latex
 #
 # There are ten characters that LaTeX treats as special characters, which
-# means that they do not simply typeset themselves: 
+# means that they do not simply typeset themselves:
 #      # $ % & ~ _ ^ \ { }
 #
 # TeX ignores blanks following an escaped character; if you want a blank (as
-# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
+# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ...").
 
 sub _latex_escape {
   my $value = shift;
@@ -1784,18 +1926,18 @@ sub _html_escape_nbsp {
 
 sub _translate_old_latex_format {
   warn "_translate_old_latex_format called\n"
-    if $DEBUG; 
+    if $DEBUG;
 
   my @template = ();
   while ( @_ ) {
     my $line = shift;
-  
+
     if ( $line =~ /^%%Detail\s*$/ ) {
-  
+
       push @template, q![@--!,
                       q!  foreach my $_tr_line (@detail_items) {!,
                       q!    if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
-                      q!      $_tr_line->{'description'} .= !, 
+                      q!      $_tr_line->{'description'} .= !,
                       q!        "\\tabularnewline\n~~".!,
                       q!        join( "\\tabularnewline\n~~",!,
                       q!          @{$_tr_line->{'ext_description'}}!,
@@ -1831,9 +1973,9 @@ sub _translate_old_latex_format {
 
     } else {
       $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
-      push @template, $line;  
+      push @template, $line;
     }
-  
+
   }
 
   if ($DEBUG) {
@@ -1853,7 +1995,7 @@ sub terms {
 
   #check for an invoice-specific override
   return $self->invoice_terms if $self->invoice_terms;
-  
+
   #check for a customer- specific override
   my $cust_main = $self->cust_main;
   return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
@@ -1878,6 +2020,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;
 }
@@ -1891,6 +2039,23 @@ sub due_date2str {
   $self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
 }
 
+=item invoice_pay_by_msg
+
+  displays the invoice_pay_by_msg or default Please pay by [_1] if empty.
+
+=cut
+
+sub invoice_pay_by_msg {
+  my $self = shift;
+  my $msg = '';
+  my $please_pay_by =
+        $self->conf->config('invoice_pay_by_msg', $self->agentnum)
+        || 'Please pay by [_1]';
+  $msg .= ' - ' . $self->mt($please_pay_by, $self->due_date2str('short')) . ' ';
+
+  $msg;
+}
+
 =item balance_due_msg
 
 =cut
@@ -1900,13 +2065,13 @@ sub balance_due_msg {
   my $msg = $self->mt('Balance Due');
   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 
+       || $self->has_sections ) {
+    # if enabled, the due date is shown with Total New Charges (see
     # _items_total) and not here
     # (yes, or if invoice_sections is enabled; this is just for compatibility)
     if ( $self->due_date ) {
-      $msg .= ' - ' . $self->mt('Please pay by'). ' '.
-        $self->due_date2str('short');
+      $msg .= $self->invoice_pay_by_msg
+       unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum);
     } elsif ( $self->terms ) {
       $msg .= ' - '. $self->mt($self->terms);
     }
@@ -1929,7 +2094,7 @@ sub balance_due_date {
   $duedate;
 }
 
-sub credit_balance_msg { 
+sub credit_balance_msg {
   my $self = shift;
   $self->mt('Credit Balance Remaining')
 }
@@ -2032,6 +2197,7 @@ Returns an argument list to be passed to L<FS::Misc::send_email>.
 =cut
 
 use MIME::Entity;
+use Encode;
 
 sub generate_email {
 
@@ -2073,13 +2239,22 @@ sub generate_email {
 
       my $msg_template = FS::msg_template->by_key($msgnum)
         or die "${tc}email_pdf_msgnum $msgnum not found\n";
-      my %prepared = $msg_template->prepare(
+      my $cust_msg = $msg_template->prepare(
         cust_main => $self->cust_main,
-        object    => $self
+        object    => $self,
+        msgtype   => 'invoice',
       );
 
-      @text = split(/(?=\n)/, $prepared{'text_body'});
-      $html = $prepared{'html_body'};
+      # XXX hack to make this work in the new cust_msg era; consider replacing
+      # with cust_bill_send_with_notice events.
+      my @parts = $cust_msg->parts;
+      foreach my $part (@parts) { # will only have two parts, normally
+        if ( $part->mime_type eq 'text/plain' ) {
+          @text = @{ $part->body };
+        } elsif ( $part->mime_type eq 'text/html' ) {
+          $html = $part->bodyhandle->as_string;
+        }
+      }
 
     } elsif ( my @note = $conf->config($tc.'email_pdf_note') ) {
 
@@ -2087,17 +2262,25 @@ sub generate_email {
         if $DEBUG;
       @text = $conf->config($tc.'email_pdf_note');
       $html = join('<BR>', @text);
-  
+
     } # else use the plain text invoice
   }
 
   if (!@text) {
 
-    warn "$me generating plain text invoice"
-      if $DEBUG;
+    if ( $conf->exists($tc.'template') ) {
+
+      warn "$me generating plain text invoice"
+        if $DEBUG;
+
+      @text = $self->print_text(\%args);
+
+    } else {
 
-    # 'print_text' argument is no longer used
-    @text = $self->print_text(\%args);
+      warn "$me no plain text version exists; sending empty message body"
+        if $DEBUG;
+
+    }
 
   }
 
@@ -2106,7 +2289,11 @@ sub generate_email {
     'Encoding'    => 'quoted-printable',
     'Charset'     => 'UTF-8',
     #'Encoding'    => '7bit',
-    'Data'        => \@text,
+    'Data'        => [
+      map
+        { Encode::encode('UTF-8', $_, Encode::FB_WARN | Encode::LEAVE_SRC ) }
+        @text
+    ],
     'Disposition' => 'inline',
   );
 
@@ -2140,7 +2327,7 @@ sub generate_email {
         'Filename'   => 'logo.png',
         'Content-ID' => "<$content_id>",
       ;
-   
+
       if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) {
         my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
         push @related_parts, build MIME::Entity
@@ -2181,11 +2368,15 @@ sub generate_email {
       'Data'        => [ '<html>',
                          '  <head>',
                          '    <title>',
-                         '      '. encode_entities($return{'subject'}), 
+                         '      '. encode_entities($return{'subject'}),
                          '    </title>',
                          '  </head>',
                          '  <body bgcolor="#e8e8e8">',
-                         $html,
+                         Encode::encode(
+                           'UTF-8',
+                           $html,
+                           Encode::FB_WARN | Encode::LEAVE_SRC
+                         ),
                          '  </body>',
                          '</html>',
                        ],
@@ -2212,15 +2403,42 @@ sub generate_email {
   my @otherparts = ();
   if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
 
-    push @otherparts, build MIME::Entity
-      'Type'        => 'text/csv',
-      'Encoding'    => '7bit',
-      'Data'        => [ map { "$_\n" }
-                           $self->call_details('prepend_billed_number' => 1)
-                       ],
-      'Disposition' => 'attachment',
-      'Filename'    => 'usage-'. $self->invnum. '.csv',
-    ;
+    if ( $conf->config('voip-cdr_email_attach') eq 'zip' ) {
+
+      my $data = join('', map "$_\n",
+                   $self->call_details(prepend_billed_number=>1)
+                 );
+
+      my $zip = new Archive::Zip;
+      my $file = $zip->addString( $data, 'usage-'.$self->invnum.'.csv' );
+      $file->desiredCompressionMethod( COMPRESSION_DEFLATED );
+
+      my $zipdata = '';
+      my $SH = IO::Scalar->new(\$zipdata);
+      my $status = $zip->writeToFileHandle($SH);
+      die "Error zipping CDR attachment: $!" unless $status == AZ_OK;
+
+      push @otherparts, build MIME::Entity
+        'Type'        => 'application/zip',
+        'Encoding'    => 'base64',
+        'Data'        => $zipdata,
+        'Disposition' => 'attachment',
+        'Filename'    => 'usage-'. $self->invnum. '.zip',
+      ;
+
+    } else { # } elsif ( $conf->config('voip-cdr_email_attach') eq 'csv' ) {
+
+      push @otherparts, build MIME::Entity
+        'Type'        => 'text/csv',
+        'Encoding'    => '7bit',
+        'Data'        => [ map { "$_\n" }
+                             $self->call_details('prepend_billed_number' => 1)
+                         ],
+        'Disposition' => 'attachment',
+        'Filename'    => 'usage-'. $self->invnum. '.csv',
+      ;
+
+    }
 
   }
 
@@ -2264,7 +2482,7 @@ sub generate_email {
 =item mimebuild_pdf
 
 Returns a list suitable for passing to MIME::Entity->build(), representing
-this invoice as PDF attachment.
+this quotation or invoice as PDF attachment.
 
 =cut
 
@@ -2275,8 +2493,115 @@ sub mimebuild_pdf {
     'Encoding'    => 'base64',
     'Data'        => [ $self->print_pdf(@_) ],
     'Disposition' => 'attachment',
-    'Filename'    => 'invoice-'. $self->invnum. '.pdf',
+    'Filename'    => $self->pdf_filename,
+  );
+}
+
+=item postal_mail_fsinc
+
+Sends this invoice to the Freeside Internet Services, Inc. print and mail
+service.
+
+=cut
+
+use CAM::PDF;
+use IO::Socket::SSL;
+use LWP::UserAgent;
+use HTTP::Request::Common qw( POST );
+use Cpanel::JSON::XS;
+use MIME::Base64;
+sub postal_mail_fsinc {
+  my ( $self, %opt ) = @_;
+
+  if ( $FS::Misc::DISABLE_PRINT ) {
+    warn 'postal_mail_fsinc() disabled by $FS::Misc::DISABLE_PRINT' if $DEBUG;
+    return;
+  }
+
+  my $url = 'https://ws.freeside.biz/print';
+
+  my $cust_main = $self->cust_main;
+  my $agentnum = $cust_main->agentnum;
+  my $bill_location = $cust_main->bill_location;
+
+  die "Extra charges for international mailing; contact support\@freeside.biz to enable\n"
+    if $bill_location->country ne 'US';
+
+  my $conf = new FS::Conf;
+
+  my @company_address = $conf->config('company_address', $agentnum);
+  my ( $company_address1, $company_address2, $company_city, $company_state, $company_zip );
+  if ( $company_address[2] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
+    $company_address1 = $company_address[0];
+    $company_address2 = $company_address[1];
+    $company_city  = $1;
+    $company_state = $2;
+    $company_zip   = $3;
+  } elsif ( $company_address[1] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
+    $company_address1 = $company_address[0];
+    $company_address2 = '';
+    $company_city  = $1;
+    $company_state = $2;
+    $company_zip   = $3;
+  } else {
+    die "Unparsable company_address; contact support\@freeside.biz\n";
+  }
+  $company_city =~ s/,$//;
+
+  my $file = $self->print_pdf(%opt, 'no_addresses' => 1);
+  my $pages = CAM::PDF->new($file)->numPages;
+
+  my $ua = LWP::UserAgent->new(
+    'ssl_opts' => {
+      verify_hostname => 0,
+      SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
+      SSL_version     => 'SSLv3',
+    }
   );
+  my $response = $ua->request( POST $url, [
+    'support-key'      => scalar($conf->config('support-key')),
+    'file'             => encode_base64($file),
+    'pages'            => $pages,
+
+    #from:
+    'company_name'     => scalar( $conf->config('company_name', $agentnum) ),
+    'company_address1' => $company_address1,
+    'company_address2' => $company_address2,
+    'company_city'     => $company_city,
+    'company_state'    => $company_state,
+    'company_zip'      => $company_zip,
+    'company_country'  => 'US',
+    'company_phonenum' => scalar($conf->config('company_phonenum', $agentnum)),
+    'company_email'    => scalar($conf->config('invoice_from', $agentnum)),
+
+    #to:
+    'name'             => $cust_main->invoice_attn
+                            || $cust_main->contact_firstlast,
+    'company'          => $cust_main->company,
+    'address1'         => $bill_location->address1,
+    'address2'         => $bill_location->address2,
+    'city'             => $bill_location->city,
+    'state'            => $bill_location->state,
+    'zip'              => $bill_location->zip,
+    'country'          => $bill_location->country,
+  ]);
+
+  die "Print connection error: ". $response->message.
+      ' ('. $response->as_string. ")\n"
+    unless $response->is_success;
+
+  local $@;
+  my $content = eval { decode_json($response->content) };
+  die "Print JSON error : $@\n" if $@;
+
+  die $content->{error}."\n"
+    if $content->{error};
+
+  #TODO: store this so we can query for a status later
+  warn "Invoice printed, ID ". $content->{id}. "\n";
+
+  $content->{id};
+
 }
 
 =item _items_sections OPTIONS
@@ -2284,13 +2609,13 @@ sub mimebuild_pdf {
 Generate section information for all items appearing on this invoice.
 This will only be called for multi-section invoices.
 
-For each line item (L<FS::cust_bill_pkg> record), this will fetch all 
-related display records (L<FS::cust_bill_pkg_display>) and organize 
-them into two groups ("early" and "late" according to whether they come 
-before or after the total), then into sections.  A subtotal is calculated 
+For each line item (L<FS::cust_bill_pkg> record), this will fetch all
+related display records (L<FS::cust_bill_pkg_display>) and organize
+them into two groups ("early" and "late" according to whether they come
+before or after the total), then into sections.  A subtotal is calculated
 for each section.
 
-Section descriptions are returned in sort weight order.  Each consists 
+Section descriptions are returned in sort weight order.  Each consists
 of a hash containing:
 
 description: the package category name, escaped
@@ -2299,7 +2624,7 @@ tax_section: a flag indicating that the section contains only tax charges
 summarized: same as tax_section, for some reason
 sort_weight: the package category's sort weight
 
-If 'condense' is set on the display record, it also contains everything 
+If 'condense' is set on the display record, it also contains everything
 returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
 coderefs to generate parts of the invoice.  This is not advised.
 
@@ -2308,32 +2633,32 @@ sections.
 
 OPTIONS may include:
 
-by_location: a flag to divide the invoice into sections by location.  
-Each section hash will have a 'location' element containing a hashref of 
+by_location: a flag to divide the invoice into sections by location.
+Each section hash will have a 'location' element containing a hashref of
 the location fields (see L<FS::cust_location>).  The section description
-will be the location label, but the template can use any of the location 
+will be the location label, but the template can use any of the location
 fields to create a suitable label.
 
-by_category: a flag to divide the invoice into sections using display 
-records (see L<FS::cust_bill_pkg_display>).  This is the "traditional" 
+by_category: a flag to divide the invoice into sections using display
+records (see L<FS::cust_bill_pkg_display>).  This is the "traditional"
 behavior.  Each section hash will have a 'category' element containing
-the section name from the display record (which probably equals the 
+the section name from the display record (which probably equals the
 category name of the package, but may not in some cases).
 
 summary: a flag indicating that this is a summary-format invoice.
 Turning this on has the following effects:
 - Ignores display items with the 'summary' flag.
 - Places all sections in the "early" group even if they have post_total.
-- Creates sections for all non-disabled package categories, even if they 
+- Creates sections for all non-disabled package categories, even if they
 have no charges on this invoice, as well as a section with no name.
 
 escape: an escape function to use for section titles.
 
-extra_sections: an arrayref of additional sections to return after the 
-sorted list.  If there are any of these, section subtotals exclude 
+extra_sections: an arrayref of additional sections to return after the
+sorted list.  If there are any of these, section subtotals exclude
 usage charges.
 
-format: 'latex', 'html', or 'template' (i.e. text).  Not used, but 
+format: 'latex', 'html', or 'template' (i.e. text).  Not used, but
 passed through to C<_condense_section()>.
 
 =cut
@@ -2342,7 +2667,7 @@ use vars qw(%pkg_category_cache);
 sub _items_sections {
   my $self = shift;
   my %opt = @_;
-  
+
   my $escape = $opt{escape};
   my @extra_sections = @{ $opt{extra_sections} || [] };
 
@@ -2355,12 +2680,12 @@ sub _items_sections {
   my %not_tax = ();
 
   # About tax items + multisection invoices:
-  # If either invoice_*summary option is enabled, AND there is a 
-  # package category with the name of the tax, then there will be 
+  # If either invoice_*summary option is enabled, AND there is a
+  # package category with the name of the tax, then there will be
   # a display record assigning the tax item to that category.
   #
   # However, the taxes are always placed in the "Taxes, Surcharges,
-  # and Fees" section regardless of that.  The only effect of the 
+  # and Fees" section regardless of that.  The only effect of the
   # display record is to create a subtotal for the summary page.
 
   # cache these
@@ -2388,7 +2713,13 @@ sub _items_sections {
       foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
         next if ( $display->summary && $opt{summary} );
 
-        my $section = $display->section;
+        #my $section = $display->section;
+        #false laziness with the method, but for efficiency inside this loop
+        my $section = $display->get('section');
+        if ( !$section && !$cust_bill_pkg->hidden ) {
+          $section = $cust_bill_pkg->get('categoryname'); #cust_bill->cust_bill_pkg added it (XXX quotations / quotation_section)
+        }
+
         my $type    = $display->type;
         # Set $section = undef if we're sectioning by location and this
         # line item _has_ a location (i.e. isn't a fee).
@@ -2399,11 +2730,11 @@ sub _items_sections {
           if $cust_bill_pkg->pkgnum  or $cust_bill_pkg->feepart;
 
         # there's actually a very important piece of logic buried in here:
-        # incrementing $late_subtotal{$section} CREATES 
-        # $late_subtotal{$section}.  keys(%late_subtotal) is later used 
+        # incrementing $late_subtotal{$section} CREATES
+        # $late_subtotal{$section}.  keys(%late_subtotal) is later used
         # to define the list of late sections, and likewise keys(%subtotal).
-        # When _items_cust_bill_pkg is called to generate line items for 
-        # real, it will be called with 'section' => $section for each 
+        # When _items_cust_bill_pkg is called to generate line items for
+        # real, it will be called with 'section' => $section for each
         # of these.
         if ( $display->post_total && !$opt{summary} ) {
           if (! $type || $type eq 'S') {
@@ -2423,7 +2754,7 @@ sub _items_sections {
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
           }
-          
+
           if ($type && $type eq 'U') {
             $late_subtotal{$locationnum}{$section} += $usage
               unless scalar(@extra_sections);
@@ -2479,17 +2810,16 @@ sub _items_sections {
       foreach my $sectionname (keys %{ $s->{$locationnum} }) {
         my $section = {
                         'subtotal'    => $s->{$locationnum}{$sectionname},
-                        'post_total'  => $post_total,
                         'sort_weight' => 0,
                       };
         if ( $locationnum ) {
           $section->{'locationnum'} = $locationnum;
           my $location = FS::cust_location->by_key($locationnum);
           $section->{'description'} = &{ $escape }($location->location_label);
-          # Better ideas? This will roughly group them by proximity, 
+          # Better ideas? This will roughly group them by proximity,
           # which alpha sorting on any of the address fields won't.
           # Sorting by locationnum is meaningless.
-          # We have to sort on _something_ or the order may change 
+          # We have to sort on _something_ or the order may change
           # randomly from one invoice to the next, which will confuse
           # people.
           $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
@@ -2518,10 +2848,10 @@ sub _items_sections {
       } # foreach $sectionname
     } #foreach $locationnum
     push @these, @extra_sections if $post_total == 0;
-    # need an alpha sort for location sections, because postal codes can 
+    # need an alpha sort for location sections, because postal codes can
     # be non-numeric
     $sections[ $post_total ] = [ sort {
-      $opt{'by_location'} ? 
+      $opt{'by_location'} ?
         ($a->{sort_weight} cmp $b->{sort_weight}) :
         ($a->{sort_weight} <=> $b->{sort_weight})
       } @these ];
@@ -2766,8 +3096,8 @@ sub _condensed_total_line_generator {
 
 =item _items_pkg [ OPTIONS ]
 
-Return line item hashes for each package item on this invoice. Nearly 
-equivalent to 
+Return line item hashes for each package item on this invoice. Nearly
+equivalent to
 
 $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
 
@@ -2814,6 +3144,10 @@ sub _items_fee {
   my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
   my $escape_function = $options{escape_function};
 
+  my $locale = $self->cust_main
+             ? $self->cust_main->locale
+             : $self->prospect_main->locale;
+
   my @items;
   foreach my $cust_bill_pkg (@cust_bill_pkg) {
     # cache this, so we don't look it up again in every section
@@ -2825,17 +3159,31 @@ sub _items_fee {
       warn "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n";
       next;
     }
-    if ( exists($options{section}) and exists($options{section}{category}) )
-    {
-      my $categoryname = $options{section}{category};
-      # then filter for items that have that section
-      if ( $part_fee->categoryname ne $categoryname ) {
-        warn "skipping fee '".$part_fee->itemdesc."'--not in section $categoryname\n" if $DEBUG;
-        next;
-      }
-    } # otherwise include them all in the main section
-    # XXX what to do when sectioning by location?
-    
+
+    # If _items_fee is called while building a sectioned invoice,
+    #   - invoice_sections_method: category
+    #     Skip fee records that do not match the section category.
+    #   - invoice_sections_method: location
+    #     Skip fee records always for location sections.
+    #     The fee records will be presented in the tax/fee section instead.
+    if (
+      exists( $options{section} )
+      and
+      (
+        (
+          exists( $options{section}{category} )
+          and
+          $part_fee->categoryname ne $options{section}{category}
+        )
+        or
+        exists( $options{section}{location})
+      )
+    ) {
+      warn "skipping fee '".$part_fee->itemdesc.
+           "'--not in section $options{section}{category}\n" if $DEBUG;
+      next;
+    }
+
     my @ext_desc;
     my %base_invnums; # invnum => invoice date
     foreach ($cust_bill_pkg->cust_bill_pkg_fee) {
@@ -2854,14 +3202,19 @@ sub _items_fee {
           $self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_})
         );
     }
-    my $desc = $part_fee->itemdesc_locale($self->cust_main->locale);
+    my $desc = $part_fee->itemdesc_locale($locale);
     # but not escape the base description line
 
+    my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+      if $options{section_with_taxes};
+
     push @items,
       { feepart     => $cust_bill_pkg->feepart,
+        billpkgnum  => $cust_bill_pkg->billpkgnum,
         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?
       };
   }
@@ -2923,7 +3276,7 @@ sub _taxsort {
 
 sub _items_tax {
   my $self = shift;
-  my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart } 
+  my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart }
     $self->cust_bill_pkg;
   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
 
@@ -2952,20 +3305,19 @@ escape_function: the function used to escape strings.
 DEPRECATED? (expensive, mostly unused?)
 format_function: the function used to format CDRs.
 
-section: a hashref containing 'category' and/or 'locationnum'; if this 
+section: a hashref containing 'category' and/or 'locationnum'; if this
 is present, only returns line items that belong to that category and/or
 location (whichever is defined).
 
 multisection: a flag indicating that this is a multisection invoice,
 which does something complicated.
 
-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 
-ext_description, which is an arrayref of detail lines to show below 
+pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and
+ext_description, which is an arrayref of detail lines to show below
 the package line.
 
 =cut
@@ -2988,15 +3340,21 @@ 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') || 40;
 
   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
 
+  my $agentnum = $self->agentnum;
+
   # for location labels: use default location on the invoice date
   my $default_locationnum;
-  if ( $self->custnum ) {
+  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({
@@ -3021,6 +3379,9 @@ sub _items_cust_bill_pkg {
     # if the current line item is waiting to go out, and the one we're about
     # to start is not bundled, then push out the current one and start a new
     # one.
+    if ( $d ) {
+      $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
+    }
     foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
       if ( $_ && !$cust_bill_pkg->hidden ) {
         $_->{amount}      = sprintf( "%.2f", $_->{amount} );
@@ -3028,11 +3389,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;
       }
@@ -3042,13 +3405,13 @@ sub _items_cust_bill_pkg {
       # this is a location section; skip packages that aren't at this
       # service location.
       next if $cust_bill_pkg->pkgnum == 0; # skips fees...
-      next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum 
+      next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum
               != $locationnum;
     }
 
     # Consider display records for this item to determine if it belongs
     # in this section.  Note that if there are no display records, there
-    # will be a default pseudo-record that includes all charge types 
+    # will be a default pseudo-record that includes all charge types
     # and has no section name.
     my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
                                   ? $cust_bill_pkg->cust_bill_pkg_display
@@ -3065,7 +3428,7 @@ sub _items_cust_bill_pkg {
                                 @cust_bill_pkg_display;
     } else {
       # otherwise, process all display records that aren't usage summaries
-      # (I don't think there should be usage summaries if you aren't using 
+      # (I don't think there should be usage summaries if you aren't using
       # category sections, but this is the historical behavior)
       @cust_bill_pkg_display = grep { !$_->summary }
                                 @cust_bill_pkg_display;
@@ -3091,63 +3454,19 @@ sub _items_cust_bill_pkg {
                           'no_usage'        => $opt{'no_usage'},
                         );
 
-      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
-
-        # and I guess they're never bundled either?
-        if ( $cust_bill_pkg->setup != 0 ) {
-          my $description = $desc;
-          $description .= ' Setup'
-            if $cust_bill_pkg->recur != 0
-            || $discount_show_always
-            || $cust_bill_pkg->recur_show_zero;
-          #push @b, {
-          # keep it consistent, please
-          $s = {
-            'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
-            'description' => $description,
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
-            'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup),
-            'quantity'    => $cust_bill_pkg->quantity,
-            'preref_html' => ( $opt{preref_callback}
-                                 ? &{ $opt{preref_callback} }( $cust_bill_pkg )
-                                 : ''
-                             ),
-          };
-        }
-        if ( $cust_bill_pkg->recur != 0 ) {
-          #push @b, {
-          $r = {
-            'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
-            'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
-            'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur),
-            'quantity'    => $cust_bill_pkg->quantity,
-           'preref_html'  => ( $opt{preref_callback}
-                                 ? &{ $opt{preref_callback} }( $cust_bill_pkg )
-                                 : ''
-                             ),
-          };
-        }
-
-      } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
+      if ( $cust_bill_pkg->pkgnum > 0 ) {
         # a "normal" package line item (not a quotation, not a fee, not a tax)
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
           if $DEBUG > 1;
+
         my $cust_pkg = $cust_bill_pkg->cust_pkg;
         my $part_pkg = $cust_pkg->part_pkg;
 
         # which pkgpart to show for display purposes?
         my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
 
-        # start/end dates for invoice formats that do nonstandard 
+        # start/end dates for invoice formats that do nonstandard
         # things with them
         my %item_dates = ();
         %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
@@ -3156,9 +3475,13 @@ sub _items_cust_bill_pkg {
         # not normally used, but pass this to the template anyway
         $classname = $part_pkg->classname;
 
+        my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+          if $opt{section_with_taxes};
+
         if (    (!$type || $type eq 'S')
              && (    $cust_bill_pkg->setup != 0
                   || $cust_bill_pkg->setup_show_zero
+                  || ($discount_show_always and $cust_bill_pkg->unitsetup > 0)
                 )
            )
          {
@@ -3166,14 +3489,24 @@ sub _items_cust_bill_pkg {
           warn "$me _items_cust_bill_pkg adding setup\n"
             if $DEBUG > 1;
 
+          # append the word 'Setup' to the setup line if there's going to be
+          # a recur line for the same package (i.e. not a one-time charge)
+          # XXX localization
           my $description = $desc;
           $description .= ' Setup'
             if $cust_bill_pkg->recur != 0
-            || $discount_show_always
+            || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
             || $cust_bill_pkg->recur_show_zero;
 
-          $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
-                                                              $self->agentnum )
+          my $disable_date_ranges =
+               $opt{disable_line_item_date_ranges}
+            || $part_pkg->option('disable_line_item_date_ranges', 1);
+
+          $description .= $cust_bill_pkg->time_period_pretty(
+                            $part_pkg,
+                            $agentnum,
+                            disable_date_ranges => $disable_date_ranges,
+                          )
             if $part_pkg->is_prepaid #for prepaid, "display the validity period
                                      # triggered by the recurring charge freq
                                      # (RT#26274)
@@ -3181,16 +3514,24 @@ sub _items_cust_bill_pkg {
             && ! $cust_bill_pkg->recur_show_zero;
 
           my @d = ();
-          my $svc_label;
+          my @svc_labels = ();
+          my $svc_label = '';
+
+          unless ( $part_pkg->hide_svc_detail ) {
 
-          # 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');
+            # still pass the svc_label through to the template, even if
+            # not displaying it as an ext_description
+            @svc_labels = map &{$escape_function}($_),
+              $cust_pkg->h_labels_short($self->_date,
+                                        undef,
+                                        'I',
+                                        $self->conf->{locale},
+                                       );
+            $svc_label = $svc_labels[0];
 
-          $svc_label = $svc_labels[0];
+          }
 
-          unless ( $cust_pkg->part_pkg->hide_svc_detail
+          unless ( $part_pkg->hide_svc_detail
                 || $cust_bill_pkg->hidden )
           {
 
@@ -3217,6 +3558,7 @@ sub _items_cust_bill_pkg {
             push @{ $s->{ext_description} }, @d;
           } else {
             $s = {
+              billpkgnum      => $cust_bill_pkg->billpkgnum,
               _is_setup       => 1,
               description     => $description,
               pkgpart         => $pkgpart,
@@ -3228,16 +3570,24 @@ sub _items_cust_bill_pkg {
               ext_description => \@d,
               svc_label       => ($svc_label || ''),
               locationnum     => $cust_pkg->locationnum, # sure, why not?
+              pkg_tax         => \@pkg_tax,
             };
           };
 
         }
 
+        # should we show a recur line?
+        # if type eq 'S', then NO, because we've been told not to.
+        # otherwise, show the recur line if:
+        # - there's a recurring charge
+        # - or recur_show_zero is on
+        # - or there's a positive unitrecur (so it's been discounted to zero)
+        #   and discount-show-always is on
         if (    ( !$type || $type eq 'R' || $type eq 'U' )
              && (
                      $cust_bill_pkg->recur != 0
-                  || $cust_bill_pkg->setup == 0
-                  || $discount_show_always
+                  || !defined($s)
+                  || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
                   || $cust_bill_pkg->recur_show_zero
                 )
            )
@@ -3253,13 +3603,19 @@ sub _items_cust_bill_pkg {
             $description = $self->mt('Usage charges');
           }
 
-          my $part_pkg = $cust_pkg->part_pkg;
+          my $disable_date_ranges =
+               $opt{disable_line_item_date_ranges}
+            || $part_pkg->option('disable_line_item_date_ranges', 1);
 
-          $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
-                                                              $self->agentnum );
+          $description .= $cust_bill_pkg->time_period_pretty(
+                                    $part_pkg,
+                                    $agentnum,
+                                    disable_date_ranges => $disable_date_ranges,
+                          );
 
           my @d = ();
           my @seconds = (); # for display of usage info
+          my @svc_labels = ();
           my $svc_label = '';
 
           #at least until cust_bill_pkg has "past" ranges in addition to
@@ -3269,9 +3625,13 @@ sub _items_cust_bill_pkg {
           push @dates, $prev->sdate if $prev;
           push @dates, undef if !$prev;
 
-          my @svc_labels = map &{$escape_function}($_),
-                      $cust_pkg->h_labels_short(@dates, 'I');
-          $svc_label = $svc_labels[0];
+          unless ( $part_pkg->hide_svc_detail ) {
+            @svc_labels = map &{$escape_function}($_),
+              $cust_pkg->h_labels_short(@dates,
+                                        'I',
+                                        $self->conf->{locale});
+            $svc_label = $svc_labels[0];
+          }
 
           # show service labels, unless...
                     # the package is set not to display them
@@ -3283,7 +3643,7 @@ sub _items_cust_bill_pkg {
                     # or this is a usage summary line
                 || $is_summary && $type && $type eq 'U'
                     # or this is a usage line and there's a recurring line
-                    # for the package in the same section (which will 
+                    # for the package in the same section (which will
                     # have service labels already)
                 || ($type eq 'U' and defined($r))
               )
@@ -3309,17 +3669,17 @@ sub _items_cust_bill_pkg {
 
             # Display of seconds_since_sqlradacct:
             # On the invoice, when processing @detail_items, look for a field
-            # named 'seconds'.  This will contain total seconds for each 
-            # service, in the same order as @ext_description.  For services 
+            # named 'seconds'.  This will contain total seconds for each
+            # service, in the same order as @ext_description.  For services
             # that don't support this it will show undef.
-            if ( $conf->exists('svc_acct-usage_seconds') 
+            if ( $conf->exists('svc_acct-usage_seconds')
                  and ! $cust_bill_pkg->pkgpart_override ) {
-              foreach my $cust_svc ( 
-                  $cust_pkg->h_cust_svc(@dates, 'I') 
+              foreach my $cust_svc (
+                  $cust_pkg->h_cust_svc(@dates, 'I')
                 ) {
 
-                # eval because not having any part_export_usage exports 
-                # is a fatal error, last_bill/_date because that's how 
+                # eval because not having any part_export_usage exports
+                # is a fatal error, last_bill/_date because that's how
                 # sqlradius_hour billing does it
                 my $sec = eval {
                   $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
@@ -3344,7 +3704,7 @@ sub _items_cust_bill_pkg {
 
           warn "$me _items_cust_bill_pkg calculating amount\n"
             if $DEBUG > 1;
-  
+
           my $amount = 0;
           if (!$type) {
             $amount = $cust_bill_pkg->recur;
@@ -3353,7 +3713,7 @@ sub _items_cust_bill_pkg {
           } elsif ($type eq 'U') {
             $amount = $cust_bill_pkg->usage;
           }
-  
+
           if ( !$type || $type eq 'R' ) {
 
             warn "$me _items_cust_bill_pkg adding recur\n"
@@ -3369,6 +3729,7 @@ sub _items_cust_bill_pkg {
               push @{ $r->{ext_description} }, @d;
             } else {
               $r = {
+                billpkgnum      => $cust_bill_pkg->billpkgnum,
                 description     => $description,
                 pkgpart         => $pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
@@ -3380,6 +3741,7 @@ sub _items_cust_bill_pkg {
                 ext_description => \@d,
                 svc_label       => ($svc_label || ''),
                 locationnum     => $cust_pkg->locationnum,
+                pkg_tax         => \@pkg_tax,
               };
               $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
             }
@@ -3398,6 +3760,7 @@ sub _items_cust_bill_pkg {
             } elsif ( $amount ) {
               # create a new usage line
               $u = {
+                billpkgnum      => $cust_bill_pkg->billpkgnum,
                 description     => $description,
                 pkgpart         => $pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
@@ -3407,6 +3770,7 @@ sub _items_cust_bill_pkg {
                 %item_dates,
                 ext_description => \@d,
                 locationnum     => $cust_pkg->locationnum,
+                pkg_tax         => \@pkg_tax,
               };
             } # else this has no usage, so don't create a usage section
           }
@@ -3421,11 +3785,11 @@ sub _items_cust_bill_pkg {
         # items of this kind should normally not have sdate/edate.
         push @b, {
           'description' => $desc,
-          'amount'      => sprintf('%.2f', $cust_bill_pkg->setup 
+          'amount'      => sprintf('%.2f', $cust_bill_pkg->setup
                                            + $cust_bill_pkg->recur)
         };
 
-      } # if quotation / package line item / other line item
+      } # if package line item / other line item
 
       # decide whether to show active discounts here
       if (
@@ -3434,7 +3798,7 @@ sub _items_cust_bill_pkg {
           # case 2: we are showing a setup line for a package that has
           # no base recurring fee
           or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
-          # case 3: we are showing a recur line for a package that has 
+          # case 3: we are showing a recur line for a package that has
           # a base recurring fee
           or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
       ) {
@@ -3444,44 +3808,29 @@ sub _items_cust_bill_pkg {
           # $item_discount->{amount} is negative
 
           if ( $d and $cust_bill_pkg->hidden ) {
-            $d->{amount}      += $item_discount->{amount};
+            $d->{setup_amount} += $item_discount->{setup_amount};
+            $d->{recur_amount} += $item_discount->{recur_amount};
           } else {
             $d = $item_discount;
             $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} };
           }
 
-          # update the active line (before the discount) to show the 
+          # update the active line (before the discount) to show the
           # original price (whether this is a hidden line or not)
-          #
-          # quotation discounts keep track of setup and recur; invoice 
-          # discounts currently don't
-          if ( exists $item_discount->{setup_amount} ) {
-
-            $s->{amount} -= $item_discount->{setup_amount} if $s;
-            $r->{amount} -= $item_discount->{recur_amount} if $r;
-
-          } else {
-
-            # $active_line is the line item hashref for the line that will
-            # show the original price
-            # (use the recur or single line for the package, unless we're 
-            # showing a setup line for a package with no recurring fee)
-            my $active_line = $r;
-            if ( $type eq 'S' ) {
-              $active_line = $s;
-            }
-            $active_line->{amount} -= $item_discount->{amount};
 
-          }
+          $s->{amount} -= $item_discount->{setup_amount} if $s;
+          $r->{amount} -= $item_discount->{recur_amount} if $r;
 
         } # if there are any discounts
       } # if this is an appropriate place to show discounts
 
     } # foreach $display
 
-    $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
-                                && $conf->exists('discount-show-always'));
+  }
 
+  # discount amount is internally split up
+  if ( $d ) {
+    $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
   }
 
   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
@@ -3493,11 +3842,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} )
     }
   }
 
@@ -3511,9 +3860,9 @@ sub _items_cust_bill_pkg {
 =item _items_discounts_avail
 
 Returns an array of line item hashrefs representing available term discounts
-for this invoice.  This makes the same assumptions that apply to term 
-discounts in general: that the package is billed monthly, at a flat rate, 
-with no usage charges.  A prorated first month will be handled, as will 
+for this invoice.  This makes the same assumptions that apply to term
+discounts in general: that the package is billed monthly, at a flat rate,
+with no usage charges.  A prorated first month will be handled, as will
 a setup fee if the discount is allowed to apply to setup fees.
 
 =cut
@@ -3521,7 +3870,7 @@ a setup fee if the discount is allowed to apply to setup fees.
 sub _items_discounts_avail {
   my $self = shift;
 
-  #maybe move this method from cust_bill when quotations support discount_plans 
+  #maybe move this method from cust_bill when quotations support discount_plans
   return () unless $self->can('discount_plans');
   my %plans = $self->discount_plans;
 
@@ -3533,7 +3882,7 @@ sub _items_discounts_avail {
     my $plan = $plans{$months};
 
     my $term_total = sprintf('%.2f', $plan->discounted_total);
-    my $percent = sprintf('%.0f', 
+    my $percent = sprintf('%.0f',
                           100 * (1 - $term_total / $plan->base_total) );
     my $permonth = sprintf('%.2f', $term_total / $months);
     my $detail = $self->mt('discount on item'). ' '.
@@ -3546,7 +3895,7 @@ sub _items_discounts_avail {
     +{
       description => $self->mt('Save [_1]% by paying for [_2] months',
                                 $percent, $months),
-      amount      => $self->mt('[_1] ([_2] per month)', 
+      amount      => $self->mt('[_1] ([_2] per month)',
                                 $term_total, $money_char.$permonth),
       ext_description => ($detail || ''),
     }
@@ -3555,4 +3904,68 @@ sub _items_discounts_avail {
 
 }
 
+=item has_sections AGENTNUM
+
+Return true if invoice_sections should be enabled for this bill.
+ (Inherited by both cust_bill and cust_bill_void)
+
+Determination:
+* False if not an invoice
+* True always if conf invoice_sections is enabled
+* True always if sections_by_location is enabled
+* True if conf invoice_sections_multilocation > 1,
+  and location_count >= invoice_sections_multilocation
+* Else, False
+
+=cut
+
+sub has_sections {
+  my ($self, $agentnum) = @_;
+
+  return 0 unless $self->invnum > 0;
+
+  $agentnum ||= $self->agentnum;
+  return 1 if $self->conf->config_bool('invoice_sections', $agentnum);
+  return 1 if $self->conf->exists('sections_by_location', $agentnum);
+
+  my $location_min = $self->conf->config(
+    'invoice_sections_multilocation', $agentnum,
+  );
+
+  return 1
+    if $location_min
+    && $self->location_count >= $location_min;
+
+  0;
+}
+
+
+=item location_count
+
+Return the number of locations billed on this invoice
+
+=cut
+
+sub location_count {
+  my ($self) = @_;
+  return 0 unless $self->invnum;
+
+  # SELECT COUNT( DISTINCT cust_pkg.locationnum )
+  # FROM cust_bill_pkg
+  # LEFT JOIN cust_pkg USING (pkgnum)
+  # WHERE invnum = 278
+  #   AND cust_bill_pkg.pkgnum > 0
+
+  my $result = qsearchs({
+    select    => 'COUNT(DISTINCT cust_pkg.locationnum) as location_count',
+    table     => 'cust_bill_pkg',
+    addl_from => 'LEFT JOIN cust_pkg USING (pkgnum)',
+    extra_sql => 'WHERE invnum = '.dbh->quote( $self->invnum )
+               . '  AND cust_bill_pkg.pkgnum > 0'
+  });
+  ref $result ? $result->location_count : 0;
+}
+
+
+
 1;