Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / Template_Mixin.pm
index 990f31f..0928ee5 100644 (file)
@@ -2,7 +2,9 @@ package FS::Template_Mixin;
 
 use strict;
 use vars qw( $DEBUG $me
-             $money_char $date_format $rdate_format $date_format_long );
+             $money_char
+             $date_format
+           );
              # but NOT $conf
 use vars qw( $invoice_lines @buf ); #yuck
 use List::Util qw(sum);
@@ -26,10 +28,8 @@ $DEBUG = 0;
 $me = '[FS::Template_Mixin]';
 FS::UID->install_callback( sub { 
   my $conf = new FS::Conf; #global
-  $money_char       = $conf->config('money_char')       || '$';  
-  $date_format      = $conf->config('date_format')      || '%x'; #/YY
-  $rdate_format     = $conf->config('date_format')      || '%m/%d/%Y';  #/YYYY
-  $date_format_long = $conf->config('date_format_long') || '%b %o, %Y';
+  $money_char  = $conf->config('money_char')  || '$';  
+  $date_format = $conf->config('date_format') || '%x'; #/YY
 } );
 
 =item conf [ MODE ]
@@ -220,26 +220,82 @@ Internal method - returns a filled-in template for this invoice as a scalar.
 
 See print_ps and print_pdf for methods that return PostScript and PDF output.
 
-Non optional options include 
-  format - latex, html, template
+Required options
 
-Optional options include
+=over 4
 
-template - a value used as a suffix for a configuration template.  Please 
-don't use this.
+=item format
 
-time - a value used to control the printing of overdue messages.  The
+The B<format> option is required and should be set to html, latex (print and PDF) or template (plaintext).
+
+=back
+
+Additional options
+
+=over 4
+
+=item notice_name
+
+Overrides "Invoice" as the name of the sent document.
+
+=item today
+
+Used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
-cid - 
+=item logo_file
+
+Logo file (path to temporary EPS file on the local filesystem)
+
+=item cid
+
+CID for inline (emailed) images (logo)
+
+=item locale
+
+Override customer's locale
+
+=item unsquelch_cdr
+
+Overrides any per customer cdr squelching when true
+
+=item no_number
+
+Supress the (invoice, quotation, statement, etc.) number
+
+=item no_date
+
+Supress the date
+
+=item no_coupon
+
+Supress the payment coupon
+
+=item barcode_file
 
-unsquelch_cdr - overrides any per customer cdr squelching when true
+Barcode file (path to temporary EPS file on the local filesystem)
 
-notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+=item barcode_img
 
-locale - override customer's locale
+Flag indicating the barcode image should be a link (normal HTML dipaly)
+
+=item barcode_cid
+
+Barcode CID for inline (emailed) images
+
+=item preref_callback
+
+Coderef run for each line item, code should return HTML to be displayed
+before that line item (quotations only)
+
+=item template
+
+Dprecated.  Used as a suffix for a configuration template.  Please 
+don't use this, it deprecated in favor of more flexible alternatives.
+
+=back
 
 =cut
 
@@ -288,13 +344,13 @@ sub print_generic {
   my @invoice_template = map "$_\n", $conf->config($templatefile)
     or die "cannot load config data $templatefile";
 
-  my $old_latex = '';
   if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
     #change this to a die when the old code is removed
-    warn "old-style invoice template $templatefile; ".
+    # it's been almost ten years, changing it to a die.
+    die "old-style invoice template $templatefile; ".
          "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
-    $old_latex = 'true';
-    @invoice_template = _translate_old_latex_format(@invoice_template);
+         #$old_latex = 'true';
+         #@invoice_template = _translate_old_latex_format(@invoice_template);
   } 
 
   warn "$me print_generic creating T:T object\n"
@@ -406,14 +462,6 @@ sub print_generic {
   my $escape_function_nonbsp = ($format eq 'html')
                                  ? \&_html_escape : $escape_function;
 
-  my %date_formats = ( 'latex'    => $date_format_long,
-                       'html'     => $date_format_long,
-                       'template' => '%s',
-                     );
-  $date_formats{'html'} =~ s/ /&nbsp;/g;
-
-  my $date_format = $date_formats{$format};
-
   my %newline_tokens = (  'latex'     => '\\\\',
                           'html'      => '<br>',
                           'template'  => "\n",
@@ -496,16 +544,21 @@ 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 
+      # templates; see RT#28271
     'date'            => ( $params{'no_date'}
                              ? ''
-                             : time2str($date_format, $self->_date)
+                             : ($format eq 'template'
+                               ? $self->_date
+                               : $self->time2str_local('long', $self->_date, $format)
+                               )
                          ),
-    'today'           => time2str($date_format_long, $today),
+    'today'           => $self->time2str_local('long', $today, $format),
     'terms'           => $self->terms,
     'template'        => $template, #params{'template'},
     'notice_name'     => $notice_name, # escape?
     'current_charges' => sprintf("%.2f", $self->charged),
-    'duedate'         => $self->due_date2str($rdate_format), #date_format?
+    'duedate'         => $self->due_date2str('rdate'), #date_format?
 
     #customer info
     'custnum'         => $cust_main->display_custnum,
@@ -545,15 +598,9 @@ sub print_generic {
   );
  
   #localization
-  my $lh = FS::L10N->get_handle( $locale );
   $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
-  my %info = FS::Locales->locale_info($cust_main->locale || 'en_US');
-  # eval to avoid death for unimplemented languages
-  my $dh = eval { Date::Language->new($info{'name'}) } ||
-           Date::Language->new(); # fall back to English
   # prototype here to silence warnings
-  $invoice_data{'time2str'} = sub ($;$$) { $dh->time2str(@_) };
-  # eventually use this date handle everywhere in here, too
+  $invoice_data{'time2str'} = sub ($;$$) { $self->time2str_local(@_, $format) };
 
   my $min_sdate = 999999999999;
   my $max_edate = 0;
@@ -566,8 +613,10 @@ sub print_generic {
   }
 
   $invoice_data{'bill_period'} = '';
-  $invoice_data{'bill_period'} = time2str('%e %h', $min_sdate) 
-    . " to " . time2str('%e %h', $max_edate)
+  $invoice_data{'bill_period'} =
+      $self->time2str_local('%e %h', $min_sdate, $format) 
+      . " to " .
+      $self->time2str_local('%e %h', $max_edate, $format)
     if ($max_edate != 0 && $min_sdate != 999999999999);
 
   $invoice_data{finance_section} = '';
@@ -637,18 +686,6 @@ sub print_generic {
   #my $balance_due = $self->owed + $pr_total - $cr_total;
   my $balance_due = $self->owed + $pr_total;
 
-  #these are used on the summary page only
-
-    # the customer's current balance as shown on the invoice before this one
-    $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) );
-
-    # the change in balance from that invoice to this one
-    $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) );
-
-    # the sum of amount owed on all previous invoices
-    # ($pr_total is used elsewhere but not as $previous_balance)
-    $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
-
   # the sum of amount owed on all invoices
   # (this is used in the summary & on the payment coupon)
   $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
@@ -659,8 +696,69 @@ sub print_generic {
 
   if ( $self->custnum && $self->invnum ) {
 
-    if ( $self->previous_bill ) {
-      my $last_bill = $self->previous_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 = (
+        'SELECT SUM(charged) FROM cust_bill WHERE _date <= ? AND custnum = ?',
+        'SELECT -1*SUM(amount) FROM cust_credit WHERE _date <= ? AND custnum = ?',
+        'SELECT -1*SUM(paid) FROM cust_pay  WHERE _date <= ? AND custnum = ?',
+        'SELECT SUM(refund) FROM cust_refund WHERE _date <= ? AND custnum = ?',
+      );
+
+      # the customer's current balance immediately after generating the last 
+      # bill
+
+      my $last_bill_balance = $last_bill->charged;
+      foreach (@sql) {
+        #warn "$_\n";
+        my $delta = FS::Record->scalar_sql(
+          $_,
+          $last_bill->_date - 1,
+          $self->custnum,
+        );
+        #warn "$delta\n";
+        $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) {
+        #warn "$_\n";
+        my $delta = FS::Record->scalar_sql(
+          $_,
+          $self->_date - 1,
+          $self->custnum,
+        );
+        #warn "$delta\n";
+        $before_this_bill_balance += $delta;
+      }
+      $invoice_data{'balance_adjustments'} =
+        sprintf("%.2f", $last_bill_balance - $before_this_bill_balance);
+
+      warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n",
+                   $invoice_data{'balance_adjustments'}
+      ) if $DEBUG > 0;
+
+      # the sum of amount owed on all previous invoices
+      # ($pr_total is used elsewhere but not as $previous_balance)
+      $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
+
       $invoice_data{'last_bill'} = {
         '_date'     => $last_bill->_date, #unformatted
       };
@@ -675,7 +773,7 @@ sub print_generic {
         next if $cust_pay->_date > $self->_date;
         push @payments, {
             '_date'       => $cust_pay->_date,
-            'date'        => time2str($date_format, $cust_pay->_date),
+            'date'        => $self->time2str_local('long', $cust_pay->_date, $format),
             'payinfo'     => $cust_pay->payby_payinfo_pretty,
             'amount'      => sprintf('%.2f', $cust_pay->paid),
         };
@@ -690,16 +788,22 @@ sub print_generic {
         next if $cust_credit->_date > $self->_date;
         push @credits, {
             '_date'       => $cust_credit->_date,
-            'date'        => time2str($date_format, $cust_credit->_date),
+            'date'        => $self->time2str_local('long', $cust_credit->_date, $format),
             'creditreason'=> $cust_credit->reason,
             'amount'      => sprintf('%.2f', $cust_credit->amount),
         };
       }
       $invoice_data{'previous_payments'} = \@payments;
       $invoice_data{'previous_credits'}  = \@credits;
+    } else {
+      # there is no $last_bill
+      $invoice_data{'true_previous_balance'} =
+      $invoice_data{'balance_adjustments'}   =
+      $invoice_data{'previous_balance'}      = '0.00';
+      $invoice_data{'previous_payments'} = [];
+      $invoice_data{'previous_credits'} = [];
     }
-
-  }
+  } # if this is an invoice
 
   my $summarypage = '';
   if ( $conf->exists('invoice_usesummary', $agentnum) ) {
@@ -776,6 +880,7 @@ sub print_generic {
                     );
   my $money_char = $money_chars{$format};
 
+  # extremely dubious
   my %other_money_chars = ( 'latex'    => '\dollar ',#XXX should be a config too
                             'html'     => $conf->config('money_char') || '$',
                             'template' => '',
@@ -878,7 +983,7 @@ sub print_generic {
     # we haven't yet changed the template to take advantage of that, so for 
     # now, treat them as mutually exclusive.
     my %section_method = ( by_category => 1 );
-    if ( $conf->exists($tc.'sections_by_location') ) {
+    if ( $conf->config($tc.'sections_method') eq 'location' ) {
       %section_method = ( by_location => 1 );
     }
     my ($early, $late) =
@@ -936,6 +1041,36 @@ sub print_generic {
                                     sprintf('%.2f', sum( @charges ) || 0);
   }
 
+  # start setting up summary subtotals
+  my @summary_subtotals;
+  my $method = $conf->config('summary_subtotals_method');
+  if ( $method and $method ne $conf->config($tc.'sections_method') ) {
+    # then re-section them by the correct method
+    my %section_method = ( by_category => 1 );
+    if ( $conf->config('summary_subtotals_method') eq 'location' ) {
+      %section_method = ( by_location => 1 );
+    }
+    my ($early, $late) =
+      $self->_items_sections( 'summary' => $summarypage,
+                              'escape'  => $escape_function_nonbsp,
+                              'extra_sections' => $extra_sections,
+                              'format'  => $format,
+                              %section_method
+                            );
+    foreach ( @$early ) {
+      next if $_->{subtotal} == 0;
+      $_->{subtotal} = $other_money_char.sprintf('%.2f', $_->{subtotal});
+      push @summary_subtotals, $_;
+    }
+  } else {
+    # subtotal sectioning is the same as for the actual invoice sections
+    @summary_subtotals = @sections;
+  }
+
+  # Hereafter, push sections to both @sections and @summary_subtotals
+  # if they belong in both places (e.g. tax section).  Late sections are
+  # never in @summary_subtotals.
+
   # previous invoice balances in the Previous Charges section if there
   # is one, otherwise in the main detail section
   # (except if summary_only is enabled, don't show them at all)
@@ -951,14 +1086,13 @@ sub print_generic {
       my $detail = {
         ref             => $line_item->{'pkgnum'},
         pkgpart         => $line_item->{'pkgpart'},
-        quantity        => 1,
+        #quantity        => 1, # not really correct
         section         => $previous_section, # which might be $default_section
         description     => &$escape_function($line_item->{'description'}),
         ext_description => [ map { &$escape_function($_) } 
                              @{ $line_item->{'ext_description'} || [] }
                            ],
-        amount          => ( $old_latex ? '' : $money_char).
-                            $line_item->{'amount'},
+        amount          => $money_char . $line_item->{'amount'},
         product_code    => $line_item->{'pkgpart'} || 'N/A',
       };
 
@@ -1028,44 +1162,34 @@ sub print_generic {
     $options{'summary_page'} = $summarypage;
     $options{'skip_usage'} =
       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
+    $options{'preref_callback'} = $params{'preref_callback'};
 
     warn "$me   searching for line items\n"
       if $DEBUG > 1;
 
-    foreach my $line_item ( $self->_items_pkg(%options) ) {
+    foreach my $line_item ( $self->_items_pkg(%options),
+                            $self->_items_fee(%options) ) {
 
-      warn "$me     adding line item $line_item\n"
+      warn "$me     adding line item ".
+           join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
         if $DEBUG > 1;
 
-      my $detail = {
-        ext_description => [],
-      };
-      $detail->{'ref'} = $line_item->{'pkgnum'};
-      $detail->{'pkgpart'} = $line_item->{'pkgpart'};
-      $detail->{'quantity'} = $line_item->{'quantity'};
-      $detail->{'section'} = $section;
-      $detail->{'description'} = &$escape_function($line_item->{'description'});
-      if ( exists $line_item->{'ext_description'} ) {
-        @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
-      }
-      $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
-                              $line_item->{'amount'};
-      if ( exists $line_item->{'unit_amount'} ) {
-        $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
-                                   $line_item->{'unit_amount'};
-      }
-      $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+      $line_item->{'ref'} = $line_item->{'pkgnum'};
+      $line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()?
+      $line_item->{'section'} = $section;
+      $line_item->{'description'} = &$escape_function($line_item->{'description'});
+      $line_item->{'amount'} = $money_char.$line_item->{'amount'};
 
-      $detail->{'sdate'} = $line_item->{'sdate'};
-      $detail->{'edate'} = $line_item->{'edate'};
-      $detail->{'seconds'} = $line_item->{'seconds'};
-      $detail->{'svc_label'} = $line_item->{'svc_label'};
-  
-      push @detail_items, $detail;
-      push @buf, ( [ $detail->{'description'},
+      if ( length($line_item->{'unit_amount'}) ) {
+        $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
+      }
+      $line_item->{'ext_description'} ||= [];
+      push @detail_items, $line_item;
+      push @buf, ( [ $line_item->{'description'},
                      $money_char. sprintf("%10.2f", $line_item->{'amount'}),
                    ],
-                   map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
+                   map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
                  );
     }
 
@@ -1087,6 +1211,7 @@ sub print_generic {
   # if there's anything in the Previous Charges section, prepend it to the list
   if ( $pr_total and $previous_section ne $default_section ) {
     unshift @sections, $previous_section;
+    # but not @summary_subtotals
   }
 
   warn "$me adding taxes\n"
@@ -1102,13 +1227,12 @@ sub print_generic {
 
     if ( $multisection ) {
 
-      my $money = $old_latex ? '' : $money_char;
       push @detail_items, {
         ext_description => [],
         ref          => '',
         quantity     => '',
         description  => $description,
-        amount       => $money. $amount,
+        amount       => $money_char. $amount,
         product_code => '',
         section      => $tax_section,
       };
@@ -1139,8 +1263,11 @@ sub print_generic {
                                    sprintf('%.2f', $taxtotal);
       $tax_section->{'pretotal'} = 'New charges sub-total '.
                                    $total->{'total_amount'};
-      push @sections, $tax_section if $taxtotal;
-    }else{
+      if ( $taxtotal ) {
+        push @sections, $tax_section;
+        push @summary_subtotals, $tax_section;
+      }
+    } else {
       unshift @total_items, $total;
     }
   }
@@ -1233,13 +1360,12 @@ sub print_generic {
         $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
         $adjusttotal += $credit->{'amount'};
         if ( $multisection ) {
-          my $money = $old_latex ? '' : $money_char;
           push @detail_items, {
             ext_description => [],
             ref          => '',
             quantity     => '',
             description  => &$escape_function($credit->{'description'}),
-            amount       => $money. $credit->{'amount'},
+            amount       => $money_char . $credit->{'amount'},
             product_code => '',
             section      => $adjust_section,
           };
@@ -1268,13 +1394,12 @@ sub print_generic {
         $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
         $adjusttotal += $payment->{'amount'};
         if ( $multisection ) {
-          my $money = $old_latex ? '' : $money_char;
           push @detail_items, {
             ext_description => [],
             ref          => '',
             quantity     => '',
             description  => &$escape_function($payment->{'description'}),
-            amount       => $money. $payment->{'amount'},
+            amount       => $money_char . $payment->{'amount'},
             product_code => '',
             section      => $adjust_section,
           };
@@ -1292,6 +1417,8 @@ sub print_generic {
                                         sprintf('%.2f', $adjusttotal);
         push @sections, $adjust_section
           unless $adjust_section->{sort_weight};
+        # do not summarize; adjustments there are shown according to 
+        # different rules
       }
 
       # create Balance Due message
@@ -1370,7 +1497,7 @@ sub print_generic {
       'no_subtotal' => 1,
     };
 
-    push @sections, $discount_section;
+    push @sections, $discount_section; # do not summarize
     push @detail_items, map { +{
         'ref'         => '', #should this be something else?
         'section'     => $discount_section,
@@ -1380,24 +1507,50 @@ sub print_generic {
     } } @discounts_avail;
   }
 
-  my @summary_subtotals;
-  # the templates say "$_->{tax_section} || !$_->{summarized}"
-  # except 'summarized' is only true when tax_section is true, so this 
-  # is always true, so what's the deal?
-  foreach my $s (@sections) {
-    # not to include in the "summary of new charges" block:
-    # finance charges, adjustments, previous charges, 
-    # and itemized phone usage sections
-    if ( $s eq $adjust_section   or
-         ($s eq $previous_section and $s ne $default_section) or
-         ($invoice_data{'finance_section'} and 
-          $invoice_data{'finance_section'} eq $s->{description}) or
-         $s->{'description'} =~ /^\d+ $/ ) {
-      next;
+  # not adding any more sections after this
+  $invoice_data{summary_subtotals} = \@summary_subtotals;
+
+  # 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);
+    if ( @usage_subtotals ) {
+      unshift @sections, $usage_subtotals[0]->{section}; # do not summarize
+      unshift @detail_items, @usage_subtotals;
     }
-    push @summary_subtotals, $s;
   }
-  $invoice_data{summary_subtotals} = \@summary_subtotals;
+
+  # invoice history "section" (not really a section)
+  # not to be included in any subtotals, completely independent of 
+  # everything...
+  if ( $conf->exists('previous_invoice_history') ) {
+    my %history;
+    my %monthorder;
+    foreach my $cust_bill ( $cust_main->cust_bill ) {
+      # XXX hardcoded format, and currently only 'charged'; add other fields
+      # if they become necessary
+      my $date = $self->time2str_local('%b %Y', $cust_bill->_date);
+      $history{$date} ||= 0;
+      $history{$date} += $cust_bill->charged;
+      # just so we have a numeric sort key
+      $monthorder{$date} ||= $cust_bill->_date;
+    }
+    my @sorted_months = sort { $monthorder{$a} <=> $monthorder{$b} }
+                        keys %history;
+    my @sorted_amounts = map { sprintf('%.2f', $history{$_}) } @sorted_months;
+    $invoice_data{monthly_history} = [ \@sorted_months, \@sorted_amounts ];
+  }
+
+  # service locations: another option for template customization
+  my %location_info;
+  foreach my $item (@detail_items) {
+    if ( $item->{locationnum} ) {
+      $location_info{ $item->{locationnum} } ||= {
+        FS::cust_location->by_key( $item->{locationnum} )->location_hash
+      };
+    }
+  }
+  $invoice_data{location_info} = \%location_info;
 
   # debugging hook: call this with 'diag' => 1 to just get a hash of 
   # the invoice variables
@@ -1721,7 +1874,7 @@ sub due_date {
 
 sub due_date2str {
   my $self = shift;
-  $self->due_date ? time2str(shift, $self->due_date) : '';
+  $self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
 }
 
 sub balance_due_msg {
@@ -1730,7 +1883,7 @@ sub balance_due_msg {
   return $msg unless $self->terms;
   if ( $self->due_date ) {
     $msg .= ' - ' . $self->mt('Please pay by'). ' '.
-      $self->due_date2str($date_format);
+      $self->due_date2str('short');
   } elsif ( $self->terms ) {
     $msg .= ' - '. $self->terms;
   }
@@ -1743,7 +1896,7 @@ sub balance_due_date {
   my $duedate = '';
   if (    $conf->exists('invoice_default_terms') 
        && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
-    $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
+    $duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) );
   }
   $duedate;
 }
@@ -1755,12 +1908,25 @@ sub credit_balance_msg {
 
 =item _date_pretty
 
-Returns a string with the date, for example: "3/20/2008"
+Returns a string with the date, for example: "3/20/2008", localized for the
+customer.  Use _date_pretty_unlocalized for non-end-customer display use.
 
 =cut
 
 sub _date_pretty {
   my $self = shift;
+  $self->time2str_local('short', $self->_date);
+}
+
+=item _date_pretty_unlocalized
+
+Returns a string with the date, for example: "3/20/2008", in the format
+configured for the back-office.  Use _date_pretty for end-customer display use.
+
+=cut
+
+sub _date_pretty_unlocalized {
+  my $self = shift;
   time2str($date_format, $self->_date);
 }
 
@@ -1875,10 +2041,13 @@ sub _items_sections {
 
         my $section = $display->section;
         my $type    = $display->type;
-        $section = undef unless $opt{by_category};
+        # Set $section = undef if we're sectioning by location and this
+        # line item _has_ a location (i.e. isn't a fee).
+        $section = undef if $locationnum;
 
+        # set this flag if the section is not tax-only
         $not_tax{$locationnum}{$section} = 1
-          unless $cust_bill_pkg->pkgnum == 0;
+          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 
@@ -1914,29 +2083,24 @@ sub _items_sections {
         } else { # it's a pre-total (normal) section
 
           # skip tax items unless they're explicitly included in a section
-          next if $cust_bill_pkg->pkgnum == 0 && ! $section;
+          next if $cust_bill_pkg->pkgnum == 0 and
+                  ! $cust_bill_pkg->feepart   and
+                  ! $section;
 
-          if (! $type || $type eq 'S') {
+          if ( $type eq 'S' ) {
             $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
               if $cust_bill_pkg->setup != 0
               || $cust_bill_pkg->setup_show_zero;
-          }
-
-          if (! $type) {
-            $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
-              if $cust_bill_pkg->recur != 0
-              || $cust_bill_pkg->recur_show_zero;
-          }
-
-          if ($type && $type eq 'R') {
+          } elsif ( $type eq 'R' ) {
             $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
-          }
-          
-          if ($type && $type eq 'U') {
+          } elsif ( $type eq 'U' ) {
             $subtotal{$locationnum}{$section} += $usage
               unless scalar(@extra_sections);
+          } elsif ( !$type ) {
+            $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
+                                               + $cust_bill_pkg->recur;
           }
 
         }
@@ -1982,8 +2146,9 @@ sub _items_sections {
           $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
                                       $locationnum;
           $section->{'location'} = {
+            label_prefix => &{ $escape }($location->label_prefix),
             map { $_ => &{ $escape }($location->get($_)) }
-            $location->fields
+              $location->fields
           };
         } else {
           $section->{'category'} = $sectionname;
@@ -2267,7 +2432,78 @@ separate quantities, for some reason).
 
 sub _items_nontax {
   my $self = shift;
-  grep { $_->pkgnum } $self->cust_bill_pkg;
+  # The order of these is important.  Bundled line items will be merged into
+  # the most recent non-hidden item, so it needs to be the one with:
+  # - the same pkgnum
+  # - the same start date
+  # - no pkgpart_override
+  #
+  # So: sort by pkgnum,
+  # then by sdate
+  # then sort the base line item before any overrides
+  # then sort hidden before non-hidden add-ons
+  # then sort by override pkgpart (for consistency)
+  sort { $a->pkgnum <=> $b->pkgnum        or
+         $a->sdate  <=> $b->sdate         or
+         ($a->pkgpart_override ? 0 : -1)  or
+         ($b->pkgpart_override ? 0 : 1)   or
+         $b->hidden cmp $a->hidden        or
+         $a->pkgpart_override <=> $b->pkgpart_override
+       }
+  # and of course exclude taxes and fees
+  grep { $_->pkgnum > 0 } $self->cust_bill_pkg;
+}
+
+sub _items_fee {
+  my $self = shift;
+  my %options = @_;
+  my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
+  my @items;
+  foreach my $cust_bill_pkg (@cust_bill_pkg) {
+    # cache this, so we don't look it up again in every section
+    my $part_fee = $cust_bill_pkg->get('part_fee')
+       || $cust_bill_pkg->part_fee;
+    $cust_bill_pkg->set('part_fee', $part_fee);
+    if (!$part_fee) {
+      #die "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n"; # might make more sense
+      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?
+    
+    my @ext_desc;
+    my %base_invnums; # invnum => invoice date
+    foreach ($cust_bill_pkg->cust_bill_pkg_fee) {
+      if ($_->base_invnum) {
+        my $base_bill = FS::cust_bill->by_key($_->base_invnum);
+        my $base_date = $self->time2str_local('short', $base_bill->_date)
+          if $base_bill;
+        $base_invnums{$_->base_invnum} = $base_date || '';
+      }
+    }
+    foreach (sort keys(%base_invnums)) {
+      next if $_ == $self->invnum;
+      push @ext_desc,
+        $self->mt('from invoice \\#[_1] on [_2]', $_, $base_invnums{$_});
+    }
+    push @items,
+      { feepart     => $cust_bill_pkg->feepart,
+        amount      => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
+        description => $part_fee->itemdesc_locale($self->cust_main->locale),
+        ext_description => \@ext_desc
+        # sdate/edate?
+      };
+  }
+  @items;
 }
 
 sub _items_pkg {
@@ -2325,7 +2561,8 @@ sub _taxsort {
 
 sub _items_tax {
   my $self = shift;
-  my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
+  my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart } 
+    $self->cust_bill_pkg;
   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
 
   if ( $self->conf->exists('always_show_tax') ) {
@@ -2360,6 +2597,9 @@ 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)
+
 Returns a list of hashrefs, each of which may contain:
 
 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
@@ -2393,16 +2633,21 @@ sub _items_cust_bill_pkg {
   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
                                    # and location labels
 
-  my @b = ();
-  my ($s, $r, $u) = ( undef, undef, undef );
+  my @b = (); # accumulator for the line item hashes that we'll return
+  my ($s, $r, $u, $d) = ( undef, undef, undef );
+            # the 'current' line item hashes for setup, recur, usage, discount
   foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
   {
-
-    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+    # 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.
+    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) , $d ) {
       if ( $_ && !$cust_bill_pkg->hidden ) {
-        $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+        $_->{amount}      = sprintf( "%.2f", $_->{amount} );
         $_->{amount}      =~ s/^\-0\.00$/0.00/;
-        $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+        if (exists($_->{unit_amount})) {
+          $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
+        }
         push @b, { %$_ }
           if $_->{amount} != 0
           || $discount_show_always
@@ -2416,7 +2661,7 @@ sub _items_cust_bill_pkg {
     if ( $locationnum ) {
       # this is a location section; skip packages that aren't at this
       # service location.
-      next if $cust_bill_pkg->pkgnum == 0;
+      next if $cust_bill_pkg->pkgnum == 0; # skips fees...
       next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum 
               != $locationnum;
     }
@@ -2445,6 +2690,9 @@ sub _items_cust_bill_pkg {
       @cust_bill_pkg_display = grep { !$_->summary }
                                 @cust_bill_pkg_display;
     }
+
+    my $classname = ''; # package class name, will fill in later
+
     foreach my $display (@cust_bill_pkg_display) {
 
       warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
@@ -2467,7 +2715,10 @@ sub _items_cust_bill_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'
@@ -2475,18 +2726,25 @@ sub _items_cust_bill_pkg {
             || $discount_show_always
             || $cust_bill_pkg->recur_show_zero;
           push @b, {
+            'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
             'description' => $description,
             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
+            'preref_html' => ( $opt{preref_callback}
+                                 ? &{ $opt{preref_callback} }( $cust_bill_pkg )
+                                 : ''
+                             ),
           };
         }
         if ( $cust_bill_pkg->recur != 0 ) {
           push @b, {
+            '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),
           };
         }
 
       } elsif ( $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;
@@ -2503,6 +2761,9 @@ sub _items_cust_bill_pkg {
         %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
           unless $part_pkg->option('disable_line_item_date_ranges',1);
 
+        # not normally used, but pass this to the template anyway
+        $classname = $part_pkg->classname;
+
         if (    (!$type || $type eq 'S')
              && (    $cust_bill_pkg->setup != 0
                   || $cust_bill_pkg->setup_show_zero
@@ -2529,16 +2790,20 @@ sub _items_cust_bill_pkg {
 
           my @d = ();
           my $svc_label;
+
+          # 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');
+
+          $svc_label = $svc_labels[0];
+
           unless ( $cust_pkg->part_pkg->hide_svc_detail
                 || $cust_bill_pkg->hidden )
           {
 
-            my @svc_labels = map &{$escape_function}($_),
-                        $cust_pkg->h_labels_short($self->_date, undef, 'I');
             push @d, @svc_labels
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
-            $svc_label = $svc_labels[0];
-
             my $lnum = $cust_main ? $cust_main->ship_locationnum
                                   : $self->prospect_main->locationnum;
             # show the location label if it's not the customer's default
@@ -2571,6 +2836,7 @@ sub _items_cust_bill_pkg {
               quantity        => $cust_bill_pkg->quantity,
               ext_description => \@d,
               svc_label       => ($svc_label || ''),
+              locationnum     => $cust_pkg->locationnum, # sure, why not?
             };
           };
 
@@ -2591,7 +2857,8 @@ sub _items_cust_bill_pkg {
 
           my $is_summary = $display->summary;
           my $description = $desc;
-          if ( $type eq 'U' and ($is_summary or $cust_bill_pkg->hidden) ) {
+          if ( $type eq 'U' and defined($r) ) {
+            # don't just show the same description as the recur line
             $description = $self->mt('Usage charges');
           }
 
@@ -2611,22 +2878,31 @@ 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];
+
+          # show service labels, unless...
+                    # the package is set not to display them
           unless ( $part_pkg->hide_svc_detail
+                    # or this is a tax-like line item
                 || $cust_bill_pkg->itemdesc
+                    # or this is a hidden (bundled) line item
                 || $cust_bill_pkg->hidden
+                    # 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 
+                    # have service labels already)
+                || ($type eq 'U' and defined($r))
               )
           {
 
             warn "$me _items_cust_bill_pkg adding service details\n"
               if $DEBUG > 1;
 
-            my @svc_labels = map &{$escape_function}($_),
-                        $cust_pkg->h_labels_short(@dates, 'I');
             push @d, @svc_labels
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
-            $svc_label = $svc_labels[0];
-
             warn "$me _items_cust_bill_pkg done adding service details\n"
               if $DEBUG > 1;
 
@@ -2662,7 +2938,7 @@ sub _items_cust_bill_pkg {
               }
             } #if svc_acct-usage_seconds
 
-          }
+          } # if we are showing service labels
 
           unless ( $is_summary ) {
             warn "$me _items_cust_bill_pkg adding details\n"
@@ -2688,15 +2964,15 @@ sub _items_cust_bill_pkg {
             $amount = $cust_bill_pkg->usage;
           }
   
-          my $unit_amount =
-            ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
-                                              : $amount;
-
           if ( !$type || $type eq 'R' ) {
 
             warn "$me _items_cust_bill_pkg adding recur\n"
               if $DEBUG > 1;
 
+            my $unit_amount =
+              ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
+                                                : $amount;
+
             if ( $cust_bill_pkg->hidden ) {
               $r->{amount}      += $amount;
               $r->{unit_amount} += $unit_amount;
@@ -2713,6 +2989,7 @@ sub _items_cust_bill_pkg {
                 %item_dates,
                 ext_description => \@d,
                 svc_label       => ($svc_label || ''),
+                locationnum     => $cust_pkg->locationnum,
               };
               $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
             }
@@ -2727,7 +3004,6 @@ sub _items_cust_bill_pkg {
               # line for the bundle, add this package's total amount and
               # usage details to it
               $u->{amount}      += $amount;
-              $u->{unit_amount} += $unit_amount,
               push @{ $u->{ext_description} }, @d;
             } elsif ( $amount ) {
               # create a new usage line
@@ -2736,51 +3012,107 @@ sub _items_cust_bill_pkg {
                 pkgpart         => $pkgpart,
                 pkgnum          => $cust_bill_pkg->pkgnum,
                 amount          => $amount,
+                usage_item      => 1,
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
-                unit_amount     => $unit_amount,
-                quantity        => $cust_bill_pkg->quantity,
                 %item_dates,
                 ext_description => \@d,
+                locationnum     => $cust_pkg->locationnum,
               };
             } # else this has no usage, so don't create a usage section
           }
 
         } # recurring or usage with recurring charge
 
-      } else { #pkgnum tax or one-shot line item (??)
+        # decide whether to show active discounts here
+        if (
+            # case 1: we are showing a single line for the package
+            ( !$type )
+            # 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 
+            # a base recurring fee
+            or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
+        ) {
+
+          my @discounts = $cust_bill_pkg->cust_bill_pkg_discount;
+          # special case: if there are old "discount details" on this line 
+          # item, don't show discount line items
+          if ( FS::cust_bill_pkg_detail->count(
+              "detail LIKE 'Includes discount%' AND billpkgnum = " .
+              $cust_bill_pkg->billpkgnum
+             ) > 0 ) {
+             @discounts = ();
+          }
+          if( @discounts ) {
+            warn "$me _items_cust_bill_pkg including discounts for ".
+              $cust_bill_pkg->billpkgnum."\n"
+              if $DEBUG;
+            my $discount_amount = sum( map {$_->amount} @discounts );
+            my $orig_amount = $cust_bill_pkg->setup + $cust_bill_pkg->recur
+                              + $discount_amount;
+            # if multiple discounts apply to the same package, how to display
+            # them? ext_description lines, apparently
+            if ( $d and $cust_bill_pkg->hidden ) {
+              $d->{amount}      += $discount_amount;
+              $d->{orig_amount} += $orig_amount;
+            } else {
+              my @ext;
+              # make a placeholder for the original price, if necessary
+              # (if unit prices are enabled, it won't be necessary)
+              push @ext, '' if !$conf->exists('invoice-unitprice');
+              $d = {
+                _is_discount    => 1,
+                description     => $self->mt('Discount included'),
+                amount          => $discount_amount,
+                orig_amount     => $orig_amount,
+                ext_description => \@ext,
+              };
+              foreach my $cust_bill_pkg_discount (@discounts) {
+                my $def = $cust_bill_pkg_discount->cust_pkg_discount->discount;
+                push @ext, &{$escape_function}( $def->description );
+              }
+            }
+
+            # update the placeholder to show the original price in the 
+            # first ext_description line
+            if ( !$conf->exists('invoice-unitprice') ) {
+              $d->{ext_description}->[0] =
+                sprintf('Original price: %.2f', $d->{orig_amount});
+            }
+          } # if there are any discounts
+        } # if this is an appropriate place to show discounts
+
+      } else { # taxes and fees
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
           if $DEBUG > 1;
 
-        if ( $cust_bill_pkg->setup != 0 ) {
-          push @b, {
-            'description' => $desc,
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
-          };
-        }
-        if ( $cust_bill_pkg->recur != 0 ) {
-          push @b, {
-            'description' => "$desc (".
-                             time2str($date_format, $cust_bill_pkg->sdate). ' - '.
-                             time2str($date_format, $cust_bill_pkg->edate). ')',
-            'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
-          };
-        }
+        # items of this kind should normally not have sdate/edate.
+        push @b, {
+          'description' => $desc,
+          'amount'      => sprintf('%.2f', $cust_bill_pkg->setup 
+                                           + $cust_bill_pkg->recur)
+        };
 
-      }
+      } # if quotation / package line item / other line item
 
-    }
+    } # foreach $display
 
     $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
                                 && $conf->exists('discount-show-always'));
 
   }
 
-  foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+  foreach ( $s, $r, ($opt{skip_usage} ? () : $u, $d ) ) {
     if ( $_  ) {
       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+        if exists($_->{amount});
       $_->{amount}      =~ s/^\-0\.00$/0.00/;
-      $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+      if (exists($_->{unit_amount})) {
+        $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
+      }
+
       push @b, { %$_ }
         if $_->{amount} != 0
         || $discount_show_always