per-agent disable_previous_balance, #15863
[freeside.git] / FS / FS / cust_bill.pm
index d8d310a..35ce48c 100644 (file)
@@ -1,13 +1,15 @@
 package FS::cust_bill;
 
 use strict;
-use vars qw( @ISA $DEBUG $me $conf
+use vars qw( @ISA $DEBUG $me 
              $money_char $date_format $rdate_format $date_format_long );
+             # but NOT $conf
 use vars qw( $invoice_lines @buf ); #yuck
 use Fcntl qw(:flock); #for spool_csv
 use Cwd;
-use List::Util qw(min max);
+use List::Util qw(min max sum);
 use Date::Format;
+use Date::Language;
 use Text::Template 1.20;
 use File::Temp 0.14;
 use String::ShellQuote;
@@ -41,6 +43,8 @@ use FS::bill_batch;
 use FS::cust_bill_batch;
 use FS::cust_bill_pay_pkg;
 use FS::cust_credit_bill_pkg;
+use FS::discount_plan;
+use FS::L10N;
 
 @ISA = qw( FS::cust_main_Mixin FS::Record );
 
@@ -49,7 +53,7 @@ $me = '[FS::cust_bill]';
 
 #ask FS::UID to run this stuff for us later
 FS::UID->install_callback( sub { 
-  $conf = new FS::Conf;
+  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
@@ -140,6 +144,8 @@ Specific use cases
 
 =item agent_invid - legacy invoice number
 
+=item promised_date - customer promised payment date, for collection
+
 =back
 
 =head1 METHODS
@@ -240,7 +246,6 @@ sub delete {
     cust_event
     cust_credit_bill
     cust_bill_pay
-    cust_bill_pay
     cust_credit_bill
     cust_pay_batch
     cust_bill_pay_batch
@@ -364,6 +369,7 @@ cust_bill-default_agent_invid is set and it has a value, invnum otherwise.
 
 sub display_invnum {
   my $self = shift;
+  my $conf = $self->conf;
   if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){
     return $self->agent_invid;
   } else {
@@ -745,6 +751,18 @@ sub cust_bill_batch {
   qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
 }
 
+=item discount_plans
+
+Returns all discount plans (L<FS::discount_plan>) for this invoice, as a 
+hash keyed by term length.
+
+=cut
+
+sub discount_plans {
+  my $self = shift;
+  FS::discount_plan->all($self);
+}
+
 =item tax
 
 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
@@ -793,6 +811,23 @@ sub owed_pkgnum {
   $balance;
 }
 
+=item hide
+
+Returns true if this invoice should be hidden.  See the
+selfservice-hide_invoices-taxclass configuraiton setting.
+
+=cut
+
+sub hide {
+  my $self = shift;
+  my $conf = $self->conf;
+  my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
+    or return '';
+  my @cust_bill_pkg = $self->cust_bill_pkg;
+  my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
+  ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
+}
+
 =item apply_payments_and_credits [ OPTION => VALUE ... ]
 
 Applies unapplied payments and credits to this invoice.
@@ -807,6 +842,7 @@ If there is an error, returns the error, otherwise returns false.
 
 sub apply_payments_and_credits {
   my( $self, %options ) = @_;
+  my $conf = $self->conf;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -955,6 +991,7 @@ sub generate_email {
 
   my $self = shift;
   my %args = @_;
+  my $conf = $self->conf;
 
   my $me = '[FS::cust_bill::generate_email]';
 
@@ -989,7 +1026,7 @@ sub generate_email {
 
     my $alternative = build MIME::Entity
       'Type'        => 'multipart/alternative',
-      'Encoding'    => '7bit',
+      #'Encoding'    => '7bit',
       'Disposition' => 'inline'
     ;
 
@@ -1017,47 +1054,60 @@ sub generate_email {
 
     $alternative->attach(
       'Type'        => 'text/plain',
-      #'Encoding'    => 'quoted-printable',
-      'Encoding'    => '7bit',
+      'Encoding'    => 'quoted-printable',
+      #'Encoding'    => '7bit',
       'Data'        => $data,
       'Disposition' => 'inline',
     );
 
-    $args{'from'} =~ /\@([\w\.\-]+)/;
-    my $from = $1 || 'example.com';
-    my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
 
-    my $logo;
-    my $agentnum = $cust_main->agentnum;
-    if ( defined($args{'template'}) && length($args{'template'})
-         && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
-       )
-    {
-      $logo = 'logo_'. $args{'template'}. '.png';
+    my $htmldata;
+    my $image = '';
+    my $barcode = '';
+    if ( $conf->exists('invoice_email_pdf')
+         and scalar($conf->config('invoice_email_pdf_note')) ) {
+
+      $htmldata = join('<BR>', $conf->config('invoice_email_pdf_note') );
+
     } else {
-      $logo = "logo.png";
-    }
-    my $image_data = $conf->config_binary( $logo, $agentnum);
-
-    my $image = build MIME::Entity
-      'Type'       => 'image/png',
-      'Encoding'   => 'base64',
-      'Data'       => $image_data,
-      'Filename'   => 'logo.png',
-      'Content-ID' => "<$content_id>",
-    ;
+
+      $args{'from'} =~ /\@([\w\.\-]+)/;
+      my $from = $1 || 'example.com';
+      my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+
+      my $logo;
+      my $agentnum = $cust_main->agentnum;
+      if ( defined($args{'template'}) && length($args{'template'})
+           && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
+         )
+      {
+        $logo = 'logo_'. $args{'template'}. '.png';
+      } else {
+        $logo = "logo.png";
+      }
+      my $image_data = $conf->config_binary( $logo, $agentnum);
+
+      $image = build MIME::Entity
+        'Type'       => 'image/png',
+        'Encoding'   => 'base64',
+        'Data'       => $image_data,
+        'Filename'   => 'logo.png',
+        'Content-ID' => "<$content_id>",
+      ;
    
-    my $barcode;
-    if($conf->exists('invoice-barcode')){
-       my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
-       $barcode = build MIME::Entity
-         'Type'       => 'image/png',
-         'Encoding'   => 'base64',
-         'Data'       => $self->invoice_barcode(0),
-         'Filename'   => 'barcode.png',
-         'Content-ID' => "<$barcode_content_id>",
-       ;
-       $opt{'barcode_cid'} = $barcode_content_id;
+      if ($conf->exists('invoice-barcode')) {
+        my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+        $barcode = build MIME::Entity
+          'Type'       => 'image/png',
+          'Encoding'   => 'base64',
+          'Data'       => $self->invoice_barcode(0),
+          'Filename'   => 'barcode.png',
+          'Content-ID' => "<$barcode_content_id>",
+        ;
+        $opt{'barcode_cid'} = $barcode_content_id;
+      }
+
+      $htmldata = $self->print_html({ 'cid'=>$content_id, %opt });
     }
 
     $alternative->attach(
@@ -1070,7 +1120,7 @@ sub generate_email {
                          '    </title>',
                          '  </head>',
                          '  <body bgcolor="#e8e8e8">',
-                         $self->print_html({ 'cid'=>$content_id, %opt }),
+                         $htmldata,
                          '  </body>',
                          '</html>',
                        ],
@@ -1078,6 +1128,7 @@ sub generate_email {
       #'Filename'    => 'invoice.pdf',
     );
 
+
     my @otherparts = ();
     if ( $cust_main->email_csv_cdr ) {
 
@@ -1116,7 +1167,7 @@ sub generate_email {
 
       $related->add_part($alternative);
 
-      $related->add_part($image);
+      $related->add_part($image) if $image;
 
       my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt);
 
@@ -1132,11 +1183,10 @@ sub generate_email {
       #   image/png
 
       $return{'content-type'} = 'multipart/related';
-      if($conf->exists('invoice-barcode')){
-         $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
-      }
-      else {
-         $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
+      if ($conf->exists('invoice-barcode') && $barcode) {
+        $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
+      } else {
+        $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
       }
       $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
       #$return{'disposition'} = 'inline';
@@ -1240,6 +1290,7 @@ sub queueable_send {
 
 sub send {
   my $self = shift;
+  my $conf = $self->conf;
 
   my( $template, $invoice_from, $notice_name );
   my $agentnums = '';
@@ -1329,6 +1380,8 @@ sub queueable_email {
 #sub email_invoice {
 sub email {
   my $self = shift;
+  return if $self->hide;
+  my $conf = $self->conf;
 
   my( $template, $invoice_from, $notice_name, $no_coupon );
   if ( ref($_[0]) ) {
@@ -1378,6 +1431,7 @@ sub email {
 
 sub email_subject {
   my $self = shift;
+  my $conf = $self->conf;
 
   #my $template = scalar(@_) ? shift : '';
   #per-template?
@@ -1409,6 +1463,7 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub lpr_data {
   my $self = shift;
+  my $conf = $self->conf;
   my( $template, $notice_name );
   if ( ref($_[0]) ) {
     my $opt = shift;
@@ -1444,6 +1499,9 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 #sub print_invoice {
 sub print {
   my $self = shift;
+  return if $self->hide;
+  my $conf = $self->conf;
+
   my( $template, $notice_name );
   if ( ref($_[0]) ) {
     my $opt = shift;
@@ -1483,6 +1541,9 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub fax_invoice {
   my $self = shift;
+  return if $self->hide;
+  my $conf = $self->conf;
+
   my( $template, $notice_name );
   if ( ref($_[0]) ) {
     my $opt = shift;
@@ -1538,6 +1599,7 @@ enabled)
 
 sub get_open_bill_batch {
   my $self = shift;
+  my $conf = $self->conf;
   my $hashref = { status => 'O' };
   $hashref->{'agentnum'} = $conf->exists('invoice_print_pdf-spoolagent')
                              ? $self->cust_main->agentnum
@@ -1560,6 +1622,7 @@ TEMPLATENAME is unused?
 
 sub ftp_invoice {
   my $self = shift;
+  my $conf = $self->conf;
   my $template = scalar(@_) ? shift : '';
 
   $self->send_csv(
@@ -1582,6 +1645,7 @@ TEMPLATENAME is unused?
 
 sub spool_invoice {
   my $self = shift;
+  my $conf = $self->conf;
   my $template = scalar(@_) ? shift : '';
 
   $self->spool_csv(
@@ -2091,6 +2155,7 @@ sub realtime_lec {
 
 sub realtime_bop {
   my( $self, $method ) = (shift,shift);
+  my $conf = $self->conf;
   my %opt = @_;
 
   my $cust_main = $self->cust_main;
@@ -2219,6 +2284,7 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub print_latex {
   my $self = shift;
+  my $conf = $self->conf;
   my( $today, $template, %opt );
   if ( ref($_[0]) ) {
     %opt = %{ shift() };
@@ -2284,6 +2350,7 @@ sub print_latex {
                            SUFFIX   => '.tex',
                            UNLINK   => 0,
                          ) or die "can't open temp file: $!\n";
+  binmode($fh, ':utf8'); # language support
   print $fh join('', @filled_in );
   close $fh;
 
@@ -2345,13 +2412,16 @@ unsquelch_cdr - overrides any per customer cdr squelching when true
 
 notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
 
+locale - override customer's locale
+
 =cut
 
 #what's with all the sprintf('%10.2f')'s in here?  will it cause any
 # (alignment in text invoice?) problems to change them all to '%.2f' ?
-# yes: fixed width (dot matrix) text printing will be borked
+# yes: fixed width/plain text printing will be borked
 sub print_generic {
   my( $self, %params ) = @_;
+  my $conf = $self->conf;
   my $today = $params{today} ? $params{today} : time;
   warn "$me print_generic called on $self with suffix $params{template}\n"
     if $DEBUG;
@@ -2635,7 +2705,18 @@ sub print_generic {
     'total_pages'     => 1,
 
   );
-  
+  #localization
+  my $lh = FS::L10N->get_handle( $params{'locale'} || $cust_main->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
+
   my $min_sdate = 999999999999;
   my $max_edate = 0;
   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
@@ -2712,11 +2793,30 @@ sub print_generic {
 #  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 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
   $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
+
+  # the sum of amount owed on all invoices
   $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
 
+  # info from customer's last invoice before this one, for some 
+  # summary formats
+  $invoice_data{'last_bill'} = {};
+  my $last_bill = $pr_cust_bill[-1];
+  if ( $last_bill ) {
+    $invoice_data{'last_bill'} = {
+      '_date'     => $last_bill->_date, #unformatted
+      # all we need for now
+    };
+  }
+
   my $summarypage = '';
   if ( $conf->exists('invoice_usesummary', $agentnum) ) {
     $summarypage = 1;
@@ -2770,9 +2870,12 @@ sub print_generic {
       if ($format eq 'latex');
   }
 
-  $invoice_data{'po_line'} =
+  # 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("Purchase Order #". $cust_main->payinfo)
+      ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
       : $nbsp;
 
   my %money_chars = ( 'latex'    => '',
@@ -2801,10 +2904,10 @@ sub print_generic {
   warn "$me generating sections\n"
     if $DEBUG > 1;
 
-  my $previous_section = { 'description' => 'Previous Charges',
+  my $previous_section = { 'description' => $self->mt('Previous Charges'),
                            'subtotal'    => $other_money_char.
                                             sprintf('%.2f', $pr_total),
-                           'summarized'  => $summarypage ? 'Y' : '',
+                           'summarized'  => '', #why? $summarypage ? 'Y' : '',
                          };
   $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. 
     join(' / ', map { $cust_main->balance_date_range(@$_) }
@@ -2813,26 +2916,25 @@ sub print_generic {
     if $conf->exists('invoice_include_aging');
 
   my $taxtotal = 0;
-  my $tax_section = { 'description' => 'Taxes, Surcharges, and Fees',
+  my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
                       'subtotal'    => $taxtotal,   # adjusted below
-                      'summarized'  => $summarypage ? 'Y' : '',
                     };
   my $tax_weight = _pkg_category($tax_section->{description})
                         ? _pkg_category($tax_section->{description})->weight
                         : 0;
-  $tax_section->{'summarized'} = $summarypage && !$tax_weight ? 'Y' : '';
+  $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
   $tax_section->{'sort_weight'} = $tax_weight;
 
 
   my $adjusttotal = 0;
-  my $adjust_section = { 'description' => 'Credits, Payments, and Adjustments',
+  my $adjust_section = { 'description' => 
+    $self->mt('Credits, Payments, and Adjustments'),
                          'subtotal'    => 0,   # adjusted below
-                         'summarized'  => $summarypage ? 'Y' : '',
                        };
   my $adjust_weight = _pkg_category($adjust_section->{description})
                         ? _pkg_category($adjust_section->{description})->weight
                         : 0;
-  $adjust_section->{'summarized'} = $summarypage && !$adjust_weight ? 'Y' : '';
+  $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
   $adjust_section->{'sort_weight'} = $adjust_weight;
 
   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
@@ -2870,11 +2972,28 @@ sub print_generic {
           push @detail_items, @$accountcode_lines;
       }
     }
-  }else{
-    push @sections, { 'description' => '', 'subtotal' => '' };
+  } else {# not multisection
+    # make a default section
+    push @sections, { 'description' => '', 'subtotal' => '', 
+      'no_subtotal' => 1 };
+    # and calculate the finance charge total, since it won't get done otherwise.
+    # XXX possibly other totals?
+    # XXX possibly finance_pkgclass should not be used in this manner?
+    if ( $conf->exists('finance_pkgclass') ) {
+      my @finance_charges;
+      foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+        if ( grep { $_->section eq $invoice_data{finance_section} }
+             $cust_bill_pkg->cust_bill_pkg_display ) {
+          # I think these are always setup fees, but just to be sure...
+          push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
+        }
+      }
+      $invoice_data{finance_amount} = 
+        sprintf('%.2f', sum( @finance_charges ) || 0);
+    }
   }
 
-  unless (    $conf->exists('disable_previous_balance')
+  unless (    $conf->exists('disable_previous_balance', $agentnum)
            || $conf->exists('previous_balance-summary_only')
          )
   {
@@ -2908,9 +3027,10 @@ sub print_generic {
 
   }
   
-  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) {
+  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance', $agentnum) ) 
+    {
     push @buf, ['','-----------'];
-    push @buf, [ 'Total Previous Balance',
+    push @buf, [ $self->mt('Total Previous Balance'),
                  $money_char. sprintf("%10.2f", $pr_total) ];
     push @buf, ['',''];
   }
@@ -2922,9 +3042,9 @@ sub print_generic {
       my ($didsummary,$minutes) = $self->_did_summary;
       my $didsummary_desc = 'DID Activity Summary (since last invoice)';
       push @detail_items, 
-       { 'description' => $didsummary_desc,
-           'ext_description' => [ $didsummary, $minutes ],
-       };
+       { 'description' => $didsummary_desc,
+           'ext_description' => [ $didsummary, $minutes ],
+       };
   }
 
   foreach my $section (@sections, @$late_sections) {
@@ -2965,7 +3085,7 @@ sub print_generic {
     $options{'section'} = $section if $multisection;
     $options{'format'} = $format;
     $options{'escape_function'} = $escape_function;
-    $options{'format_function'} = sub { () } unless $unsquelched;
+    $options{'no_usage'} = 1 unless $unsquelched;
     $options{'unsquelched'} = $unsquelched;
     $options{'summary_page'} = $summarypage;
     $options{'skip_usage'} =
@@ -2996,6 +3116,10 @@ sub print_generic {
       $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
                                  $line_item->{'unit_amount'};
       $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+
+      $detail->{'sdate'} = $line_item->{'sdate'};
+      $detail->{'edate'} = $line_item->{'edate'};
+      $detail->{'seconds'} = $line_item->{'seconds'};
   
       push @detail_items, $detail;
       push @buf, ( [ $detail->{'description'},
@@ -3008,7 +3132,7 @@ sub print_generic {
     if ( $section->{'description'} ) {
       push @buf, ( ['','-----------'],
                    [ $section->{'description'}. ' sub-total',
-                      $money_char. sprintf("%10.2f", $section->{'subtotal'})
+                      $section->{'subtotal'} # already formatted this 
                    ],
                    [ '', '' ],
                    [ '', '' ],
@@ -3016,11 +3140,11 @@ sub print_generic {
     }
   
   }
-  
+
   $invoice_data{current_less_finance} =
     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
 
-  if ( $multisection && !$conf->exists('disable_previous_balance')
+  if ( $multisection && !$conf->exists('disable_previous_balance', $agentnum)
     || $conf->exists('previous_balance-summary_only') )
   {
     unshift @sections, $previous_section if $pr_total;
@@ -3066,7 +3190,7 @@ sub print_generic {
   
   if ( $taxtotal ) {
     my $total = {};
-    $total->{'total_item'} = 'Sub-total';
+    $total->{'total_item'} = $self->mt('Sub-total');
     $total->{'total_amount'} =
       $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
 
@@ -3083,7 +3207,8 @@ sub print_generic {
   $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
 
   push @buf,['','-----------'];
-  push @buf,[( $conf->exists('disable_previous_balance') 
+  push @buf,[$self->mt( 
+              $conf->exists('disable_previous_balance', $agentnum) 
                ? 'Total Charges'
                : 'Total New Charges'
              ),
@@ -3097,21 +3222,21 @@ sub print_generic {
          || 'Total New Charges'
       if $conf->exists('previous_balance-exclude_from_total');
     my $amount = $self->charged +
-                   ( $conf->exists('disable_previous_balance') ||
+                   ( $conf->exists('disable_previous_balance', $agentnum) ||
                      $conf->exists('previous_balance-exclude_from_total')
                      ? 0
                      : $pr_total
                    );
-    $total->{'total_item'} = &$embolden_function($item);
+    $total->{'total_item'} = &$embolden_function($self->mt($item));
     $total->{'total_amount'} =
       &$embolden_function( $other_money_char.  sprintf( '%.2f', $amount ) );
     if ( $multisection ) {
       if ( $adjust_section->{'sort_weight'} ) {
-        $adjust_section->{'posttotal'} = 'Balance Forward '. $other_money_char.
-          sprintf("%.2f", ($self->billing_balance || 0) );
+        $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
+          $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
       } else {
-        $adjust_section->{'pretotal'} = 'New charges total '. $other_money_char.
-                                        sprintf('%.2f', $self->charged );
+        $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
+          $other_money_char.  sprintf('%.2f', $self->charged );
       } 
     }else{
       push @total_items, $total;
@@ -3124,7 +3249,7 @@ sub print_generic {
     push @buf,['',''];
   }
   
-  unless ( $conf->exists('disable_previous_balance') ) {
+  unless ( $conf->exists('disable_previous_balance', $agentnum) ) {
     #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
   
     # credits
@@ -3194,6 +3319,7 @@ sub print_generic {
         unless $adjust_section->{sort_weight};
     }
 
+    # create Balance Due message
     { 
       my $total;
       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
@@ -3254,6 +3380,26 @@ sub print_generic {
       if $unsquelched;
   }
 
+  # make a discounts-available section, even without multisection
+  if ( $conf->exists('discount-show_available') 
+       and my @discounts_avail = $self->_items_discounts_avail ) {
+    my $discount_section = {
+      'description' => $self->mt('Discounts Available'),
+      'subtotal'    => '',
+      'no_subtotal' => 1,
+    };
+
+    push @sections, $discount_section;
+    push @detail_items, map { +{
+        'ref'         => '', #should this be something else?
+        'section'     => $discount_section,
+        'description' => &$escape_function( $_->{description} ),
+        'amount'      => $money_char . &$escape_function( $_->{amount} ),
+        'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
+    } } @discounts_avail;
+  }
+
+  # All sections and items are built; now fill in templates.
   my @includelist = ();
   push @includelist, 'summary' if $summarypage;
   foreach my $include ( @includelist ) {
@@ -3316,28 +3462,27 @@ sub print_generic {
     }
 
     #setup subroutine for the template
-    sub FS::cust_bill::_template::invoice_lines {
-      my $lines = shift || scalar(@FS::cust_bill::_template::buf);
+    $invoice_data{invoice_lines} = sub {
+      my $lines = shift || scalar(@buf);
       map { 
-        scalar(@FS::cust_bill::_template::buf)
-          ? shift @FS::cust_bill::_template::buf
+        scalar(@buf)
+          ? shift @buf
           : [ '', '' ];
       }
       ( 1 .. $lines );
-    }
+    };
 
     my $lines;
     my @collect;
     while (@buf) {
       push @collect, split("\n",
-        $text_template->fill_in( HASH => \%invoice_data,
-                                 PACKAGE => 'FS::cust_bill::_template'
-                               )
+        $text_template->fill_in( HASH => \%invoice_data )
       );
-      $FS::cust_bill::_template::page++;
+      $invoice_data{'page'}++;
     }
     map "$_\n", @collect;
   }else{
+    # this is where we actually create the invoice
     warn "filling in template for invoice ". $self->invnum. "\n"
       if $DEBUG;
     warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
@@ -3546,6 +3691,7 @@ sub _translate_old_latex_format {
 
 sub terms {
   my $self = shift;
+  my $conf = $self->conf;
 
   #check for an invoice-specific override
   return $self->invoice_terms if $self->invoice_terms;
@@ -3574,10 +3720,11 @@ sub due_date2str {
 
 sub balance_due_msg {
   my $self = shift;
-  my $msg = 'Balance Due';
+  my $msg = $self->mt('Balance Due');
   return $msg unless $self->terms;
   if ( $self->due_date ) {
-    $msg .= ' - Please pay by '. $self->due_date2str($date_format);
+    $msg .= ' - ' . $self->mt('Please pay by'). ' '.
+      $self->due_date2str($date_format);
   } elsif ( $self->terms ) {
     $msg .= ' - '. $self->terms;
   }
@@ -3586,6 +3733,7 @@ sub balance_due_msg {
 
 sub balance_due_date {
   my $self = shift;
+  my $conf = $self->conf;
   my $duedate = '';
   if (    $conf->exists('invoice_default_terms') 
        && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
@@ -3594,7 +3742,10 @@ sub balance_due_date {
   $duedate;
 }
 
-sub credit_balance_msg { 'Credit Balance Remaining' }
+sub credit_balance_msg { 
+  my $self = shift;
+  $self->mt('Credit Balance Remaining')
+}
 
 =item invnum_date_pretty
 
@@ -3605,7 +3756,7 @@ Returns a string with the invoice number and date, for example:
 
 sub invnum_date_pretty {
   my $self = shift;
-  'Invoice #'. $self->invnum. ' ('. $self->_date_pretty. ')';
+  $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')';
 }
 
 =item _date_pretty
@@ -3619,6 +3770,53 @@ sub _date_pretty {
   time2str($date_format, $self->_date);
 }
 
+=item _items_sections LATE SUMMARYPAGE ESCAPE EXTRA_SECTIONS FORMAT
+
+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 section.
+
+Section descriptions are returned in sort weight order.  Each consists 
+of a hash containing:
+
+description: the package category name, escaped
+subtotal: the total charges in that section
+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 
+returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
+coderefs to generate parts of the invoice.  This is not advised.
+
+Arguments:
+
+LATE: an arrayref to push the "late" section hashes onto.  The "early"
+group is simply returned from the method.
+
+SUMMARYPAGE: a flag indicating whether this is a summary-format invoice.
+Turning this on has the following effects:
+- Ignores display items with the 'summary' flag.
+- Combines all items into the "early" group.
+- 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 
+usage charges.
+
+FORMAT: 'latex', 'html', or 'template' (i.e. text).  Not used, but 
+passed through to C<_condense_section()>.
+
+=cut
+
 use vars qw(%pkg_category_cache);
 sub _items_sections {
   my $self = shift;
@@ -4011,6 +4209,7 @@ sub _condensed_total_line_generator {
 
 sub _items_extra_usage_sections {
   my $self = shift;
+  my $conf = $self->conf;
   my $escape = shift;
   my $format = shift;
 
@@ -4250,6 +4449,7 @@ sub _items_accountcode_cdr {
 
 sub _items_svc_phone_sections {
   my $self = shift;
+  my $conf = $self->conf;
   my $escape = shift;
   my $format = shift;
 
@@ -4478,7 +4678,7 @@ sub _items_svc_phone_sections {
 
 }
 
-sub _items {
+sub _items { # seems to be unused
   my $self = shift;
 
   #my @display = scalar(@_)
@@ -4497,6 +4697,7 @@ sub _items {
 
 sub _items_previous {
   my $self = shift;
+  my $conf = $self->conf;
   my $cust_main = $self->cust_main;
   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
   my @b = ();
@@ -4505,7 +4706,7 @@ sub _items_previous {
                ? 'due '. $_->due_date2str($date_format)
                : time2str($date_format, $_->_date);
     push @b, {
-      'description' => 'Previous Balance, Invoice #'. $_->invnum. " ($date)",
+      'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)",
       #'pkgpart'     => 'N/A',
       'pkgnum'      => 'N/A',
       'amount'      => sprintf("%.2f", $_->owed),
@@ -4527,6 +4728,21 @@ sub _items_previous {
   #};
 }
 
+=item _items_pkg [ OPTIONS ]
+
+Return line item hashes for each package item on this invoice. Nearly 
+equivalent to 
+
+$self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
+
+The only OPTIONS accepted is 'section', which may point to a hashref 
+with a key named 'condensed', which may have a true value.  If it 
+does, this method tries to merge identical items into items with 
+'quantity' equal to the number of items (not the sum of their 
+separate quantities, for some reason).
+
+=cut
+
 sub _items_pkg {
   my $self = shift;
   my %options = @_;
@@ -4586,17 +4802,50 @@ sub _items_tax {
   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
 }
 
+=item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
+
+Takes an arrayref of L<FS::cust_bill_pkg> objects, and returns a
+list of hashrefs describing the line items they generate on the invoice.
+
+OPTIONS may include:
+
+format: the invoice format.
+
+escape_function: the function used to escape strings.
+
+DEPRECATED? (expensive, mostly unused?)
+format_function: the function used to format CDRs.
+
+section: a hashref containing 'description'; if this is present, 
+cust_bill_pkg_display records not belonging to this section are 
+ignored.
+
+multisection: a flag indicating that this is a multisection invoice,
+which does something complicated.
+
+multilocation: a flag to display the location label for the package.
+
+Returns a list of hashrefs, each of which may contain:
+
+pkgnum, description, amount, unit_amount, quantity, _is_setup, and 
+ext_description, which is an arrayref of detail lines to show below 
+the package line.
+
+=cut
+
 sub _items_cust_bill_pkg {
   my $self = shift;
+  my $conf = $self->conf;
   my $cust_bill_pkgs = shift;
   my %opt = @_;
 
   my $format = $opt{format} || '';
   my $escape_function = $opt{escape_function} || sub { shift };
   my $format_function = $opt{format_function} || '';
-  my $unsquelched = $opt{unsquelched} || '';
+  my $no_usage = $opt{no_usage} || '';
+  my $unsquelched = $opt{unsquelched} || ''; #unused
   my $section = $opt{section}->{description} if $opt{section};
-  my $summary_page = $opt{summary_page} || '';
+  my $summary_page = $opt{summary_page} || ''; #unused
   my $multilocation = $opt{multilocation} || '';
   my $multisection = $opt{multisection} || '';
   my $discount_show_always = 0;
@@ -4650,6 +4899,7 @@ sub _items_cust_bill_pkg {
       my %details_opt = ( 'format'          => $format,
                           'escape_function' => $escape_function,
                           'format_function' => $format_function,
+                          'no_usage'        => $opt{'no_usage'},
                         );
 
       if ( $cust_bill_pkg->pkgnum > 0 ) {
@@ -4659,6 +4909,10 @@ sub _items_cust_bill_pkg {
  
         my $cust_pkg = $cust_bill_pkg->cust_pkg;
 
+        # start/end dates for invoice formats that do nonstandard 
+        # things with them
+        my %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate');
+
         if (    (!$type || $type eq 'S')
              && (    $cust_bill_pkg->setup != 0
                   || $cust_bill_pkg->setup_show_zero
@@ -4691,7 +4945,7 @@ sub _items_cust_bill_pkg {
               push @d, &{$escape_function}($loc);
             }
 
-          }
+          } #unless hiding service details
 
           push @d, $cust_bill_pkg->details(%details_opt)
             if $cust_bill_pkg->recur == 0;
@@ -4733,13 +4987,23 @@ sub _items_cust_bill_pkg {
           my $description = ($is_summary && $type && $type eq 'U')
                             ? "Usage charges" : $desc;
 
-          $description .= " (" . time2str($date_format, $cust_bill_pkg->sdate).
-                          " - ". time2str($date_format, $cust_bill_pkg->edate).
-                          ")"
-            unless $conf->exists('disable_line_item_date_ranges')
-                || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
+          unless (
+            $conf->exists('disable_line_item_date_ranges')
+              || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1)
+          ) {
+            my $time_period;
+            my $date_style = $conf->config('cust_bill-line_item-date_style');
+            if ( $date_style eq 'month_of' ) {
+              $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
+            } else {
+              $time_period =      time2str($date_format, $cust_bill_pkg->sdate).
+                           " - ". time2str($date_format, $cust_bill_pkg->edate);
+            }
+            $description .= " ($time_period)";
+          }
 
           my @d = ();
+          my @seconds = (); # for display of usage info
 
           #at least until cust_bill_pkg has "past" ranges in addition to
           #the "future" sdate/edate ones... see #3032
@@ -4773,6 +5037,27 @@ sub _items_cust_bill_pkg {
               push @d, &{$escape_function}($loc);
             }
 
+            # 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 
+            # that don't support this it will show undef.
+            if ( $conf->exists('svc_acct-usage_seconds') 
+                 and ! $cust_bill_pkg->pkgpart_override ) {
+              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 
+                # sqlradius_hour billing does it
+                my $sec = eval {
+                  $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
+                };
+                push @seconds, $sec;
+              }
+            } #if svc_acct-usage_seconds
+
           }
 
           unless ( $is_summary ) {
@@ -4781,7 +5066,7 @@ sub _items_cust_bill_pkg {
 
             #instead of omitting details entirely in this case (unwanted side
             # effects), just omit CDRs
-            $details_opt{'format_function'} = sub { () }
+            $details_opt{'no_usage'} = 1
               if $type && $type eq 'R';
 
             push @d, $cust_bill_pkg->details(%details_opt);
@@ -4817,8 +5102,10 @@ sub _items_cust_bill_pkg {
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
                 unit_amount     => $cust_bill_pkg->unitrecur,
                 quantity        => $cust_bill_pkg->quantity,
+                %item_dates,
                 ext_description => \@d,
               };
+              $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
             }
 
           } else {  # $type eq 'U'
@@ -4839,6 +5126,7 @@ sub _items_cust_bill_pkg {
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
                 unit_amount     => $cust_bill_pkg->unitrecur,
                 quantity        => $cust_bill_pkg->quantity,
+                %item_dates,
                 ext_description => \@d,
               };
             }
@@ -4913,7 +5201,7 @@ sub _items_credits {
       #'description' => 'Credit ref\#'. $_->crednum.
       #                 " (". time2str("%x",$_->cust_credit->_date) .")".
       #                 $reason,
-      'description' => 'Credit applied '.
+      'description' => $self->mt('Credit applied').' '.
                        time2str($date_format,$_->cust_credit->_date). $reason,
       'amount'      => sprintf("%.2f",$_->amount),
     };
@@ -4933,7 +5221,7 @@ sub _items_payments {
     #something more elaborate if $_->amount ne ->cust_pay->paid ?
 
     push @b, {
-      'description' => "Payment received ".
+      'description' => $self->mt('Payment received').' '.
                        time2str($date_format,$_->cust_pay->_date ),
       'amount'      => sprintf("%.2f", $_->amount )
     };
@@ -4943,6 +5231,48 @@ sub _items_payments {
 
 }
 
+=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 
+a setup fee if the discount is allowed to apply to setup fees.
+
+=cut
+
+sub _items_discounts_avail {
+  my $self = shift;
+  my $list_pkgnums = 0; # if any packages are not eligible for all discounts
+
+  my %plans = $self->discount_plans;
+
+  $list_pkgnums = grep { $_->list_pkgnums } values %plans;
+
+  map {
+    my $months = $_;
+    my $plan = $plans{$months};
+
+    my $term_total = sprintf('%.2f', $plan->discounted_total);
+    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'). ' '.
+                 join(', ', map { "#$_" } $plan->pkgnums)
+      if $list_pkgnums;
+
+    +{
+      description => $self->mt('Save [_1]% by paying for [_2] months',
+                                $percent, $months),
+      amount      => $self->mt('[_1] ([_2] per month)', 
+                                $term_total, $money_char.$permonth),
+      ext_description => ($detail || ''),
+    }
+  } #map
+  sort { $b <=> $a } keys %plans;
+
+}
+
 =item call_details [ OPTION => VALUE ... ]
 
 Returns an array of CSV strings representing the call details for this invoice
@@ -5159,6 +5489,7 @@ Currently only supported on PostgreSQL.
 =cut
 
 sub due_date_sql {
+  my $conf = new FS::Conf;
 'COALESCE(
   SUBSTRING(
     COALESCE(
@@ -5303,6 +5634,15 @@ sub search_sql_where {
 
   }
 
+  #promised_date - also has an option to accept nulls
+  if ( $param->{promised_date} ) {
+    my($beginning, $ending, $null) = @{$param->{promised_date}};
+
+    push @search, "(( cust_bill.promised_date >= $beginning AND ".
+                    "cust_bill.promised_date <  $ending )" .
+                    ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')');
+  }
+
   #agent virtualization
   my $curuser = $FS::CurrentUser::CurrentUser;
   if ( $curuser->username eq 'fs_queue'