[freeside-commits] branch master updated. 81978af92ecdaaefeff5156d9ab3b4f99586df1c

Ivan ivan at 420.am
Tue Jul 3 18:52:00 PDT 2012


The branch, master has been updated
       via  81978af92ecdaaefeff5156d9ab3b4f99586df1c (commit)
      from  7abce2207dbee012fd442940dc42070f45ef8a16 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 81978af92ecdaaefeff5156d9ab3b4f99586df1c
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Tue Jul 3 18:51:51 2012 -0700

    quotations, RT#16996

diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm
index eb9974a..4de2948 100644
--- a/FS/FS/AccessRight.pm
+++ b/FS/FS/AccessRight.pm
@@ -98,6 +98,7 @@ tie my %rights, 'Tie::IxHash',
     #'New contact',
     #'View customer contacts',
     #'List contacts',
+    'Generate quotation',
   ],
   
   ###
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index 8c9d56f..7e64130 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -1197,7 +1197,15 @@ sub reason_type_options {
   {
     'key'         => 'invoice_html',
     'section'     => 'invoicing',
-    'description' => 'Optional HTML template for invoices.  See the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:2.1:Documentation:Administration#HTML_invoice_templates">billing documentation</a> for details.',
+    'description' => 'HTML template for invoices.  See the <a href="http://www.freeside.biz/mediawiki/index.php/Freeside:2.1:Documentation:Administration#HTML_invoice_templates">billing documentation</a> for details.',
+
+    'type'        => 'textarea',
+  },
+
+  {
+    'key'         => 'quotation_html',
+    'section'     => '',
+    'description' => 'HTML template for quotations.',
 
     'type'        => 'textarea',
   },
@@ -1245,6 +1253,13 @@ sub reason_type_options {
   },
 
   {
+    'key'         => 'quotation_latex',
+    'section'     => '',
+    'description' => 'LaTeX template for typeset PostScript quotations.',
+    'type'        => 'textarea',
+  },
+
+  {
     'key'         => 'invoice_latextopmargin',
     'section'     => 'invoicing',
     'description' => 'Optional LaTeX invoice topmargin setting. Include units.',
@@ -1303,6 +1318,15 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'quotation_latexnotes',
+    'section'     => '',
+    'description' => 'Notes section for LaTeX typeset PostScript quotations.',
+    'type'        => 'textarea',
+    'per_agent'   => 1,
+    'per_locale'  => 1,
+  },
+
+  {
     'key'         => 'invoice_latexfooter',
     'section'     => 'invoicing',
     'description' => 'Footer for LaTeX typeset PostScript invoices.',
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index a90c73a..797b705 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -1054,8 +1054,50 @@ sub tables_hashref {
       'index'       => [ [ 'company' ], [ 'agentnum' ], [ 'disabled' ] ],
     },
 
-    #eventually use for billing & ship from cust_main too
-    #for now, just cust_pkg locations
+    'quotation' => {
+      'columns' => [
+        #regular fields
+        'quotationnum',   'serial',     '', '', '', '', 
+        'prospectnum',       'int', 'NULL', '', '', '',
+        'custnum',           'int', 'NULL', '', '', '',
+        '_date',        @date_type,             '', '', 
+        'disabled',         'char', 'NULL',  1, '', '', 
+        'usernum',           'int', 'NULL', '', '', '',
+        #'total',      @money_type,       '', '', 
+        #'quotation_term', 'varchar', 'NULL', $char_d, '', '',
+      ],
+      'primary_key' => 'quotationnum',
+      'unique' => [],
+      'index' => [ [ 'prospectnum' ], ['custnum'], ],
+    },
+
+    'quotation_pkg' => {
+      'columns' => [
+        'quotationpkgnum',   'serial',     '', '', '', '', 
+        'pkgpart',              'int',     '', '', '', '', 
+        'locationnum',          'int', 'NULL', '', '', '',
+        'start_date',      @date_type,             '', '', 
+        'contract_end',    @date_type,             '', '',
+        'quantity',             'int', 'NULL', '', '', '',
+        'waive_setup',         'char', 'NULL',  1, '', '', 
+      ],
+      'primary_key' => 'quotationpkgnum',
+      'unique' => [],
+      'index' => [ ['pkgpart'], ],
+    },
+
+    'quotation_pkg_discount' => {
+      'columns' => [
+        'quotationpkgdiscountnum', 'serial', '', '', '', '',
+        'quotationpkgnum',            'int', '', '', '', '', 
+        'discountnum',                'int', '', '', '', '',
+        #'end_date',              @date_type,         '', '',
+      ],
+      'primary_key' => 'quotationpkgdiscountnum',
+      'unique' => [],
+      'index'  => [ [ 'quotationpkgnum' ], ], #[ 'discountnum' ] ],
+    },
+
     'cust_location' => { #'location' now that its prospects too, but...
       'columns' => [
         'locationnum',      'serial',     '',      '', '', '',
diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm
new file mode 100644
index 0000000..19b452e
--- /dev/null
+++ b/FS/FS/Template_Mixin.pm
@@ -0,0 +1,2525 @@
+package FS::Template_Mixin;
+
+use strict;
+use vars qw( $DEBUG $me
+             $money_char $date_format $rdate_format $date_format_long );
+             # but NOT $conf
+use vars qw( $invoice_lines @buf ); #yuck
+use List::Util qw(sum);
+use Date::Format;
+use Date::Language;
+use Text::Template 1.20;
+use File::Temp 0.14;
+use HTML::Entities;
+use Locale::Country;
+use FS::UID;
+use FS::Record qw( qsearch qsearchs );
+use FS::Misc qw( generate_ps generate_pdf );
+use FS::pkg_category;
+use FS::pkg_class;
+use FS::L10N;
+
+$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';
+} );
+
+=item print_text HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
+
+Returns an text invoice, as a list of lines.
+
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time>, if specified, is 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.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+=cut
+
+sub print_text {
+  my $self = shift;
+  my( $today, $template, %opt );
+  if ( ref($_[0]) ) {
+    %opt = %{ shift() };
+    $today = delete($opt{'time'}) || '';
+    $template = delete($opt{template}) || '';
+  } else {
+    ( $today, $template, %opt ) = @_;
+  }
+
+  my %params = ( 'format' => 'template' );
+  $params{'time'} = $today if $today;
+  $params{'template'} = $template if $template;
+  $params{$_} = $opt{$_} 
+    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
+
+  $self->print_generic( %params );
+}
+
+=item print_latex HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
+
+Internal method - returns a filename of a filled-in LaTeX template for this
+invoice (Note: add ".tex" to get the actual filename), and a filename of
+an associated logo (with the .eps extension included).
+
+See print_ps and print_pdf for methods that return PostScript and PDF output.
+
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time>, if specified, is 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.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+=cut
+
+sub print_latex {
+  my $self = shift;
+  my $conf = $self->conf;
+  my( $today, $template, %opt );
+  if ( ref($_[0]) ) {
+    %opt = %{ shift() };
+    $today = delete($opt{'time'}) || '';
+    $template = delete($opt{template}) || '';
+  } else {
+    ( $today, $template, %opt ) = @_;
+  }
+
+  my %params = ( 'format' => 'latex' );
+  $params{'time'} = $today if $today;
+  $params{'template'} = $template if $template;
+  $params{$_} = $opt{$_} 
+    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
+
+  $template ||= $self->_agent_template
+    if $self->can('_agent_template');
+
+  my $pkey = $self->primary_key;
+  my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX';
+
+  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+  my $lh = new File::Temp(
+    TEMPLATE => $tmp_template,
+    DIR      => $dir,
+    SUFFIX   => '.eps',
+    UNLINK   => 0,
+  ) or die "can't open temp file: $!\n";
+
+  my $agentnum = $self->cust_main->agentnum;
+
+  if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
+    print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
+      or die "can't write temp file: $!\n";
+  } else {
+    print $lh $conf->config_binary('logo.eps', $agentnum)
+      or die "can't write temp file: $!\n";
+  }
+  close $lh;
+  $params{'logo_file'} = $lh->filename;
+
+  if( $conf->exists('invoice-barcode') && $self->can('invoice_barcode') ) {
+      my $png_file = $self->invoice_barcode($dir);
+      my $eps_file = $png_file;
+      $eps_file =~ s/\.png$/.eps/g;
+      $png_file =~ /(barcode.*png)/;
+      $png_file = $1;
+      $eps_file =~ /(barcode.*eps)/;
+      $eps_file = $1;
+
+      my $curr_dir = cwd();
+      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)
+      system('sam2p', '-j:quiet', $png_file, 'EPS:', $eps_file ) == 0
+	or die "sam2p failed: $!\n";
+      unlink($png_file);
+      chdir($curr_dir);
+
+      $params{'barcode_file'} = $eps_file;
+  }
+
+  my @filled_in = $self->print_generic( %params );
+  
+  my $fh = new File::Temp( TEMPLATE => $tmp_template,
+                           DIR      => $dir,
+                           SUFFIX   => '.tex',
+                           UNLINK   => 0,
+                         ) or die "can't open temp file: $!\n";
+  binmode($fh, ':utf8'); # language support
+  print $fh join('', @filled_in );
+  close $fh;
+
+  $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
+  return ($1, $params{'logo_file'}, $params{'barcode_file'});
+
+}
+
+=item print_generic OPTION => VALUE ...
+
+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
+
+Optional options include
+
+template - a value used as a suffix for a configuration template
+
+time - a value 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 - 
+
+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/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;
+
+  my $format = $params{format};
+  die "Unknown format: $format"
+    unless $format =~ /^(latex|html|template)$/;
+
+  my $cust_main = $self->cust_main || $self->prospect_main;
+  $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
+    unless $cust_main->payname
+        && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
+
+  my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
+                     'html'     => [ '<%=', '%>' ],
+                     'template' => [ '{', '}' ],
+                   );
+
+  warn "$me print_generic creating template\n"
+    if $DEBUG > 1;
+
+  #create the template
+  my $template = $params{template} ? $params{template} : $self->_agent_template;
+  my $templatefile = $self->template_conf. $format;
+  $templatefile .= "_$template"
+    if length($template) && $conf->exists($templatefile."_$template");
+  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; ".
+         "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;
+
+  my $text_template = new Text::Template(
+    TYPE => 'ARRAY',
+    SOURCE => \@invoice_template,
+    DELIMITERS => $delimiters{$format},
+  );
+
+  warn "$me print_generic compiling T:T object\n"
+    if $DEBUG > 1;
+
+  $text_template->compile()
+    or die "Can't compile $templatefile: $Text::Template::ERROR\n";
+
+
+  # additional substitution could possibly cause breakage in existing templates
+  my %convert_maps = ( 
+    'latex' => {
+                 'notes'         => sub { map "$_", @_ },
+                 'footer'        => sub { map "$_", @_ },
+                 'smallfooter'   => sub { map "$_", @_ },
+                 'returnaddress' => sub { map "$_", @_ },
+                 'coupon'        => sub { map "$_", @_ },
+                 'summary'       => sub { map "$_", @_ },
+               },
+    'html'  => {
+                 'notes' =>
+                   sub {
+                     map { 
+                       s/%%(.*)$/<!-- $1 -->/g;
+                       s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
+                       s/\\begin\{enumerate\}/<ol>/g;
+                       s/\\item /  <li>/g;
+                       s/\\end\{enumerate\}/<\/ol>/g;
+                       s/\\textbf\{(.*)\}/<b>$1<\/b>/g;
+                       s/\\\\\*/<br>/g;
+                       s/\\dollar ?/\$/g;
+                       s/\\#/#/g;
+                       s/~/ /g;
+                       $_;
+                     }  @_
+                   },
+                 'footer' =>
+                   sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
+                 'smallfooter' =>
+                   sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
+                 'returnaddress' =>
+                   sub {
+                     map { 
+                       s/~/ /g;
+                       s/\\\\\*?\s*$/<BR>/;
+                       s/\\hyphenation\{[\w\s\-]+}//;
+                       s/\\([&])/$1/g;
+                       $_;
+                     }  @_
+                   },
+                 'coupon'        => sub { "" },
+                 'summary'       => sub { "" },
+               },
+    'template' => {
+                 'notes' =>
+                   sub {
+                     map { 
+                       s/%%.*$//g;
+                       s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
+                       s/\\begin\{enumerate\}//g;
+                       s/\\item /  * /g;
+                       s/\\end\{enumerate\}//g;
+                       s/\\textbf\{(.*)\}/$1/g;
+                       s/\\\\\*/ /;
+                       s/\\dollar ?/\$/g;
+                       $_;
+                     }  @_
+                   },
+                 'footer' =>
+                   sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
+                 'smallfooter' =>
+                   sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
+                 'returnaddress' =>
+                   sub {
+                     map { 
+                       s/~/ /g;
+                       s/\\\\\*?\s*$/\n/;             # dubious
+                       s/\\hyphenation\{[\w\s\-]+}//;
+                       $_;
+                     }  @_
+                   },
+                 'coupon'        => sub { "" },
+                 'summary'       => sub { "" },
+               },
+  );
+
+
+  # hashes for differing output formats
+  my %nbsps = ( 'latex'    => '~',
+                'html'     => '',    # '&nbps;' would be nice
+                'template' => '',    # not used
+              );
+  my $nbsp = $nbsps{$format};
+
+  my %escape_functions = ( 'latex'    => \&_latex_escape,
+                           'html'     => \&_html_escape_nbsp,#\&encode_entities,
+                           'template' => sub { shift },
+                         );
+  my $escape_function = $escape_functions{$format};
+  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/ / /g;
+
+  my $date_format = $date_formats{$format};
+
+  my %embolden_functions = ( 'latex'    => sub { return '\textbf{'. shift(). '}'
+                                               },
+                             'html'     => sub { return '<b>'. shift(). '</b>'
+                                               },
+                             'template' => sub { shift },
+                           );
+  my $embolden_function = $embolden_functions{$format};
+
+  my %newline_tokens = (  'latex'     => '\\\\',
+                          'html'      => '<br>',
+                          'template'  => "\n",
+                        );
+  my $newline_token = $newline_tokens{$format};
+
+  warn "$me generating template variables\n"
+    if $DEBUG > 1;
+
+  # generate template variables
+  my $returnaddress;
+  if (
+         defined( $conf->config_orbase( "invoice_${format}returnaddress",
+                                        $template
+                                      )
+                )
+       && length( $conf->config_orbase( "invoice_${format}returnaddress",
+                                        $template
+                                      )
+                )
+  ) {
+
+    $returnaddress = join("\n",
+      $conf->config_orbase("invoice_${format}returnaddress", $template)
+    );
+
+  } elsif ( grep /\S/,
+            $conf->config_orbase('invoice_latexreturnaddress', $template) ) {
+
+    my $convert_map = $convert_maps{$format}{'returnaddress'};
+    $returnaddress =
+      join( "\n",
+            &$convert_map( $conf->config_orbase( "invoice_latexreturnaddress",
+                                                 $template
+                                               )
+                         )
+          );
+  } elsif ( grep /\S/, $conf->config('company_address', $cust_main->agentnum) ) {
+
+    my $convert_map = $convert_maps{$format}{'returnaddress'};
+    $returnaddress = join( "\n", &$convert_map(
+                                   map { s/( {2,})/'~' x length($1)/eg;
+                                         s/$/\\\\\*/;
+                                         $_
+                                       }
+                                     ( $conf->config('company_name', $cust_main->agentnum),
+                                       $conf->config('company_address', $cust_main->agentnum),
+                                     )
+                                 )
+                     );
+
+  } else {
+
+    my $warning = "Couldn't find a return address; ".
+                  "do you need to set the company_address configuration value?";
+    warn "$warning\n";
+    $returnaddress = $nbsp;
+    #$returnaddress = $warning;
+
+  }
+
+  warn "$me generating invoice data\n"
+    if $DEBUG > 1;
+
+  my $agentnum = $cust_main->agentnum;
+
+  my %invoice_data = (
+
+    #invoice from info
+    'company_name'    => scalar( $conf->config('company_name', $agentnum) ),
+    'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n",
+    'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ),
+    'returnaddress'   => $returnaddress,
+    'agent'           => &$escape_function($cust_main->agent->agent),
+
+    #invoice/quotation info
+    'invnum'          => $self->invnum,
+    'quotationnum'    => $self->quotationnum,
+    'date'            => time2str($date_format, $self->_date),
+    'today'           => time2str($date_format_long, $today),
+    'terms'           => $self->terms,
+    'template'        => $template, #params{'template'},
+    'notice_name'     => ($params{'notice_name'} || $self->notice_name),#escape_function?
+    'current_charges' => sprintf("%.2f", $self->charged),
+    'duedate'         => $self->due_date2str($rdate_format), #date_format?
+
+    #customer info
+    'custnum'         => $cust_main->display_custnum,
+    'prospectnum'     => $cust_main->prospectnum,
+    'agent_custid'    => &$escape_function($cust_main->agent_custid),
+    ( map { $_ => &$escape_function($cust_main->$_()) } qw(
+      payname company address1 address2 city state zip fax
+    )),
+
+    #global config
+    'ship_enable'     => $conf->exists('invoice-ship_address'),
+    'unitprices'      => $conf->exists('invoice-unitprice'),
+    '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)),
+    'headsep'               => scalar($conf->config('invoice_latexheadsep', $agentnum)),
+    'textheight'            => scalar($conf->config('invoice_latextextheight', $agentnum)),
+    'extracouponspace'      => scalar($conf->config('invoice_latexextracouponspace', $agentnum)),
+    'couponfootsep'         => scalar($conf->config('invoice_latexcouponfootsep', $agentnum)),
+    'verticalreturnaddress' => $conf->exists('invoice_latexverticalreturnaddress', $agentnum),
+    'addresssep'            => scalar($conf->config('invoice_latexaddresssep', $agentnum)),
+    'amountenclosedsep'     => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)),
+    'coupontoaddresssep'    => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)),
+    'addcompanytoaddress'   => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum),
+
+    # better hang on to conf_dir for a while (for old templates)
+    'conf_dir'        => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
+
+    #these are only used when doing paged plaintext
+    'page'            => 1,
+    '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 ) {
+    next unless $cust_bill_pkg->pkgnum > 0;
+    $min_sdate = $cust_bill_pkg->sdate
+      if length($cust_bill_pkg->sdate) && $cust_bill_pkg->sdate < $min_sdate;
+    $max_edate = $cust_bill_pkg->edate
+      if length($cust_bill_pkg->edate) && $cust_bill_pkg->edate > $max_edate;
+  }
+
+  $invoice_data{'bill_period'} = '';
+  $invoice_data{'bill_period'} = time2str('%e %h', $min_sdate) 
+    . " to " . time2str('%e %h', $max_edate)
+    if ($max_edate != 0 && $min_sdate != 999999999999);
+
+  $invoice_data{finance_section} = '';
+  if ( $conf->config('finance_pkgclass') ) {
+    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
+
+  my $countrydefault = $conf->config('countrydefault') || 'US';
+  foreach ( qw( address1 address2 city state zip country fax) ){
+    my $method = 'ship_'.$_;
+    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
+  }
+  foreach ( qw( contact company ) ) { #compatibility
+    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$_);
+  }
+  $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'} = '';
+  } else {
+    $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
+  }
+
+  my @address = ();
+  $invoice_data{'address'} = \@address;
+  push @address,
+    $cust_main->payname.
+      ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
+        ? " (P.O. #". $cust_main->payinfo. ")"
+        : ''
+      )
+  ;
+  push @address, $cust_main->company
+    if $cust_main->company;
+  push @address, $cust_main->address1;
+  push @address, $cust_main->address2
+    if $cust_main->address2;
+  push @address,
+    $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
+  push @address, $invoice_data{'country'}
+    if $invoice_data{'country'};
+  push @address, ''
+    while (scalar(@address) < 5);
+
+  $invoice_data{'logo_file'} = $params{'logo_file'}
+    if $params{'logo_file'};
+  $invoice_data{'barcode_file'} = $params{'barcode_file'}
+    if $params{'barcode_file'};
+  $invoice_data{'barcode_img'} = $params{'barcode_img'}
+    if $params{'barcode_img'};
+  $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 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;
+  }
+  $invoice_data{'summarypage'} = $summarypage;
+
+  warn "$me substituting variables in notes, footer, smallfooter\n"
+    if $DEBUG > 1;
+
+  my $tc = $self->template_conf;
+  my @include = ( [ $tc,        'notes' ],
+                  [ 'invoice_', 'footer' ],
+                  [ 'invoice_', 'smallfooter', ],
+                );
+  push @include, [ $tc,        'coupon', ]
+    unless $params{'no_coupon'};
+
+  foreach my $i (@include) {
+
+    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_tt = new Text::Template (
+      TYPE       => 'ARRAY',
+      SOURCE     => [ map "$_\n", @inc_src ],
+      DELIMITERS => $delimiters{$format},
+    ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
+
+    unless ( $inc_tt->compile() ) {
+      my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
+      warn $error. "Template:\n". join('', map "$_\n", @inc_src);
+      die $error;
+    }
+
+    $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;
+
+  my %money_chars = ( 'latex'    => '',
+                      'html'     => $conf->config('money_char') || '$',
+                      'template' => '',
+                    );
+  my $money_char = $money_chars{$format};
+
+  my %other_money_chars = ( 'latex'    => '\dollar ',#XXX should be a config too
+                            'html'     => $conf->config('money_char') || '$',
+                            'template' => '',
+                          );
+  my $other_money_char = $other_money_chars{$format};
+  $invoice_data{'dollar'} = $other_money_char;
+
+  my @detail_items = ();
+  my @total_items = ();
+  my @buf = ();
+  my @sections = ();
+
+  $invoice_data{'detail_items'} = \@detail_items;
+  $invoice_data{'total_items'} = \@total_items;
+  $invoice_data{'buf'} = \@buf;
+  $invoice_data{'sections'} = \@sections;
+
+  warn "$me generating sections\n"
+    if $DEBUG > 1;
+
+  my $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');
+
+  my $taxtotal = 0;
+  my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
+                      'subtotal'    => $taxtotal,   # adjusted below
+                    };
+  my $tax_weight = _pkg_category($tax_section->{description})
+                        ? _pkg_category($tax_section->{description})->weight
+                        : 0;
+  $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
+  $tax_section->{'sort_weight'} = $tax_weight;
+
+
+  my $adjusttotal = 0;
+  my $adjust_section = { 'description' => 
+    $self->mt('Credits, Payments, and Adjustments'),
+                         'subtotal'    => 0,   # adjusted below
+                       };
+  my $adjust_weight = _pkg_category($adjust_section->{description})
+                        ? _pkg_category($adjust_section->{description})->weight
+                        : 0;
+  $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';
+  my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum);
+  $invoice_data{'multisection'} = $multisection;
+  my $late_sections = [];
+  my $extra_sections = [];
+  my $extra_lines = ();
+
+  my $default_section = { 'description' => '',
+                          'subtotal'    => '', 
+                          'no_subtotal' => 1,
+                        };
+
+  if ( $multisection ) {
+    ($extra_sections, $extra_lines) =
+      $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
+      if $conf->exists('usage_class_as_a_section', $cust_main->agentnum)
+      && $self->can('_items_extra_usage_sections');
+
+    push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
+
+    push @detail_items, @$extra_lines if $extra_lines;
+    push @sections,
+      $self->_items_sections( $late_sections,      # this could stand a refactor
+                              $summarypage,
+                              $escape_function_nonbsp,
+                              $extra_sections,
+                              $format,             #bah
+                            );
+    if (    $conf->exists('svc_phone_sections')
+         && $self->can('_items_svc_phone_sections')
+       )
+    {
+      my ($phone_sections, $phone_lines) =
+        $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
+      push @{$late_sections}, @$phone_sections;
+      push @detail_items, @$phone_lines;
+    }
+    if ( $conf->exists('voip-cust_accountcode_cdr')
+         && $cust_main->accountcode_cdr
+         && $self->can('_items_accountcode_cdr')
+       )
+    {
+      my ($accountcode_section, $accountcode_lines) =
+        $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
+      if ( scalar(@$accountcode_lines) ) {
+          push @{$late_sections}, $accountcode_section;
+          push @detail_items, @$accountcode_lines;
+      }
+    }
+  } else {# not multisection
+    # make a default section
+    push @sections, $default_section;
+    # 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', $agentnum)
+           || $conf->exists('previous_balance-summary_only')
+           || ! $self->can('_items_previous')
+         )
+  {
+
+    warn "$me adding previous balances\n"
+      if $DEBUG > 1;
+
+    foreach my $line_item ( $self->_items_previous ) {
+
+      my $detail = {
+        ext_description => [],
+      };
+      $detail->{'ref'} = $line_item->{'pkgnum'};
+      $detail->{'quantity'} = 1;
+      $detail->{'section'} = $multisection ? $previous_section
+                                           : $default_section;
+      $detail->{'description'} = &$escape_function($line_item->{'description'});
+      if ( exists $line_item->{'ext_description'} ) {
+        @{$detail->{'ext_description'}} = map {
+          &$escape_function($_);
+        } @{$line_item->{'ext_description'}};
+      }
+      $detail->{'amount'} = ( $old_latex ? '' : $money_char).
+                            $line_item->{'amount'};
+      $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+
+      push @detail_items, $detail;
+      push @buf, [ $detail->{'description'},
+                   $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+                 ];
+    }
+
+  }
+  
+  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance', $agentnum) ) 
+    {
+    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, 
+       { 'description' => $didsummary_desc,
+           'ext_description' => [ $didsummary, $minutes ],
+       };
+  }
+
+  foreach my $section (@sections, @$late_sections) {
+
+    warn "$me adding section \n". Dumper($section)
+      if $DEBUG > 1;
+
+    # begin some normalization
+    $section->{'subtotal'} = $section->{'amount'}
+      if $multisection
+         && !exists($section->{subtotal})
+         && exists($section->{amount});
+
+    $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
+      if ( $invoice_data{finance_section} &&
+           $section->{'description'} eq $invoice_data{finance_section} );
+
+    $section->{'subtotal'} = $other_money_char.
+                             sprintf('%.2f', $section->{'subtotal'})
+      if $multisection;
+
+    # continue some normalization
+    $section->{'amount'}   = $section->{'subtotal'}
+      if $multisection;
+
+
+    if ( $section->{'description'} ) {
+      push @buf, ( [ &$escape_function($section->{'description'}), '' ],
+                   [ '', '' ],
+                 );
+    }
+
+    warn "$me   setting options\n"
+      if $DEBUG > 1;
+
+    my $multilocation = scalar($cust_main->cust_location); #too expensive?
+    my %options = ();
+    $options{'section'} = $section if $multisection;
+    $options{'format'} = $format;
+    $options{'escape_function'} = $escape_function;
+    $options{'no_usage'} = 1 unless $unsquelched;
+    $options{'unsquelched'} = $unsquelched;
+    $options{'summary_page'} = $summarypage;
+    $options{'skip_usage'} =
+      scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
+    $options{'multilocation'} = $multilocation;
+    $options{'multisection'} = $multisection;
+
+    warn "$me   searching for line items\n"
+      if $DEBUG > 1;
+
+    foreach my $line_item ( $self->_items_pkg(%options) ) {
+
+      warn "$me     adding line item $line_item\n"
+        if $DEBUG > 1;
+
+      my $detail = {
+        ext_description => [],
+      };
+      $detail->{'ref'} = $line_item->{'pkgnum'};
+      $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'};
+      $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'},
+                     $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+                   ],
+                   map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
+                 );
+    }
+
+    if ( $section->{'description'} ) {
+      push @buf, ( ['','-----------'],
+                   [ $section->{'description'}. ' sub-total',
+                      $section->{'subtotal'} # already formatted this 
+                   ],
+                   [ '', '' ],
+                   [ '', '' ],
+                 );
+    }
+  
+  }
+
+  $invoice_data{current_less_finance} =
+    sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
+
+  if ( $multisection && !$conf->exists('disable_previous_balance', $agentnum)
+    || $conf->exists('previous_balance-summary_only') )
+  {
+    unshift @sections, $previous_section if $pr_total;
+  }
+
+  warn "$me adding taxes\n"
+    if $DEBUG > 1;
+
+  foreach my $tax ( $self->_items_tax ) {
+
+    $taxtotal += $tax->{'amount'};
+
+    my $description = &$escape_function( $tax->{'description'} );
+    my $amount      = sprintf( '%.2f', $tax->{'amount'} );
+
+    if ( $multisection ) {
+
+      my $money = $old_latex ? '' : $money_char;
+      push @detail_items, {
+        ext_description => [],
+        ref          => '',
+        quantity     => '',
+        description  => $description,
+        amount       => $money. $amount,
+        product_code => '',
+        section      => $tax_section,
+      };
+
+    } else {
+
+      push @total_items, {
+        'total_item'   => $description,
+        'total_amount' => $other_money_char. $amount,
+      };
+
+    }
+
+    push @buf,[ $description,
+                $money_char. $amount,
+              ];
+
+  }
+  
+  if ( $taxtotal ) {
+    my $total = {};
+    $total->{'total_item'} = $self->mt('Sub-total');
+    $total->{'total_amount'} =
+      $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
+
+    if ( $multisection ) {
+      $tax_section->{'subtotal'} = $other_money_char.
+                                   sprintf('%.2f', $taxtotal);
+      $tax_section->{'pretotal'} = 'New charges sub-total '.
+                                   $total->{'total_amount'};
+      push @sections, $tax_section if $taxtotal;
+    }else{
+      unshift @total_items, $total;
+    }
+  }
+  $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
+
+  push @buf,['','-----------'];
+  push @buf,[$self->mt( 
+              $conf->exists('disable_previous_balance', $agentnum) 
+               ? 'Total Charges'
+               : 'Total New Charges'
+             ),
+             $money_char. sprintf("%10.2f",$self->charged) ];
+  push @buf,['',''];
+
+  {
+    my $total = {};
+    my $item = 'Total';
+    $item = $conf->config('previous_balance-exclude_from_total')
+         || 'Total New Charges'
+      if $conf->exists('previous_balance-exclude_from_total');
+    my $amount = $self->charged +
+                   ( $conf->exists('disable_previous_balance', $agentnum) ||
+                     $conf->exists('previous_balance-exclude_from_total')
+                     ? 0
+                     : $pr_total
+                   );
+    $total->{'total_item'} = &$embolden_function($self->mt($item));
+    $total->{'total_amount'} =
+      &$embolden_function( $other_money_char.  sprintf( '%.2f', $amount ) );
+    if ( $multisection ) {
+      if ( $adjust_section->{'sort_weight'} ) {
+        $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
+          $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
+      } else {
+        $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
+          $other_money_char.  sprintf('%.2f', $self->charged );
+      } 
+    }else{
+      push @total_items, $total;
+    }
+    push @buf,['','-----------'];
+    push @buf,[$item,
+               $money_char.
+               sprintf( '%10.2f', $amount )
+              ];
+    push @buf,['',''];
+  }
+  
+  unless (    $conf->exists('disable_previous_balance', $agentnum) 
+           || ! $self->can('_items_credits')
+           || ! $self->can('_items_payments')
+         )
+  {
+    #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
+  
+    # credits
+    my $credittotal = 0;
+    foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
+
+      my $total;
+      $total->{'total_item'} = &$escape_function($credit->{'description'});
+      $credittotal += $credit->{'amount'};
+      $total->{'total_amount'} = '-'. $other_money_char. $credit->{'amount'};
+      $adjusttotal += $credit->{'amount'};
+      if ( $multisection ) {
+        my $money = $old_latex ? '' : $money_char;
+        push @detail_items, {
+          ext_description => [],
+          ref          => '',
+          quantity     => '',
+          description  => &$escape_function($credit->{'description'}),
+          amount       => $money. $credit->{'amount'},
+          product_code => '',
+          section      => $adjust_section,
+        };
+      } else {
+        push @total_items, $total;
+      }
+
+    }
+    $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
+
+    #credits (again)
+    foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
+      push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
+    }
+
+    # payments
+    my $paymenttotal = 0;
+    foreach my $payment ( $self->_items_payments ) {
+      my $total = {};
+      $total->{'total_item'} = &$escape_function($payment->{'description'});
+      $paymenttotal += $payment->{'amount'};
+      $total->{'total_amount'} = '-'. $other_money_char. $payment->{'amount'};
+      $adjusttotal += $payment->{'amount'};
+      if ( $multisection ) {
+        my $money = $old_latex ? '' : $money_char;
+        push @detail_items, {
+          ext_description => [],
+          ref          => '',
+          quantity     => '',
+          description  => &$escape_function($payment->{'description'}),
+          amount       => $money. $payment->{'amount'},
+          product_code => '',
+          section      => $adjust_section,
+        };
+      }else{
+        push @total_items, $total;
+      }
+      push @buf, [ $payment->{'description'},
+                   $money_char. sprintf("%10.2f", $payment->{'amount'}),
+                 ];
+    }
+    $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
+  
+    if ( $multisection ) {
+      $adjust_section->{'subtotal'} = $other_money_char.
+                                      sprintf('%.2f', $adjusttotal);
+      push @sections, $adjust_section
+        unless $adjust_section->{sort_weight};
+    }
+
+    # create Balance Due message
+    { 
+      my $total;
+      $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
+      $total->{'total_amount'} =
+        &$embolden_function(
+          $other_money_char. sprintf('%.2f', $summarypage 
+                                               ? $self->charged +
+                                                 $self->billing_balance
+                                               : $self->owed + $pr_total
+                                    )
+        );
+      if ( $multisection && !$adjust_section->{sort_weight} ) {
+        $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
+                                         $total->{'total_amount'};
+      }else{
+        push @total_items, $total;
+      }
+      push @buf,['','-----------'];
+      push @buf,[$self->balance_due_msg, $money_char. 
+        sprintf("%10.2f", $balance_due ) ];
+    }
+
+    if ( $conf->exists('previous_balance-show_credit')
+        and $cust_main->balance < 0 ) {
+      my $credit_total = {
+        'total_item'    => &$embolden_function($self->credit_balance_msg),
+        'total_amount'  => &$embolden_function(
+          $other_money_char. sprintf('%.2f', -$cust_main->balance)
+        ),
+      };
+      if ( $multisection ) {
+        $adjust_section->{'posttotal'} .= $newline_token .
+          $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
+      }
+      else {
+        push @total_items, $credit_total;
+      }
+      push @buf,['','-----------'];
+      push @buf,[$self->credit_balance_msg, $money_char. 
+        sprintf("%10.2f", -$cust_main->balance ) ];
+    }
+  }
+
+  if ( $multisection ) {
+    if (    $conf->exists('svc_phone_sections')
+         && $self->can('_items_svc_phone_sections')
+       )
+    {
+      my $total;
+      $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
+      $total->{'total_amount'} =
+        &$embolden_function(
+          $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
+        );
+      my $last_section = pop @sections;
+      $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
+                                     $total->{'total_amount'};
+      push @sections, $last_section;
+    }
+    push @sections, @$late_sections
+      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 ) {
+
+    my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
+    my @inc_src;
+
+    if ( length( $conf->config($inc_file, $agentnum) ) ) {
+
+      @inc_src = $conf->config($inc_file, $agentnum);
+
+    } else {
+
+      $inc_file = $conf->key_orbase("invoice_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_tt = new Text::Template (
+      TYPE       => 'ARRAY',
+      SOURCE     => [ map "$_\n", @inc_src ],
+      DELIMITERS => $delimiters{$format},
+    ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
+
+    unless ( $inc_tt->compile() ) {
+      my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
+      warn $error. "Template:\n". join('', map "$_\n", @inc_src);
+      die $error;
+    }
+
+    $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
+
+    $invoice_data{$include} =~ s/\n+$//
+      if ($format eq 'latex');
+  }
+
+  $invoice_lines = 0;
+  my $wasfunc = 0;
+  foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
+    /invoice_lines\((\d*)\)/;
+    $invoice_lines += $1 || scalar(@buf);
+    $wasfunc=1;
+  }
+  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;
+    }
+
+    #setup subroutine for the template
+    $invoice_data{invoice_lines} = sub {
+      my $lines = shift || scalar(@buf);
+      map { 
+        scalar(@buf)
+          ? shift @buf
+          : [ '', '' ];
+      }
+      ( 1 .. $lines );
+    };
+
+    my $lines;
+    my @collect;
+    while (@buf) {
+      push @collect, split("\n",
+        $text_template->fill_in( HASH => \%invoice_data )
+      );
+      $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"
+      if $DEBUG > 1;
+
+    $text_template->fill_in(HASH => \%invoice_data);
+  }
+}
+
+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;
+}
+
+=item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
+
+Returns an postscript invoice, as a scalar.
+
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time> an optional value 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.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+=cut
+
+sub print_ps {
+  my $self = shift;
+
+  my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
+  my $ps = generate_ps($file);
+  unlink($logofile);
+  unlink($barcodefile) if $barcodefile;
+
+  $ps;
+}
+
+=item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
+
+Returns an PDF invoice, as a scalar.
+
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time> an optional value 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.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+=cut
+
+sub print_pdf {
+  my $self = shift;
+
+  my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
+  my $pdf = generate_pdf($file);
+  unlink($logofile);
+  unlink($barcodefile) if $barcodefile;
+
+  $pdf;
+}
+
+=item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
+
+Returns an HTML invoice, as a scalar.
+
+I<time> an optional value 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.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
+when emailing the invoice as part of a multipart/related MIME email.
+
+=cut
+
+sub print_html {
+  my $self = shift;
+  my %params;
+  if ( ref($_[0]) ) {
+    %params = %{ shift() }; 
+  }else{
+    $params{'time'} = shift;
+    $params{'template'} = shift;
+    $params{'cid'} = shift;
+  }
+
+  $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: 
+#      # $ % & ~ _ ^ \ { }
+#
+# 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 ..."). 
+
+sub _latex_escape {
+  my $value = shift;
+  $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
+  $value =~ s/([<>])/\$$1\$/g;
+  $value;
+}
+
+sub _html_escape {
+  my $value = shift;
+  encode_entities($value);
+  $value;
+}
+
+sub _html_escape_nbsp {
+  my $value = _html_escape(shift);
+  $value =~ s/ +/ /g;
+  $value;
+}
+
+#utility methods for print_*
+
+sub _translate_old_latex_format {
+  warn "_translate_old_latex_format called\n"
+    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!        "\\tabularnewline\n~~".!,
+                      q!        join( "\\tabularnewline\n~~",!,
+                      q!          @{$_tr_line->{'ext_description'}}!,
+                      q!        );!,
+                      q!    }!;
+
+      while ( ( my $line_item_line = shift )
+              !~ /^%%EndDetail\s*$/                            ) {
+        $line_item_line =~ s/'/\\'/g;    # nice LTS
+        $line_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
+        $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
+        push @template, "    \$OUT .= '$line_item_line';";
+      }
+
+      push @template, '}',
+                      '--@]';
+      #' doh, gvim
+    } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
+
+      push @template, '[@--',
+                      '  foreach my $_tr_line (@total_items) {';
+
+      while ( ( my $total_item_line = shift )
+              !~ /^%%EndTotalDetails\s*$/                      ) {
+        $total_item_line =~ s/'/\\'/g;    # nice LTS
+        $total_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
+        $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
+        push @template, "    \$OUT .= '$total_item_line';";
+      }
+
+      push @template, '}',
+                      '--@]';
+
+    } else {
+      $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
+      push @template, $line;  
+    }
+  
+  }
+
+  if ($DEBUG) {
+    warn "$_\n" foreach @template;
+  }
+
+  (@template);
+}
+
+sub terms {
+  my $self = shift;
+  my $conf = $self->conf;
+
+  #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;
+
+  #use configured default
+  $conf->config('invoice_default_terms') || '';
+}
+
+sub due_date {
+  my $self = shift;
+  my $duedate = '';
+  if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
+    $duedate = $self->_date() + ( $1 * 86400 );
+  }
+  $duedate;
+}
+
+sub due_date2str {
+  my $self = shift;
+  $self->due_date ? time2str(shift, $self->due_date) : '';
+}
+
+sub balance_due_msg {
+  my $self = shift;
+  my $msg = $self->mt('Balance Due');
+  return $msg unless $self->terms;
+  if ( $self->due_date ) {
+    $msg .= ' - ' . $self->mt('Please pay by'). ' '.
+      $self->due_date2str($date_format);
+  } elsif ( $self->terms ) {
+    $msg .= ' - '. $self->terms;
+  }
+  $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*$/ ) {
+    $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
+  }
+  $duedate;
+}
+
+sub credit_balance_msg { 
+  my $self = shift;
+  $self->mt('Credit Balance Remaining')
+}
+
+=item _date_pretty
+
+Returns a string with the date, for example: "3/20/2008"
+
+=cut
+
+sub _date_pretty {
+  my $self = shift;
+  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;
+  my $late = shift;
+  my $summarypage = shift;
+  my $escape = shift;
+  my $extra_sections = shift;
+  my $format = shift;
+
+  my %subtotal = ();
+  my %late_subtotal = ();
+  my %not_tax = ();
+
+  foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
+  {
+
+      my $usage = $cust_bill_pkg->usage;
+
+      foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
+        next if ( $display->summary && $summarypage );
+
+        my $section = $display->section;
+        my $type    = $display->type;
+
+        $not_tax{$section} = 1
+          unless $cust_bill_pkg->pkgnum == 0;
+
+        if ( $display->post_total && !$summarypage ) {
+          if (! $type || $type eq 'S') {
+            $late_subtotal{$section} += $cust_bill_pkg->setup
+              if $cust_bill_pkg->setup != 0
+              || $cust_bill_pkg->setup_show_zero;
+          }
+
+          if (! $type) {
+            $late_subtotal{$section} += $cust_bill_pkg->recur
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
+          }
+
+          if ($type && $type eq 'R') {
+            $late_subtotal{$section} += $cust_bill_pkg->recur - $usage
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
+          }
+          
+          if ($type && $type eq 'U') {
+            $late_subtotal{$section} += $usage
+              unless scalar(@$extra_sections);
+          }
+
+        } else {
+
+          next if $cust_bill_pkg->pkgnum == 0 && ! $section;
+
+          if (! $type || $type eq 'S') {
+            $subtotal{$section} += $cust_bill_pkg->setup
+              if $cust_bill_pkg->setup != 0
+              || $cust_bill_pkg->setup_show_zero;
+          }
+
+          if (! $type) {
+            $subtotal{$section} += $cust_bill_pkg->recur
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
+          }
+
+          if ($type && $type eq 'R') {
+            $subtotal{$section} += $cust_bill_pkg->recur - $usage
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
+          }
+          
+          if ($type && $type eq 'U') {
+            $subtotal{$section} += $usage
+              unless scalar(@$extra_sections);
+          }
+
+        }
+
+      }
+
+  }
+
+  %pkg_category_cache = ();
+
+  push @$late, map { { 'description' => &{$escape}($_),
+                       'subtotal'    => $late_subtotal{$_},
+                       'post_total'  => 1,
+                       'sort_weight' => ( _pkg_category($_)
+                                            ? _pkg_category($_)->weight
+                                            : 0
+                                       ),
+                       ((_pkg_category($_) && _pkg_category($_)->condense)
+                                           ? $self->_condense_section($format)
+                                           : ()
+                       ),
+                   } }
+                 sort _sectionsort keys %late_subtotal;
+
+  my @sections;
+  if ( $summarypage ) {
+    @sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled }
+                map { $_->categoryname } qsearch('pkg_category', {});
+    push @sections, '' if exists($subtotal{''});
+  } else {
+    @sections = keys %subtotal;
+  }
+
+  my @early = map { { 'description' => &{$escape}($_),
+                      'subtotal'    => $subtotal{$_},
+                      'summarized'  => $not_tax{$_} ? '' : 'Y',
+                      'tax_section' => $not_tax{$_} ? '' : 'Y',
+                      'sort_weight' => ( _pkg_category($_)
+                                           ? _pkg_category($_)->weight
+                                           : 0
+                                       ),
+                       ((_pkg_category($_) && _pkg_category($_)->condense)
+                                           ? $self->_condense_section($format)
+                                           : ()
+                       ),
+                    }
+                  } @sections;
+  push @early, @$extra_sections if $extra_sections;
+
+  sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
+
+}
+
+#helper subs for above
+
+sub _sectionsort {
+  _pkg_category($a)->weight <=> _pkg_category($b)->weight;
+}
+
+sub _pkg_category {
+  my $categoryname = shift;
+  $pkg_category_cache{$categoryname} ||=
+    qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
+}
+
+my %condensed_format = (
+  'label' => [ qw( Description Qty Amount ) ],
+  'fields' => [
+                sub { shift->{description} },
+                sub { shift->{quantity} },
+                sub { my($href, %opt) = @_;
+                      ($opt{dollar} || ''). $href->{amount};
+                    },
+              ],
+  'align'  => [ qw( l r r ) ],
+  'span'   => [ qw( 5 1 1 ) ],            # unitprices?
+  'width'  => [ qw( 10.7cm 1.4cm 1.6cm ) ],   # don't like this
+);
+
+sub _condense_section {
+  my ( $self, $format ) = ( shift, shift );
+  ( 'condensed' => 1,
+    map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
+      qw( description_generator
+          header_generator
+          total_generator
+          total_line_generator
+        )
+  );
+}
+
+sub _condensed_generator_defaults {
+  my ( $self, $format ) = ( shift, shift );
+  return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
+}
+
+my %html_align = (
+  'c' => 'center',
+  'l' => 'left',
+  'r' => 'right',
+);
+
+sub _condensed_header_generator {
+  my ( $self, $format ) = ( shift, shift );
+
+  my ( $f, $prefix, $suffix, $separator, $column ) =
+    _condensed_generator_defaults($format);
+
+  if ($format eq 'latex') {
+    $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
+    $suffix = "\\\\\n\\hline";
+    $separator = "&\n";
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
+          };
+  } elsif ( $format eq 'html' ) {
+    $prefix = '<th></th>';
+    $suffix = '';
+    $separator = '';
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return qq!<th align="$html_align{$a}">$d</th>!;
+      };
+  }
+
+  sub {
+    my @args = @_;
+    my @result = ();
+
+    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
+      push @result,
+        &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
+    }
+
+    $prefix. join($separator, @result). $suffix;
+  };
+
+}
+
+sub _condensed_description_generator {
+  my ( $self, $format ) = ( shift, shift );
+
+  my ( $f, $prefix, $suffix, $separator, $column ) =
+    _condensed_generator_defaults($format);
+
+  my $money_char = '$';
+  if ($format eq 'latex') {
+    $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
+    $suffix = '\\\\';
+    $separator = " & \n";
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
+          };
+    $money_char = '\\dollar';
+  }elsif ( $format eq 'html' ) {
+    $prefix = '"><td align="center"></td>';
+    $suffix = '';
+    $separator = '';
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return qq!<td align="$html_align{$a}">$d</td>!;
+      };
+    #$money_char = $conf->config('money_char') || '$';
+    $money_char = '';  # this is madness
+  }
+
+  sub {
+    #my @args = @_;
+    my $href = shift;
+    my @result = ();
+
+    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
+      my $dollar = '';
+      $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
+      push @result,
+        &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
+                    map { $f->{$_}->[$i] } qw(align span width)
+                  );
+    }
+
+    $prefix. join( $separator, @result ). $suffix;
+  };
+
+}
+
+sub _condensed_total_generator {
+  my ( $self, $format ) = ( shift, shift );
+
+  my ( $f, $prefix, $suffix, $separator, $column ) =
+    _condensed_generator_defaults($format);
+  my $style = '';
+
+  if ($format eq 'latex') {
+    $prefix = "& ";
+    $suffix = "\\\\\n";
+    $separator = " & \n";
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
+          };
+  }elsif ( $format eq 'html' ) {
+    $prefix = '';
+    $suffix = '';
+    $separator = '';
+    $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
+      };
+  }
+
+
+  sub {
+    my @args = @_;
+    my @result = ();
+
+    #  my $r = &{$f->{fields}->[$i]}(@args);
+    #  $r .= ' Total' unless $i;
+
+    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
+      push @result,
+        &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
+                    map { $f->{$_}->[$i] } qw(align span width)
+                  );
+    }
+
+    $prefix. join( $separator, @result ). $suffix;
+  };
+
+}
+
+=item total_line_generator FORMAT
+
+Returns a coderef used for generation of invoice total line items for this
+usage_class.  FORMAT is either html or latex
+
+=cut
+
+# should not be used: will have issues with hash element names (description vs
+# total_item and amount vs total_amount -- another array of functions?
+
+sub _condensed_total_line_generator {
+  my ( $self, $format ) = ( shift, shift );
+
+  my ( $f, $prefix, $suffix, $separator, $column ) =
+    _condensed_generator_defaults($format);
+  my $style = '';
+
+  if ($format eq 'latex') {
+    $prefix = "& ";
+    $suffix = "\\\\\n";
+    $separator = " & \n";
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
+          };
+  }elsif ( $format eq 'html' ) {
+    $prefix = '';
+    $suffix = '';
+    $separator = '';
+    $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
+      };
+  }
+
+
+  sub {
+    my @args = @_;
+    my @result = ();
+
+    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
+      push @result,
+        &{$column}( &{$f->{fields}->[$i]}(@args),
+                    map { $f->{$_}->[$i] } qw(align span width)
+                  );
+    }
+
+    $prefix. join( $separator, @result ). $suffix;
+  };
+
+}
+
+#  sub _items { # seems to be unused
+#    my $self = shift;
+#  
+#    #my @display = scalar(@_)
+#    #              ? @_
+#    #              : qw( _items_previous _items_pkg );
+#    #              #: qw( _items_pkg );
+#    #              #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
+#    my @display = qw( _items_previous _items_pkg );
+#  
+#    my @b = ();
+#    foreach my $display ( @display ) {
+#      push @b, $self->$display(@_);
+#    }
+#    @b;
+#  }
+
+=item _items_pkg [ OPTIONS ]
+
+Return line item hashes for each package item on this invoice. Nearly 
+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 = @_;
+
+  warn "$me _items_pkg searching for all package line items\n"
+    if $DEBUG > 1;
+
+  my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
+
+  warn "$me _items_pkg filtering line items\n"
+    if $DEBUG > 1;
+  my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
+
+  if ($options{section} && $options{section}->{condensed}) {
+
+    warn "$me _items_pkg condensing section\n"
+      if $DEBUG > 1;
+
+    my %itemshash = ();
+    local $Storable::canonical = 1;
+    foreach ( @items ) {
+      my $item = { %$_ };
+      delete $item->{ref};
+      delete $item->{ext_description};
+      my $key = freeze($item);
+      $itemshash{$key} ||= 0;
+      $itemshash{$key} ++; # += $item->{quantity};
+    }
+    @items = sort { $a->{description} cmp $b->{description} }
+             map { my $i = thaw($_);
+                   $i->{quantity} = $itemshash{$_};
+                   $i->{amount} =
+                     sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
+                   $i;
+                 }
+             keys %itemshash;
+  }
+
+  warn "$me _items_pkg returning ". scalar(@items). " items\n"
+    if $DEBUG > 1;
+
+  @items;
+}
+
+sub _taxsort {
+  return 0 unless $a->itemdesc cmp $b->itemdesc;
+  return -1 if $b->itemdesc eq 'Tax';
+  return 1 if $a->itemdesc eq 'Tax';
+  return -1 if $b->itemdesc eq 'Other surcharges';
+  return 1 if $a->itemdesc eq 'Other surcharges';
+  $a->itemdesc cmp $b->itemdesc;
+}
+
+sub _items_tax {
+  my $self = shift;
+  my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
+  $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
+}
+
+=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 $no_usage = $opt{no_usage} || '';
+  my $unsquelched = $opt{unsquelched} || ''; #unused
+  my $section = $opt{section}->{description} if $opt{section};
+  my $summary_page = $opt{summary_page} || ''; #unused
+  my $multilocation = $opt{multilocation} || '';
+  my $multisection = $opt{multisection} || '';
+  my $discount_show_always = 0;
+
+  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
+
+  my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
+
+  my @b = ();
+  my ($s, $r, $u) = ( undef, undef, undef );
+  foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
+  {
+
+    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+      if ( $_ && !$cust_bill_pkg->hidden ) {
+        $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+        $_->{amount}      =~ s/^\-0\.00$/0.00/;
+        $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+        push @b, { %$_ }
+          if $_->{amount} != 0
+          || $discount_show_always
+          || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+          || (   $_->{_is_setup} && $_->{setup_show_zero} )
+        ;
+        $_ = undef;
+      }
+    }
+
+    my @cust_bill_pkg_display = $cust_bill_pkg->cust_bill_pkg_display;
+
+    warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
+         $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
+      if $DEBUG > 1;
+
+    foreach my $display ( grep { defined($section)
+                                 ? $_->section eq $section
+                                 : 1
+                               }
+                          #grep { !$_->summary || !$summary_page } # bunk!
+                          grep { !$_->summary || $multisection }
+                          @cust_bill_pkg_display
+                        )
+    {
+
+      warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
+           $display->billpkgdisplaynum. "\n"
+        if $DEBUG > 1;
+
+      my $type = $display->type;
+
+      my $desc = $cust_bill_pkg->desc;
+      $desc = substr($desc, 0, $maxlength). '...'
+        if $format eq 'latex' && length($desc) > $maxlength;
+
+      my %details_opt = ( 'format'          => $format,
+                          'escape_function' => $escape_function,
+                          'format_function' => $format_function,
+                          'no_usage'        => $opt{'no_usage'},
+                        );
+
+      if ( $cust_bill_pkg->pkgnum > 0 ) {
+
+        warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
+          if $DEBUG > 1;
+ 
+        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
+                )
+           )
+         {
+
+          warn "$me _items_cust_bill_pkg adding setup\n"
+            if $DEBUG > 1;
+
+          my $description = $desc;
+          $description .= ' Setup'
+            if $cust_bill_pkg->recur != 0
+            || $discount_show_always
+            || $cust_bill_pkg->recur_show_zero;
+
+          my @d = ();
+          unless ( $cust_pkg->part_pkg->hide_svc_detail
+                || $cust_bill_pkg->hidden )
+          {
+
+            push @d, map &{$escape_function}($_),
+                         $cust_pkg->h_labels_short($self->_date, undef, 'I')
+              unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
+
+            if ( $multilocation ) {
+              my $loc = $cust_pkg->location_label;
+              $loc = substr($loc, 0, $maxlength). '...'
+                if $format eq 'latex' && length($loc) > $maxlength;
+              push @d, &{$escape_function}($loc);
+            }
+
+          } #unless hiding service details
+
+          push @d, $cust_bill_pkg->details(%details_opt)
+            if $cust_bill_pkg->recur == 0;
+
+          if ( $cust_bill_pkg->hidden ) {
+            $s->{amount}      += $cust_bill_pkg->setup;
+            $s->{unit_amount} += $cust_bill_pkg->unitsetup;
+            push @{ $s->{ext_description} }, @d;
+          } else {
+            $s = {
+              _is_setup       => 1,
+              description     => $description,
+              #pkgpart         => $part_pkg->pkgpart,
+              pkgnum          => $cust_bill_pkg->pkgnum,
+              amount          => $cust_bill_pkg->setup,
+              setup_show_zero => $cust_bill_pkg->setup_show_zero,
+              unit_amount     => $cust_bill_pkg->unitsetup,
+              quantity        => $cust_bill_pkg->quantity,
+              ext_description => \@d,
+            };
+          };
+
+        }
+
+        if (    ( !$type || $type eq 'R' || $type eq 'U' )
+             && (
+                     $cust_bill_pkg->recur != 0
+                  || $cust_bill_pkg->setup == 0
+                  || $discount_show_always
+                  || $cust_bill_pkg->recur_show_zero
+                )
+           )
+        {
+
+          warn "$me _items_cust_bill_pkg adding recur/usage\n"
+            if $DEBUG > 1;
+
+          my $is_summary = $display->summary;
+          my $description = ($is_summary && $type && $type eq 'U')
+                            ? "Usage charges" : $desc;
+
+          #pry be a bit more efficient to look some of this conf stuff up
+          # outside the loop
+          unless (
+            $conf->exists('disable_line_item_date_ranges')
+              || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1)
+          ) {
+            my $time_period;
+            my $date_style = $conf->config( 'cust_bill-line_item-date_style',
+                                            $cust_main->agentnum
+                                          );
+            if ( defined($date_style) && $date_style eq 'month_of' ) {
+              $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
+            } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
+              my $desc = $conf->config( 'cust_bill-line_item-date_description',
+                                         $cust_main->agentnum
+                                      );
+              $desc .= ' ' unless $desc =~ /\s$/;
+              $time_period = $desc. time2str('%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
+          my @dates = ( $self->_date );
+          my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
+          push @dates, $prev->sdate if $prev;
+          push @dates, undef if !$prev;
+
+          unless ( $cust_pkg->part_pkg->hide_svc_detail
+                || $cust_bill_pkg->itemdesc
+                || $cust_bill_pkg->hidden
+                || $is_summary && $type && $type eq 'U' )
+          {
+
+            warn "$me _items_cust_bill_pkg adding service details\n"
+              if $DEBUG > 1;
+
+            push @d, map &{$escape_function}($_),
+                         $cust_pkg->h_labels_short(@dates, 'I')
+                                                   #$cust_bill_pkg->edate,
+                                                   #$cust_bill_pkg->sdate)
+              unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
+
+            warn "$me _items_cust_bill_pkg done adding service details\n"
+              if $DEBUG > 1;
+
+            if ( $multilocation ) {
+              my $loc = $cust_pkg->location_label;
+              $loc = substr($loc, 0, $maxlength). '...'
+                if $format eq 'latex' && length($loc) > $maxlength;
+              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 ) {
+            warn "$me _items_cust_bill_pkg adding details\n"
+              if $DEBUG > 1;
+
+            #instead of omitting details entirely in this case (unwanted side
+            # effects), just omit CDRs
+            $details_opt{'no_usage'} = 1
+              if $type && $type eq 'R';
+
+            push @d, $cust_bill_pkg->details(%details_opt);
+          }
+
+          warn "$me _items_cust_bill_pkg calculating amount\n"
+            if $DEBUG > 1;
+  
+          my $amount = 0;
+          if (!$type) {
+            $amount = $cust_bill_pkg->recur;
+          } elsif ($type eq 'R') {
+            $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
+          } elsif ($type eq 'U') {
+            $amount = $cust_bill_pkg->usage;
+          }
+  
+          if ( !$type || $type eq 'R' ) {
+
+            warn "$me _items_cust_bill_pkg adding recur\n"
+              if $DEBUG > 1;
+
+            if ( $cust_bill_pkg->hidden ) {
+              $r->{amount}      += $amount;
+              $r->{unit_amount} += $cust_bill_pkg->unitrecur;
+              push @{ $r->{ext_description} }, @d;
+            } else {
+              $r = {
+                description     => $description,
+                #pkgpart         => $part_pkg->pkgpart,
+                pkgnum          => $cust_bill_pkg->pkgnum,
+                amount          => $amount,
+                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'
+
+            warn "$me _items_cust_bill_pkg adding usage\n"
+              if $DEBUG > 1;
+
+            if ( $cust_bill_pkg->hidden ) {
+              $u->{amount}      += $amount;
+              $u->{unit_amount} += $cust_bill_pkg->unitrecur;
+              push @{ $u->{ext_description} }, @d;
+            } else {
+              $u = {
+                description     => $description,
+                #pkgpart         => $part_pkg->pkgpart,
+                pkgnum          => $cust_bill_pkg->pkgnum,
+                amount          => $amount,
+                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,
+              };
+            }
+          }
+
+        } # recurring or usage with recurring charge
+
+      } else { #pkgnum tax or one-shot line item (??)
+
+        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),
+          };
+        }
+
+      }
+
+    }
+
+    $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
+                                && $conf->exists('discount-show-always'));
+
+  }
+
+  foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+    if ( $_  ) {
+      $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+      $_->{amount}      =~ s/^\-0\.00$/0.00/;
+      $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+      push @b, { %$_ }
+        if $_->{amount} != 0
+        || $discount_show_always
+        || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+        || (   $_->{_is_setup} && $_->{setup_show_zero} )
+    }
+  }
+
+  warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
+    if $DEBUG > 1;
+
+  @b;
+
+}
+
+=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;
+
+  #maybe move this method from cust_bill when quotations support discount_plans 
+  return () unless $self->can('discount_plans');
+  my %plans = $self->discount_plans;
+
+  my $list_pkgnums = 0; # if any packages are not eligible for all discounts
+  $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;
+
+    # discounts for non-integer months don't work anyway
+    $months = sprintf("%d", $months);
+
+    +{
+      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;
+
+}
+
+1;
diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm
index 8d4b346..417b202 100644
--- a/FS/FS/Upgrade.pm
+++ b/FS/FS/Upgrade.pm
@@ -4,6 +4,7 @@ use strict;
 use vars qw( @ISA @EXPORT_OK $DEBUG );
 use Exporter;
 use Tie::IxHash;
+use File::Slurp;
 use FS::UID qw( dbh driver_name );
 use FS::Conf;
 use FS::Record qw(qsearchs qsearch str2time_sql);
@@ -63,7 +64,12 @@ sub upgrade_config {
 
   upgrade_overlimit_groups($conf);
   map { upgrade_overlimit_groups($conf,$_->agentnum) } qsearch('agent', {});
-  
+
+  my $DIST_CONF = '/usr/local/etc/freeside/default_conf/';#DIST_CONF in Makefile
+  $conf->set($_, scalar(read_file( "$DIST_CONF/$_" )) )
+    foreach grep { ! $conf->exists($_) && -s "$DIST_CONF/$_" }
+      qw( quotation_html quotation_latex quotation_latexnotes );
+
 }
 
 sub upgrade_overlimit_groups {
diff --git a/FS/FS/access_right.pm b/FS/FS/access_right.pm
index 1e65ca3..e6266b4 100644
--- a/FS/FS/access_right.pm
+++ b/FS/FS/access_right.pm
@@ -192,6 +192,7 @@ sub _upgrade_data { # class method
     'Cancel customer package immediately' => 'Un-cancel customer package',
     'Suspend customer package'            => 'Suspend customer',
     'Unsuspend customer package'          => 'Unsuspend customer',
+    'New prospect'                        => 'Generate quotation',
 
     'List services'    => [ 'Services: Accounts',
                             'Services: Domains',
diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm
index 498025f..83748be 100644
--- a/FS/FS/cust_bill.pm
+++ b/FS/FS/cust_bill.pm
@@ -1,26 +1,20 @@
 package FS::cust_bill;
+use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::Record );
 
 use strict;
-use vars qw( @ISA $DEBUG $me 
-             $money_char $date_format $rdate_format $date_format_long );
+use vars qw( $DEBUG $me $date_format );
              # 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 sum);
+use List::Util qw(min max);
 use Date::Format;
-use Date::Language;
-use Text::Template 1.20;
 use File::Temp 0.14;
-use String::ShellQuote;
 use HTML::Entities;
-use Locale::Country;
 use Storable qw( freeze thaw );
 use GD::Barcode;
 use FS::UID qw( datasrc );
-use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print );
+use FS::Misc qw( send_email send_fax do_print );
 use FS::Record qw( qsearch qsearchs dbh );
-use FS::cust_main_Mixin;
 use FS::cust_main;
 use FS::cust_statement;
 use FS::cust_bill_pkg;
@@ -46,18 +40,13 @@ use FS::cust_credit_bill_pkg;
 use FS::discount_plan;
 use FS::L10N;
 
- at ISA = qw( FS::cust_main_Mixin FS::Record );
-
 $DEBUG = 0;
 $me = '[FS::cust_bill]';
 
 #ask FS::UID to run this stuff for us later
 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';
 } );
 
 =head1 NAME
@@ -161,6 +150,7 @@ Invoices are normally created by calling the bill method of a customer object
 =cut
 
 sub table { 'cust_bill'; }
+sub notice_name { 'Invoice'; }
 
 sub cust_linked { $_[0]->cust_main_custnum; } 
 sub cust_unlinked_msg {
@@ -2325,143 +2315,6 @@ sub _agent_invoice_from {
   $self->cust_main->agent_invoice_from;
 }
 
-=item print_text HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
-
-Returns an text invoice, as a list of lines.
-
-Options can be passed as a hashref (recommended) or as a list of time, template
-and then any key/value pairs for any other options.
-
-I<time>, if specified, is 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.
-
-I<template>, if specified, is the name of a suffix for alternate invoices.
-
-I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
-
-=cut
-
-sub print_text {
-  my $self = shift;
-  my( $today, $template, %opt );
-  if ( ref($_[0]) ) {
-    %opt = %{ shift() };
-    $today = delete($opt{'time'}) || '';
-    $template = delete($opt{template}) || '';
-  } else {
-    ( $today, $template, %opt ) = @_;
-  }
-
-  my %params = ( 'format' => 'template' );
-  $params{'time'} = $today if $today;
-  $params{'template'} = $template if $template;
-  $params{$_} = $opt{$_} 
-    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
-
-  $self->print_generic( %params );
-}
-
-=item print_latex HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
-
-Internal method - returns a filename of a filled-in LaTeX template for this
-invoice (Note: add ".tex" to get the actual filename), and a filename of
-an associated logo (with the .eps extension included).
-
-See print_ps and print_pdf for methods that return PostScript and PDF output.
-
-Options can be passed as a hashref (recommended) or as a list of time, template
-and then any key/value pairs for any other options.
-
-I<time>, if specified, is 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.
-
-I<template>, if specified, is the name of a suffix for alternate invoices.
-
-I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
-
-=cut
-
-sub print_latex {
-  my $self = shift;
-  my $conf = $self->conf;
-  my( $today, $template, %opt );
-  if ( ref($_[0]) ) {
-    %opt = %{ shift() };
-    $today = delete($opt{'time'}) || '';
-    $template = delete($opt{template}) || '';
-  } else {
-    ( $today, $template, %opt ) = @_;
-  }
-
-  my %params = ( 'format' => 'latex' );
-  $params{'time'} = $today if $today;
-  $params{'template'} = $template if $template;
-  $params{$_} = $opt{$_} 
-    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
-
-  $template ||= $self->_agent_template;
-
-  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
-  my $lh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
-                           DIR      => $dir,
-                           SUFFIX   => '.eps',
-                           UNLINK   => 0,
-                         ) or die "can't open temp file: $!\n";
-
-  my $agentnum = $self->cust_main->agentnum;
-
-  if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
-    print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
-      or die "can't write temp file: $!\n";
-  } else {
-    print $lh $conf->config_binary('logo.eps', $agentnum)
-      or die "can't write temp file: $!\n";
-  }
-  close $lh;
-  $params{'logo_file'} = $lh->filename;
-
-  if($conf->exists('invoice-barcode')){
-      my $png_file = $self->invoice_barcode($dir);
-      my $eps_file = $png_file;
-      $eps_file =~ s/\.png$/.eps/g;
-      $png_file =~ /(barcode.*png)/;
-      $png_file = $1;
-      $eps_file =~ /(barcode.*eps)/;
-      $eps_file = $1;
-
-      my $curr_dir = cwd();
-      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)
-      system('sam2p', '-j:quiet', $png_file, 'EPS:', $eps_file ) == 0
-	or die "sam2p failed: $!\n";
-      unlink($png_file);
-      chdir($curr_dir);
-
-      $params{'barcode_file'} = $eps_file;
-  }
-
-  my @filled_in = $self->print_generic( %params );
-  
-  my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
-                           DIR      => $dir,
-                           SUFFIX   => '.tex',
-                           UNLINK   => 0,
-                         ) or die "can't open temp file: $!\n";
-  binmode($fh, ':utf8'); # language support
-  print $fh join('', @filled_in );
-  close $fh;
-
-  $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
-  return ($1, $params{'logo_file'}, $params{'barcode_file'});
-
-}
-
 =item invoice_barcode DIR_OR_FALSE
 
 Generates an invoice barcode PNG. If DIR_OR_FALSE is a true value,
@@ -2491,1951 +2344,164 @@ sub invoice_barcode {
     return $gd->png;
 }
 
-=item print_generic OPTION => VALUE ...
-
-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
-
-Optional options include
-
-template - a value used as a suffix for a configuration template
-
-time - a value 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 - 
-
-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)
+=item invnum_date_pretty
 
-locale - override customer's locale
+Returns a string with the invoice number and date, for example:
+"Invoice #54 (3/20/2008)"
 
 =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/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;
-
-  my $format = $params{format};
-  die "Unknown format: $format"
-    unless $format =~ /^(latex|html|template)$/;
-
-  my $cust_main = $self->cust_main;
-  $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
-    unless $cust_main->payname
-        && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
-
-  my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
-                     'html'     => [ '<%=', '%>' ],
-                     'template' => [ '{', '}' ],
-                   );
-
-  warn "$me print_generic creating template\n"
-    if $DEBUG > 1;
-
-  #create the template
-  my $template = $params{template} ? $params{template} : $self->_agent_template;
-  my $templatefile = "invoice_$format";
-  $templatefile .= "_$template"
-    if length($template) && $conf->exists($templatefile."_$template");
-  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; ".
-         "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;
-
-  my $text_template = new Text::Template(
-    TYPE => 'ARRAY',
-    SOURCE => \@invoice_template,
-    DELIMITERS => $delimiters{$format},
-  );
-
-  warn "$me print_generic compiling T:T object\n"
-    if $DEBUG > 1;
-
-  $text_template->compile()
-    or die "Can't compile $templatefile: $Text::Template::ERROR\n";
-
-
-  # additional substitution could possibly cause breakage in existing templates
-  my %convert_maps = ( 
-    'latex' => {
-                 'notes'         => sub { map "$_", @_ },
-                 'footer'        => sub { map "$_", @_ },
-                 'smallfooter'   => sub { map "$_", @_ },
-                 'returnaddress' => sub { map "$_", @_ },
-                 'coupon'        => sub { map "$_", @_ },
-                 'summary'       => sub { map "$_", @_ },
-               },
-    'html'  => {
-                 'notes' =>
-                   sub {
-                     map { 
-                       s/%%(.*)$/<!-- $1 -->/g;
-                       s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
-                       s/\\begin\{enumerate\}/<ol>/g;
-                       s/\\item /  <li>/g;
-                       s/\\end\{enumerate\}/<\/ol>/g;
-                       s/\\textbf\{(.*)\}/<b>$1<\/b>/g;
-                       s/\\\\\*/<br>/g;
-                       s/\\dollar ?/\$/g;
-                       s/\\#/#/g;
-                       s/~/ /g;
-                       $_;
-                     }  @_
-                   },
-                 'footer' =>
-                   sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
-                 'smallfooter' =>
-                   sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
-                 'returnaddress' =>
-                   sub {
-                     map { 
-                       s/~/ /g;
-                       s/\\\\\*?\s*$/<BR>/;
-                       s/\\hyphenation\{[\w\s\-]+}//;
-                       s/\\([&])/$1/g;
-                       $_;
-                     }  @_
-                   },
-                 'coupon'        => sub { "" },
-                 'summary'       => sub { "" },
-               },
-    'template' => {
-                 'notes' =>
-                   sub {
-                     map { 
-                       s/%%.*$//g;
-                       s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
-                       s/\\begin\{enumerate\}//g;
-                       s/\\item /  * /g;
-                       s/\\end\{enumerate\}//g;
-                       s/\\textbf\{(.*)\}/$1/g;
-                       s/\\\\\*/ /;
-                       s/\\dollar ?/\$/g;
-                       $_;
-                     }  @_
-                   },
-                 'footer' =>
-                   sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
-                 'smallfooter' =>
-                   sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
-                 'returnaddress' =>
-                   sub {
-                     map { 
-                       s/~/ /g;
-                       s/\\\\\*?\s*$/\n/;             # dubious
-                       s/\\hyphenation\{[\w\s\-]+}//;
-                       $_;
-                     }  @_
-                   },
-                 'coupon'        => sub { "" },
-                 'summary'       => sub { "" },
-               },
-  );
-
-
-  # hashes for differing output formats
-  my %nbsps = ( 'latex'    => '~',
-                'html'     => '',    # '&nbps;' would be nice
-                'template' => '',    # not used
-              );
-  my $nbsp = $nbsps{$format};
-
-  my %escape_functions = ( 'latex'    => \&_latex_escape,
-                           'html'     => \&_html_escape_nbsp,#\&encode_entities,
-                           'template' => sub { shift },
-                         );
-  my $escape_function = $escape_functions{$format};
-  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/ / /g;
-
-  my $date_format = $date_formats{$format};
-
-  my %embolden_functions = ( 'latex'    => sub { return '\textbf{'. shift(). '}'
-                                               },
-                             'html'     => sub { return '<b>'. shift(). '</b>'
-                                               },
-                             'template' => sub { shift },
-                           );
-  my $embolden_function = $embolden_functions{$format};
-
-  my %newline_tokens = (  'latex'     => '\\\\',
-                          'html'      => '<br>',
-                          'template'  => "\n",
-                        );
-  my $newline_token = $newline_tokens{$format};
-
-  warn "$me generating template variables\n"
-    if $DEBUG > 1;
-
-  # generate template variables
-  my $returnaddress;
-  if (
-         defined( $conf->config_orbase( "invoice_${format}returnaddress",
-                                        $template
-                                      )
-                )
-       && length( $conf->config_orbase( "invoice_${format}returnaddress",
-                                        $template
-                                      )
-                )
-  ) {
-
-    $returnaddress = join("\n",
-      $conf->config_orbase("invoice_${format}returnaddress", $template)
-    );
-
-  } elsif ( grep /\S/,
-            $conf->config_orbase('invoice_latexreturnaddress', $template) ) {
-
-    my $convert_map = $convert_maps{$format}{'returnaddress'};
-    $returnaddress =
-      join( "\n",
-            &$convert_map( $conf->config_orbase( "invoice_latexreturnaddress",
-                                                 $template
-                                               )
-                         )
-          );
-  } elsif ( grep /\S/, $conf->config('company_address', $self->cust_main->agentnum) ) {
-
-    my $convert_map = $convert_maps{$format}{'returnaddress'};
-    $returnaddress = join( "\n", &$convert_map(
-                                   map { s/( {2,})/'~' x length($1)/eg;
-                                         s/$/\\\\\*/;
-                                         $_
-                                       }
-                                     ( $conf->config('company_name', $self->cust_main->agentnum),
-                                       $conf->config('company_address', $self->cust_main->agentnum),
-                                     )
-                                 )
-                     );
+sub invnum_date_pretty {
+  my $self = shift;
+  $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')';
+}
 
-  } else {
+#sub _items_extra_usage_sections {
+#  my $self = shift;
+#  my $escape = shift;
+#
+#  my %sections = ();
+#
+#  my %usage_class =  map{ $_->classname, $_ } qsearch('usage_class', {});
+#  foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
+#  {
+#    next unless $cust_bill_pkg->pkgnum > 0;
+#
+#    foreach my $section ( keys %usage_class ) {
+#
+#      my $usage = $cust_bill_pkg->usage($section);
+#
+#      next unless $usage && $usage > 0;
+#
+#      $sections{$section} ||= 0;
+#      $sections{$section} += $usage;
+#
+#    }
+#
+#  }
+#
+#  map { { 'description' => &{$escape}($_),
+#          'subtotal'    => $sections{$_},
+#          'summarized'  => '',
+#          'tax_section' => '',
+#        }
+#      }
+#    sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
+#
+#}
 
-    my $warning = "Couldn't find a return address; ".
-                  "do you need to set the company_address configuration value?";
-    warn "$warning\n";
-    $returnaddress = $nbsp;
-    #$returnaddress = $warning;
+sub _items_extra_usage_sections {
+  my $self = shift;
+  my $conf = $self->conf;
+  my $escape = shift;
+  my $format = shift;
 
-  }
+  my %sections = ();
+  my %classnums = ();
+  my %lines = ();
 
-  warn "$me generating invoice data\n"
-    if $DEBUG > 1;
-
-  my $agentnum = $self->cust_main->agentnum;
-
-  my %invoice_data = (
-
-    #invoice from info
-    'company_name'    => scalar( $conf->config('company_name', $agentnum) ),
-    'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n",
-    'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ),
-    'returnaddress'   => $returnaddress,
-    'agent'           => &$escape_function($cust_main->agent->agent),
-
-    #invoice info
-    'invnum'          => $self->invnum,
-    'date'            => time2str($date_format, $self->_date),
-    'today'           => time2str($date_format_long, $today),
-    'terms'           => $self->terms,
-    'template'        => $template, #params{'template'},
-    'notice_name'     => ($params{'notice_name'} || 'Invoice'),#escape_function?
-    'current_charges' => sprintf("%.2f", $self->charged),
-    'duedate'         => $self->due_date2str($rdate_format), #date_format?
-
-    #customer info
-    'custnum'         => $cust_main->display_custnum,
-    'agent_custid'    => &$escape_function($cust_main->agent_custid),
-    ( map { $_ => &$escape_function($cust_main->$_()) } qw(
-      payname company address1 address2 city state zip fax
-    )),
-
-    #global config
-    'ship_enable'     => $conf->exists('invoice-ship_address'),
-    'unitprices'      => $conf->exists('invoice-unitprice'),
-    '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)),
-    'headsep'               => scalar($conf->config('invoice_latexheadsep', $agentnum)),
-    'textheight'            => scalar($conf->config('invoice_latextextheight', $agentnum)),
-    'extracouponspace'      => scalar($conf->config('invoice_latexextracouponspace', $agentnum)),
-    'couponfootsep'         => scalar($conf->config('invoice_latexcouponfootsep', $agentnum)),
-    'verticalreturnaddress' => $conf->exists('invoice_latexverticalreturnaddress', $agentnum),
-    'addresssep'            => scalar($conf->config('invoice_latexaddresssep', $agentnum)),
-    'amountenclosedsep'     => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)),
-    'coupontoaddresssep'    => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)),
-    'addcompanytoaddress'   => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum),
-
-    # better hang on to conf_dir for a while (for old templates)
-    'conf_dir'        => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
-
-    #these are only used when doing paged plaintext
-    'page'            => 1,
-    'total_pages'     => 1,
+  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
 
-  );
- 
-  #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;
+  my %usage_class =  map { $_->classnum => $_ } qsearch( 'usage_class', {} );
   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
     next unless $cust_bill_pkg->pkgnum > 0;
-    $min_sdate = $cust_bill_pkg->sdate
-      if length($cust_bill_pkg->sdate) && $cust_bill_pkg->sdate < $min_sdate;
-    $max_edate = $cust_bill_pkg->edate
-      if length($cust_bill_pkg->edate) && $cust_bill_pkg->edate > $max_edate;
-  }
-
-  $invoice_data{'bill_period'} = '';
-  $invoice_data{'bill_period'} = time2str('%e %h', $min_sdate) 
-    . " to " . time2str('%e %h', $max_edate)
-    if ($max_edate != 0 && $min_sdate != 999999999999);
-
-  $invoice_data{finance_section} = '';
-  if ( $conf->config('finance_pkgclass') ) {
-    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
-
-  my $countrydefault = $conf->config('countrydefault') || 'US';
-  foreach ( qw( address1 address2 city state zip country fax) ){
-    my $method = 'ship_'.$_;
-    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
-  }
-  foreach ( qw( contact company ) ) { #compatibility
-    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$_);
-  }
-  $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'} = '';
-  } else {
-    $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
-  }
-
-  my @address = ();
-  $invoice_data{'address'} = \@address;
-  push @address,
-    $cust_main->payname.
-      ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
-        ? " (P.O. #". $cust_main->payinfo. ")"
-        : ''
-      )
-  ;
-  push @address, $cust_main->company
-    if $cust_main->company;
-  push @address, $cust_main->address1;
-  push @address, $cust_main->address2
-    if $cust_main->address2;
-  push @address,
-    $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
-  push @address, $invoice_data{'country'}
-    if $invoice_data{'country'};
-  push @address, ''
-    while (scalar(@address) < 5);
-
-  $invoice_data{'logo_file'} = $params{'logo_file'}
-    if $params{'logo_file'};
-  $invoice_data{'barcode_file'} = $params{'barcode_file'}
-    if $params{'barcode_file'};
-  $invoice_data{'barcode_img'} = $params{'barcode_img'}
-    if $params{'barcode_img'};
-  $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 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;
-  }
-  $invoice_data{'summarypage'} = $summarypage;
 
-  warn "$me substituting variables in notes, footer, smallfooter\n"
-    if $DEBUG > 1;
-
-  my @include = (qw( notes footer smallfooter ));
-  push @include, 'coupon' unless $params{'no_coupon'};
-  foreach my $include (@include) {
-
-    my $inc_file = $conf->key_orbase("invoice_${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("invoice_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) );
-
-    }
+    foreach my $classnum ( keys %usage_class ) {
+      my $section = $usage_class{$classnum}->classname;
+      $classnums{$section} = $classnum;
 
-    my $inc_tt = new Text::Template (
-      TYPE       => 'ARRAY',
-      SOURCE     => [ map "$_\n", @inc_src ],
-      DELIMITERS => $delimiters{$format},
-    ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
+      foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
+        my $amount = $detail->amount;
+        next unless $amount && $amount > 0;
+ 
+        $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
+        $sections{$section}{amount} += $amount;  #subtotal
+        $sections{$section}{calls}++;
+        $sections{$section}{duration} += $detail->duration;
 
-    unless ( $inc_tt->compile() ) {
-      my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
-      warn $error. "Template:\n". join('', map "$_\n", @inc_src);
-      die $error;
-    }
+        my $desc = $detail->regionname; 
+        my $description = $desc;
+        $description = substr($desc, 0, $maxlength). '...'
+          if $format eq 'latex' && length($desc) > $maxlength;
 
-    $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
+        $lines{$section}{$desc} ||= {
+          description     => &{$escape}($description),
+          #pkgpart         => $part_pkg->pkgpart,
+          pkgnum          => $cust_bill_pkg->pkgnum,
+          ref             => '',
+          amount          => 0,
+          calls           => 0,
+          duration        => 0,
+          #unit_amount     => $cust_bill_pkg->unitrecur,
+          quantity        => $cust_bill_pkg->quantity,
+          product_code    => 'N/A',
+          ext_description => [],
+        };
 
-    $invoice_data{$include} =~ s/\n+$//
-      if ($format eq 'latex');
-  }
+        $lines{$section}{$desc}{amount} += $amount;
+        $lines{$section}{$desc}{calls}++;
+        $lines{$section}{$desc}{duration} += $detail->duration;
 
-  # 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') || '$',
-                      'template' => '',
-                    );
-  my $money_char = $money_chars{$format};
-
-  my %other_money_chars = ( 'latex'    => '\dollar ',#XXX should be a config too
-                            'html'     => $conf->config('money_char') || '$',
-                            'template' => '',
-                          );
-  my $other_money_char = $other_money_chars{$format};
-  $invoice_data{'dollar'} = $other_money_char;
-
-  my @detail_items = ();
-  my @total_items = ();
-  my @buf = ();
-  my @sections = ();
-
-  $invoice_data{'detail_items'} = \@detail_items;
-  $invoice_data{'total_items'} = \@total_items;
-  $invoice_data{'buf'} = \@buf;
-  $invoice_data{'sections'} = \@sections;
-
-  warn "$me generating sections\n"
-    if $DEBUG > 1;
-
-  my $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');
-
-  my $taxtotal = 0;
-  my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
-                      'subtotal'    => $taxtotal,   # adjusted below
-                    };
-  my $tax_weight = _pkg_category($tax_section->{description})
-                        ? _pkg_category($tax_section->{description})->weight
-                        : 0;
-  $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
-  $tax_section->{'sort_weight'} = $tax_weight;
-
-
-  my $adjusttotal = 0;
-  my $adjust_section = { 'description' => 
-    $self->mt('Credits, Payments, and Adjustments'),
-                         'subtotal'    => 0,   # adjusted below
-                       };
-  my $adjust_weight = _pkg_category($adjust_section->{description})
-                        ? _pkg_category($adjust_section->{description})->weight
-                        : 0;
-  $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';
-  my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum);
-  $invoice_data{'multisection'} = $multisection;
-  my $late_sections = [];
-  my $extra_sections = [];
-  my $extra_lines = ();
-
-  my $default_section = { 'description' => '',
-                          'subtotal'    => '', 
-                          'no_subtotal' => 1,
-                        };
-
-  if ( $multisection ) {
-    ($extra_sections, $extra_lines) =
-      $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
-      if $conf->exists('usage_class_as_a_section', $cust_main->agentnum);
-
-    push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
-
-    push @detail_items, @$extra_lines if $extra_lines;
-    push @sections,
-      $self->_items_sections( $late_sections,      # this could stand a refactor
-                              $summarypage,
-                              $escape_function_nonbsp,
-                              $extra_sections,
-                              $format,             #bah
-                            );
-    if ($conf->exists('svc_phone_sections')) {
-      my ($phone_sections, $phone_lines) =
-        $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
-      push @{$late_sections}, @$phone_sections;
-      push @detail_items, @$phone_lines;
-    }
-    if ($conf->exists('voip-cust_accountcode_cdr') && $cust_main->accountcode_cdr) {
-      my ($accountcode_section, $accountcode_lines) =
-        $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
-      if ( scalar(@$accountcode_lines) ) {
-          push @{$late_sections}, $accountcode_section;
-          push @detail_items, @$accountcode_lines;
-      }
-    }
-  } else {# not multisection
-    # make a default section
-    push @sections, $default_section;
-    # 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', $agentnum)
-           || $conf->exists('previous_balance-summary_only')
-         )
-  {
-
-    warn "$me adding previous balances\n"
-      if $DEBUG > 1;
+  my %sectionmap = ();
+  foreach (keys %sections) {
+    my $usage_class = $usage_class{$classnums{$_}};
+    $sectionmap{$_} = { 'description' => &{$escape}($_),
+                        'amount'    => $sections{$_}{amount},    #subtotal
+                        'calls'       => $sections{$_}{calls},
+                        'duration'    => $sections{$_}{duration},
+                        'summarized'  => '',
+                        'tax_section' => '',
+                        'sort_weight' => $usage_class->weight,
+                        ( $usage_class->format
+                          ? ( map { $_ => $usage_class->$_($format) }
+                              qw( description_generator header_generator total_generator total_line_generator )
+                            )
+                          : ()
+                        ), 
+                      };
+  }
 
-    foreach my $line_item ( $self->_items_previous ) {
+  my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} }
+                 values %sectionmap;
 
-      my $detail = {
-        ext_description => [],
-      };
-      $detail->{'ref'} = $line_item->{'pkgnum'};
-      $detail->{'quantity'} = 1;
-      $detail->{'section'} = $multisection ? $previous_section
-                                           : $default_section;
-      $detail->{'description'} = &$escape_function($line_item->{'description'});
-      if ( exists $line_item->{'ext_description'} ) {
-        @{$detail->{'ext_description'}} = map {
-          &$escape_function($_);
-        } @{$line_item->{'ext_description'}};
-      }
-      $detail->{'amount'} = ( $old_latex ? '' : $money_char).
-                            $line_item->{'amount'};
-      $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
-
-      push @detail_items, $detail;
-      push @buf, [ $detail->{'description'},
-                   $money_char. sprintf("%10.2f", $line_item->{'amount'}),
-                 ];
+  my @lines = ();
+  foreach my $section ( keys %lines ) {
+    foreach my $line ( keys %{$lines{$section}} ) {
+      my $l = $lines{$section}{$line};
+      $l->{section}     = $sectionmap{$section};
+      $l->{amount}      = sprintf( "%.2f", $l->{amount} );
+      #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
+      push @lines, $l;
     }
-
-  }
-  
-  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance', $agentnum) ) 
-    {
-    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') ) {
-      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, 
-       { 'description' => $didsummary_desc,
-           'ext_description' => [ $didsummary, $minutes ],
-       };
-  }
-
-  foreach my $section (@sections, @$late_sections) {
-
-    warn "$me adding section \n". Dumper($section)
-      if $DEBUG > 1;
-
-    # begin some normalization
-    $section->{'subtotal'} = $section->{'amount'}
-      if $multisection
-         && !exists($section->{subtotal})
-         && exists($section->{amount});
-
-    $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
-      if ( $invoice_data{finance_section} &&
-           $section->{'description'} eq $invoice_data{finance_section} );
-
-    $section->{'subtotal'} = $other_money_char.
-                             sprintf('%.2f', $section->{'subtotal'})
-      if $multisection;
-
-    # continue some normalization
-    $section->{'amount'}   = $section->{'subtotal'}
-      if $multisection;
 
+  return(\@sections, \@lines);
 
-    if ( $section->{'description'} ) {
-      push @buf, ( [ &$escape_function($section->{'description'}), '' ],
-                   [ '', '' ],
-                 );
-    }
+}
 
-    warn "$me   setting options\n"
-      if $DEBUG > 1;
+sub _did_summary {
+    my $self = shift;
+    my $end = $self->_date;
 
-    my $multilocation = scalar($cust_main->cust_location); #too expensive?
-    my %options = ();
-    $options{'section'} = $section if $multisection;
-    $options{'format'} = $format;
-    $options{'escape_function'} = $escape_function;
-    $options{'no_usage'} = 1 unless $unsquelched;
-    $options{'unsquelched'} = $unsquelched;
-    $options{'summary_page'} = $summarypage;
-    $options{'skip_usage'} =
-      scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
-    $options{'multilocation'} = $multilocation;
-    $options{'multisection'} = $multisection;
+    # start at date of previous invoice + 1 second or 0 if no previous invoice
+    my $start = $self->scalar_sql("SELECT max(_date) FROM cust_bill WHERE custnum = ? and invnum != ?",$self->custnum,$self->invnum);
+    $start = 0 if !$start;
+    $start++;
 
-    warn "$me   searching for line items\n"
-      if $DEBUG > 1;
-
-    foreach my $line_item ( $self->_items_pkg(%options) ) {
-
-      warn "$me     adding line item $line_item\n"
-        if $DEBUG > 1;
-
-      my $detail = {
-        ext_description => [],
-      };
-      $detail->{'ref'} = $line_item->{'pkgnum'};
-      $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'};
-      $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'},
-                     $money_char. sprintf("%10.2f", $line_item->{'amount'}),
-                   ],
-                   map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
-                 );
-    }
-
-    if ( $section->{'description'} ) {
-      push @buf, ( ['','-----------'],
-                   [ $section->{'description'}. ' sub-total',
-                      $section->{'subtotal'} # already formatted this 
-                   ],
-                   [ '', '' ],
-                   [ '', '' ],
-                 );
-    }
-  
-  }
-
-  $invoice_data{current_less_finance} =
-    sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
-
-  if ( $multisection && !$conf->exists('disable_previous_balance', $agentnum)
-    || $conf->exists('previous_balance-summary_only') )
-  {
-    unshift @sections, $previous_section if $pr_total;
-  }
-
-  warn "$me adding taxes\n"
-    if $DEBUG > 1;
-
-  foreach my $tax ( $self->_items_tax ) {
-
-    $taxtotal += $tax->{'amount'};
-
-    my $description = &$escape_function( $tax->{'description'} );
-    my $amount      = sprintf( '%.2f', $tax->{'amount'} );
-
-    if ( $multisection ) {
-
-      my $money = $old_latex ? '' : $money_char;
-      push @detail_items, {
-        ext_description => [],
-        ref          => '',
-        quantity     => '',
-        description  => $description,
-        amount       => $money. $amount,
-        product_code => '',
-        section      => $tax_section,
-      };
-
-    } else {
-
-      push @total_items, {
-        'total_item'   => $description,
-        'total_amount' => $other_money_char. $amount,
-      };
-
-    }
-
-    push @buf,[ $description,
-                $money_char. $amount,
-              ];
-
-  }
-  
-  if ( $taxtotal ) {
-    my $total = {};
-    $total->{'total_item'} = $self->mt('Sub-total');
-    $total->{'total_amount'} =
-      $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
-
-    if ( $multisection ) {
-      $tax_section->{'subtotal'} = $other_money_char.
-                                   sprintf('%.2f', $taxtotal);
-      $tax_section->{'pretotal'} = 'New charges sub-total '.
-                                   $total->{'total_amount'};
-      push @sections, $tax_section if $taxtotal;
-    }else{
-      unshift @total_items, $total;
-    }
-  }
-  $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
-
-  push @buf,['','-----------'];
-  push @buf,[$self->mt( 
-              $conf->exists('disable_previous_balance', $agentnum) 
-               ? 'Total Charges'
-               : 'Total New Charges'
-             ),
-             $money_char. sprintf("%10.2f",$self->charged) ];
-  push @buf,['',''];
-
-  {
-    my $total = {};
-    my $item = 'Total';
-    $item = $conf->config('previous_balance-exclude_from_total')
-         || 'Total New Charges'
-      if $conf->exists('previous_balance-exclude_from_total');
-    my $amount = $self->charged +
-                   ( $conf->exists('disable_previous_balance', $agentnum) ||
-                     $conf->exists('previous_balance-exclude_from_total')
-                     ? 0
-                     : $pr_total
-                   );
-    $total->{'total_item'} = &$embolden_function($self->mt($item));
-    $total->{'total_amount'} =
-      &$embolden_function( $other_money_char.  sprintf( '%.2f', $amount ) );
-    if ( $multisection ) {
-      if ( $adjust_section->{'sort_weight'} ) {
-        $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
-          $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
-      } else {
-        $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
-          $other_money_char.  sprintf('%.2f', $self->charged );
-      } 
-    }else{
-      push @total_items, $total;
-    }
-    push @buf,['','-----------'];
-    push @buf,[$item,
-               $money_char.
-               sprintf( '%10.2f', $amount )
-              ];
-    push @buf,['',''];
-  }
-  
-  unless ( $conf->exists('disable_previous_balance', $agentnum) ) {
-    #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
-  
-    # credits
-    my $credittotal = 0;
-    foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
-
-      my $total;
-      $total->{'total_item'} = &$escape_function($credit->{'description'});
-      $credittotal += $credit->{'amount'};
-      $total->{'total_amount'} = '-'. $other_money_char. $credit->{'amount'};
-      $adjusttotal += $credit->{'amount'};
-      if ( $multisection ) {
-        my $money = $old_latex ? '' : $money_char;
-        push @detail_items, {
-          ext_description => [],
-          ref          => '',
-          quantity     => '',
-          description  => &$escape_function($credit->{'description'}),
-          amount       => $money. $credit->{'amount'},
-          product_code => '',
-          section      => $adjust_section,
-        };
-      } else {
-        push @total_items, $total;
-      }
-
-    }
-    $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
-
-    #credits (again)
-    foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
-      push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
-    }
-
-    # payments
-    my $paymenttotal = 0;
-    foreach my $payment ( $self->_items_payments ) {
-      my $total = {};
-      $total->{'total_item'} = &$escape_function($payment->{'description'});
-      $paymenttotal += $payment->{'amount'};
-      $total->{'total_amount'} = '-'. $other_money_char. $payment->{'amount'};
-      $adjusttotal += $payment->{'amount'};
-      if ( $multisection ) {
-        my $money = $old_latex ? '' : $money_char;
-        push @detail_items, {
-          ext_description => [],
-          ref          => '',
-          quantity     => '',
-          description  => &$escape_function($payment->{'description'}),
-          amount       => $money. $payment->{'amount'},
-          product_code => '',
-          section      => $adjust_section,
-        };
-      }else{
-        push @total_items, $total;
-      }
-      push @buf, [ $payment->{'description'},
-                   $money_char. sprintf("%10.2f", $payment->{'amount'}),
-                 ];
-    }
-    $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
-  
-    if ( $multisection ) {
-      $adjust_section->{'subtotal'} = $other_money_char.
-                                      sprintf('%.2f', $adjusttotal);
-      push @sections, $adjust_section
-        unless $adjust_section->{sort_weight};
-    }
-
-    # create Balance Due message
-    { 
-      my $total;
-      $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
-      $total->{'total_amount'} =
-        &$embolden_function(
-          $other_money_char. sprintf('%.2f', $summarypage 
-                                               ? $self->charged +
-                                                 $self->billing_balance
-                                               : $self->owed + $pr_total
-                                    )
-        );
-      if ( $multisection && !$adjust_section->{sort_weight} ) {
-        $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
-                                         $total->{'total_amount'};
-      }else{
-        push @total_items, $total;
-      }
-      push @buf,['','-----------'];
-      push @buf,[$self->balance_due_msg, $money_char. 
-        sprintf("%10.2f", $balance_due ) ];
-    }
-
-    if ( $conf->exists('previous_balance-show_credit')
-        and $cust_main->balance < 0 ) {
-      my $credit_total = {
-        'total_item'    => &$embolden_function($self->credit_balance_msg),
-        'total_amount'  => &$embolden_function(
-          $other_money_char. sprintf('%.2f', -$cust_main->balance)
-        ),
-      };
-      if ( $multisection ) {
-        $adjust_section->{'posttotal'} .= $newline_token .
-          $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
-      }
-      else {
-        push @total_items, $credit_total;
-      }
-      push @buf,['','-----------'];
-      push @buf,[$self->credit_balance_msg, $money_char. 
-        sprintf("%10.2f", -$cust_main->balance ) ];
-    }
-  }
-
-  if ( $multisection ) {
-    if ($conf->exists('svc_phone_sections')) {
-      my $total;
-      $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
-      $total->{'total_amount'} =
-        &$embolden_function(
-          $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
-        );
-      my $last_section = pop @sections;
-      $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
-                                     $total->{'total_amount'};
-      push @sections, $last_section;
-    }
-    push @sections, @$late_sections
-      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 ) {
-
-    my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
-    my @inc_src;
-
-    if ( length( $conf->config($inc_file, $agentnum) ) ) {
-
-      @inc_src = $conf->config($inc_file, $agentnum);
-
-    } else {
-
-      $inc_file = $conf->key_orbase("invoice_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_tt = new Text::Template (
-      TYPE       => 'ARRAY',
-      SOURCE     => [ map "$_\n", @inc_src ],
-      DELIMITERS => $delimiters{$format},
-    ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
-
-    unless ( $inc_tt->compile() ) {
-      my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
-      warn $error. "Template:\n". join('', map "$_\n", @inc_src);
-      die $error;
-    }
-
-    $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
-
-    $invoice_data{$include} =~ s/\n+$//
-      if ($format eq 'latex');
-  }
-
-  $invoice_lines = 0;
-  my $wasfunc = 0;
-  foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
-    /invoice_lines\((\d*)\)/;
-    $invoice_lines += $1 || scalar(@buf);
-    $wasfunc=1;
-  }
-  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;
-    }
-
-    #setup subroutine for the template
-    $invoice_data{invoice_lines} = sub {
-      my $lines = shift || scalar(@buf);
-      map { 
-        scalar(@buf)
-          ? shift @buf
-          : [ '', '' ];
-      }
-      ( 1 .. $lines );
-    };
-
-    my $lines;
-    my @collect;
-    while (@buf) {
-      push @collect, split("\n",
-        $text_template->fill_in( HASH => \%invoice_data )
-      );
-      $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"
-      if $DEBUG > 1;
-
-    $text_template->fill_in(HASH => \%invoice_data);
-  }
-}
-
-# 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;
-}
-
-=item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
-
-Returns an postscript invoice, as a scalar.
-
-Options can be passed as a hashref (recommended) or as a list of time, template
-and then any key/value pairs for any other options.
-
-I<time> an optional value 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.
-
-I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
-
-=cut
-
-sub print_ps {
-  my $self = shift;
-
-  my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
-  my $ps = generate_ps($file);
-  unlink($logofile);
-  unlink($barcodefile) if $barcodefile;
-
-  $ps;
-}
-
-=item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
-
-Returns an PDF invoice, as a scalar.
-
-Options can be passed as a hashref (recommended) or as a list of time, template
-and then any key/value pairs for any other options.
-
-I<time> an optional value 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.
-
-I<template>, if specified, is the name of a suffix for alternate invoices.
-
-I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
-
-=cut
-
-sub print_pdf {
-  my $self = shift;
-
-  my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
-  my $pdf = generate_pdf($file);
-  unlink($logofile);
-  unlink($barcodefile) if $barcodefile;
-
-  $pdf;
-}
-
-=item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
-
-Returns an HTML invoice, as a scalar.
-
-I<time> an optional value 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.
-
-I<template>, if specified, is the name of a suffix for alternate invoices.
-
-I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
-
-I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
-when emailing the invoice as part of a multipart/related MIME email.
-
-=cut
-
-sub print_html {
-  my $self = shift;
-  my %params;
-  if ( ref($_[0]) ) {
-    %params = %{ shift() }; 
-  }else{
-    $params{'time'} = shift;
-    $params{'template'} = shift;
-    $params{'cid'} = shift;
-  }
-
-  $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: 
-#      # $ % & ~ _ ^ \ { }
-#
-# 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 ..."). 
-
-sub _latex_escape {
-  my $value = shift;
-  $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
-  $value =~ s/([<>])/\$$1\$/g;
-  $value;
-}
-
-sub _html_escape {
-  my $value = shift;
-  encode_entities($value);
-  $value;
-}
-
-sub _html_escape_nbsp {
-  my $value = _html_escape(shift);
-  $value =~ s/ +/ /g;
-  $value;
-}
-
-#utility methods for print_*
-
-sub _translate_old_latex_format {
-  warn "_translate_old_latex_format called\n"
-    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!        "\\tabularnewline\n~~".!,
-                      q!        join( "\\tabularnewline\n~~",!,
-                      q!          @{$_tr_line->{'ext_description'}}!,
-                      q!        );!,
-                      q!    }!;
-
-      while ( ( my $line_item_line = shift )
-              !~ /^%%EndDetail\s*$/                            ) {
-        $line_item_line =~ s/'/\\'/g;    # nice LTS
-        $line_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
-        $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
-        push @template, "    \$OUT .= '$line_item_line';";
-      }
-
-      push @template, '}',
-                      '--@]';
-      #' doh, gvim
-    } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
-
-      push @template, '[@--',
-                      '  foreach my $_tr_line (@total_items) {';
-
-      while ( ( my $total_item_line = shift )
-              !~ /^%%EndTotalDetails\s*$/                      ) {
-        $total_item_line =~ s/'/\\'/g;    # nice LTS
-        $total_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
-        $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
-        push @template, "    \$OUT .= '$total_item_line';";
-      }
-
-      push @template, '}',
-                      '--@]';
-
-    } else {
-      $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
-      push @template, $line;  
-    }
-  
-  }
-
-  if ($DEBUG) {
-    warn "$_\n" foreach @template;
-  }
-
-  (@template);
-}
-
-sub terms {
-  my $self = shift;
-  my $conf = $self->conf;
-
-  #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->invoice_terms;
-
-  #use configured default
-  $conf->config('invoice_default_terms') || '';
-}
-
-sub due_date {
-  my $self = shift;
-  my $duedate = '';
-  if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
-    $duedate = $self->_date() + ( $1 * 86400 );
-  }
-  $duedate;
-}
-
-sub due_date2str {
-  my $self = shift;
-  $self->due_date ? time2str(shift, $self->due_date) : '';
-}
-
-sub balance_due_msg {
-  my $self = shift;
-  my $msg = $self->mt('Balance Due');
-  return $msg unless $self->terms;
-  if ( $self->due_date ) {
-    $msg .= ' - ' . $self->mt('Please pay by'). ' '.
-      $self->due_date2str($date_format);
-  } elsif ( $self->terms ) {
-    $msg .= ' - '. $self->terms;
-  }
-  $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*$/ ) {
-    $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
-  }
-  $duedate;
-}
-
-sub credit_balance_msg { 
-  my $self = shift;
-  $self->mt('Credit Balance Remaining')
-}
-
-=item invnum_date_pretty
-
-Returns a string with the invoice number and date, for example:
-"Invoice #54 (3/20/2008)"
-
-=cut
-
-sub invnum_date_pretty {
-  my $self = shift;
-  $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')';
-}
-
-=item _date_pretty
-
-Returns a string with the date, for example: "3/20/2008"
-
-=cut
-
-sub _date_pretty {
-  my $self = shift;
-  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;
-  my $late = shift;
-  my $summarypage = shift;
-  my $escape = shift;
-  my $extra_sections = shift;
-  my $format = shift;
-
-  my %subtotal = ();
-  my %late_subtotal = ();
-  my %not_tax = ();
-
-  foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
-  {
-
-      my $usage = $cust_bill_pkg->usage;
-
-      foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
-        next if ( $display->summary && $summarypage );
-
-        my $section = $display->section;
-        my $type    = $display->type;
-
-        $not_tax{$section} = 1
-          unless $cust_bill_pkg->pkgnum == 0;
-
-        if ( $display->post_total && !$summarypage ) {
-          if (! $type || $type eq 'S') {
-            $late_subtotal{$section} += $cust_bill_pkg->setup
-              if $cust_bill_pkg->setup != 0
-              || $cust_bill_pkg->setup_show_zero;
-          }
-
-          if (! $type) {
-            $late_subtotal{$section} += $cust_bill_pkg->recur
-              if $cust_bill_pkg->recur != 0
-              || $cust_bill_pkg->recur_show_zero;
-          }
-
-          if ($type && $type eq 'R') {
-            $late_subtotal{$section} += $cust_bill_pkg->recur - $usage
-              if $cust_bill_pkg->recur != 0
-              || $cust_bill_pkg->recur_show_zero;
-          }
-          
-          if ($type && $type eq 'U') {
-            $late_subtotal{$section} += $usage
-              unless scalar(@$extra_sections);
-          }
-
-        } else {
-
-          next if $cust_bill_pkg->pkgnum == 0 && ! $section;
-
-          if (! $type || $type eq 'S') {
-            $subtotal{$section} += $cust_bill_pkg->setup
-              if $cust_bill_pkg->setup != 0
-              || $cust_bill_pkg->setup_show_zero;
-          }
-
-          if (! $type) {
-            $subtotal{$section} += $cust_bill_pkg->recur
-              if $cust_bill_pkg->recur != 0
-              || $cust_bill_pkg->recur_show_zero;
-          }
-
-          if ($type && $type eq 'R') {
-            $subtotal{$section} += $cust_bill_pkg->recur - $usage
-              if $cust_bill_pkg->recur != 0
-              || $cust_bill_pkg->recur_show_zero;
-          }
-          
-          if ($type && $type eq 'U') {
-            $subtotal{$section} += $usage
-              unless scalar(@$extra_sections);
-          }
-
-        }
-
-      }
-
-  }
-
-  %pkg_category_cache = ();
-
-  push @$late, map { { 'description' => &{$escape}($_),
-                       'subtotal'    => $late_subtotal{$_},
-                       'post_total'  => 1,
-                       'sort_weight' => ( _pkg_category($_)
-                                            ? _pkg_category($_)->weight
-                                            : 0
-                                       ),
-                       ((_pkg_category($_) && _pkg_category($_)->condense)
-                                           ? $self->_condense_section($format)
-                                           : ()
-                       ),
-                   } }
-                 sort _sectionsort keys %late_subtotal;
-
-  my @sections;
-  if ( $summarypage ) {
-    @sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled }
-                map { $_->categoryname } qsearch('pkg_category', {});
-    push @sections, '' if exists($subtotal{''});
-  } else {
-    @sections = keys %subtotal;
-  }
-
-  my @early = map { { 'description' => &{$escape}($_),
-                      'subtotal'    => $subtotal{$_},
-                      'summarized'  => $not_tax{$_} ? '' : 'Y',
-                      'tax_section' => $not_tax{$_} ? '' : 'Y',
-                      'sort_weight' => ( _pkg_category($_)
-                                           ? _pkg_category($_)->weight
-                                           : 0
-                                       ),
-                       ((_pkg_category($_) && _pkg_category($_)->condense)
-                                           ? $self->_condense_section($format)
-                                           : ()
-                       ),
-                    }
-                  } @sections;
-  push @early, @$extra_sections if $extra_sections;
-
-  sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
-
-}
-
-#helper subs for above
-
-sub _sectionsort {
-  _pkg_category($a)->weight <=> _pkg_category($b)->weight;
-}
-
-sub _pkg_category {
-  my $categoryname = shift;
-  $pkg_category_cache{$categoryname} ||=
-    qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
-}
-
-my %condensed_format = (
-  'label' => [ qw( Description Qty Amount ) ],
-  'fields' => [
-                sub { shift->{description} },
-                sub { shift->{quantity} },
-                sub { my($href, %opt) = @_;
-                      ($opt{dollar} || ''). $href->{amount};
-                    },
-              ],
-  'align'  => [ qw( l r r ) ],
-  'span'   => [ qw( 5 1 1 ) ],            # unitprices?
-  'width'  => [ qw( 10.7cm 1.4cm 1.6cm ) ],   # don't like this
-);
-
-sub _condense_section {
-  my ( $self, $format ) = ( shift, shift );
-  ( 'condensed' => 1,
-    map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
-      qw( description_generator
-          header_generator
-          total_generator
-          total_line_generator
-        )
-  );
-}
-
-sub _condensed_generator_defaults {
-  my ( $self, $format ) = ( shift, shift );
-  return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
-}
-
-my %html_align = (
-  'c' => 'center',
-  'l' => 'left',
-  'r' => 'right',
-);
-
-sub _condensed_header_generator {
-  my ( $self, $format ) = ( shift, shift );
-
-  my ( $f, $prefix, $suffix, $separator, $column ) =
-    _condensed_generator_defaults($format);
-
-  if ($format eq 'latex') {
-    $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
-    $suffix = "\\\\\n\\hline";
-    $separator = "&\n";
-    $column =
-      sub { my ($d,$a,$s,$w) = @_;
-            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
-          };
-  } elsif ( $format eq 'html' ) {
-    $prefix = '<th></th>';
-    $suffix = '';
-    $separator = '';
-    $column =
-      sub { my ($d,$a,$s,$w) = @_;
-            return qq!<th align="$html_align{$a}">$d</th>!;
-      };
-  }
-
-  sub {
-    my @args = @_;
-    my @result = ();
-
-    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
-      push @result,
-        &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
-    }
-
-    $prefix. join($separator, @result). $suffix;
-  };
-
-}
-
-sub _condensed_description_generator {
-  my ( $self, $format ) = ( shift, shift );
-
-  my ( $f, $prefix, $suffix, $separator, $column ) =
-    _condensed_generator_defaults($format);
-
-  my $money_char = '$';
-  if ($format eq 'latex') {
-    $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
-    $suffix = '\\\\';
-    $separator = " & \n";
-    $column =
-      sub { my ($d,$a,$s,$w) = @_;
-            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
-          };
-    $money_char = '\\dollar';
-  }elsif ( $format eq 'html' ) {
-    $prefix = '"><td align="center"></td>';
-    $suffix = '';
-    $separator = '';
-    $column =
-      sub { my ($d,$a,$s,$w) = @_;
-            return qq!<td align="$html_align{$a}">$d</td>!;
-      };
-    #$money_char = $conf->config('money_char') || '$';
-    $money_char = '';  # this is madness
-  }
-
-  sub {
-    #my @args = @_;
-    my $href = shift;
-    my @result = ();
-
-    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
-      my $dollar = '';
-      $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
-      push @result,
-        &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
-                    map { $f->{$_}->[$i] } qw(align span width)
-                  );
-    }
-
-    $prefix. join( $separator, @result ). $suffix;
-  };
-
-}
-
-sub _condensed_total_generator {
-  my ( $self, $format ) = ( shift, shift );
-
-  my ( $f, $prefix, $suffix, $separator, $column ) =
-    _condensed_generator_defaults($format);
-  my $style = '';
-
-  if ($format eq 'latex') {
-    $prefix = "& ";
-    $suffix = "\\\\\n";
-    $separator = " & \n";
-    $column =
-      sub { my ($d,$a,$s,$w) = @_;
-            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
-          };
-  }elsif ( $format eq 'html' ) {
-    $prefix = '';
-    $suffix = '';
-    $separator = '';
-    $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
-    $column =
-      sub { my ($d,$a,$s,$w) = @_;
-            return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
-      };
-  }
-
-
-  sub {
-    my @args = @_;
-    my @result = ();
-
-    #  my $r = &{$f->{fields}->[$i]}(@args);
-    #  $r .= ' Total' unless $i;
-
-    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
-      push @result,
-        &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
-                    map { $f->{$_}->[$i] } qw(align span width)
-                  );
-    }
-
-    $prefix. join( $separator, @result ). $suffix;
-  };
-
-}
-
-=item total_line_generator FORMAT
-
-Returns a coderef used for generation of invoice total line items for this
-usage_class.  FORMAT is either html or latex
-
-=cut
-
-# should not be used: will have issues with hash element names (description vs
-# total_item and amount vs total_amount -- another array of functions?
-
-sub _condensed_total_line_generator {
-  my ( $self, $format ) = ( shift, shift );
-
-  my ( $f, $prefix, $suffix, $separator, $column ) =
-    _condensed_generator_defaults($format);
-  my $style = '';
-
-  if ($format eq 'latex') {
-    $prefix = "& ";
-    $suffix = "\\\\\n";
-    $separator = " & \n";
-    $column =
-      sub { my ($d,$a,$s,$w) = @_;
-            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
-          };
-  }elsif ( $format eq 'html' ) {
-    $prefix = '';
-    $suffix = '';
-    $separator = '';
-    $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
-    $column =
-      sub { my ($d,$a,$s,$w) = @_;
-            return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
-      };
-  }
-
-
-  sub {
-    my @args = @_;
-    my @result = ();
-
-    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
-      push @result,
-        &{$column}( &{$f->{fields}->[$i]}(@args),
-                    map { $f->{$_}->[$i] } qw(align span width)
-                  );
-    }
-
-    $prefix. join( $separator, @result ). $suffix;
-  };
-
-}
-
-#sub _items_extra_usage_sections {
-#  my $self = shift;
-#  my $escape = shift;
-#
-#  my %sections = ();
-#
-#  my %usage_class =  map{ $_->classname, $_ } qsearch('usage_class', {});
-#  foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
-#  {
-#    next unless $cust_bill_pkg->pkgnum > 0;
-#
-#    foreach my $section ( keys %usage_class ) {
-#
-#      my $usage = $cust_bill_pkg->usage($section);
-#
-#      next unless $usage && $usage > 0;
-#
-#      $sections{$section} ||= 0;
-#      $sections{$section} += $usage;
-#
-#    }
-#
-#  }
-#
-#  map { { 'description' => &{$escape}($_),
-#          'subtotal'    => $sections{$_},
-#          'summarized'  => '',
-#          'tax_section' => '',
-#        }
-#      }
-#    sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
-#
-#}
-
-sub _items_extra_usage_sections {
-  my $self = shift;
-  my $conf = $self->conf;
-  my $escape = shift;
-  my $format = shift;
-
-  my %sections = ();
-  my %classnums = ();
-  my %lines = ();
-
-  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
-
-  my %usage_class =  map { $_->classnum => $_ } qsearch( 'usage_class', {} );
-  foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
-    next unless $cust_bill_pkg->pkgnum > 0;
-
-    foreach my $classnum ( keys %usage_class ) {
-      my $section = $usage_class{$classnum}->classname;
-      $classnums{$section} = $classnum;
-
-      foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
-        my $amount = $detail->amount;
-        next unless $amount && $amount > 0;
- 
-        $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
-        $sections{$section}{amount} += $amount;  #subtotal
-        $sections{$section}{calls}++;
-        $sections{$section}{duration} += $detail->duration;
-
-        my $desc = $detail->regionname; 
-        my $description = $desc;
-        $description = substr($desc, 0, $maxlength). '...'
-          if $format eq 'latex' && length($desc) > $maxlength;
-
-        $lines{$section}{$desc} ||= {
-          description     => &{$escape}($description),
-          #pkgpart         => $part_pkg->pkgpart,
-          pkgnum          => $cust_bill_pkg->pkgnum,
-          ref             => '',
-          amount          => 0,
-          calls           => 0,
-          duration        => 0,
-          #unit_amount     => $cust_bill_pkg->unitrecur,
-          quantity        => $cust_bill_pkg->quantity,
-          product_code    => 'N/A',
-          ext_description => [],
-        };
-
-        $lines{$section}{$desc}{amount} += $amount;
-        $lines{$section}{$desc}{calls}++;
-        $lines{$section}{$desc}{duration} += $detail->duration;
-
-      }
-    }
-  }
-
-  my %sectionmap = ();
-  foreach (keys %sections) {
-    my $usage_class = $usage_class{$classnums{$_}};
-    $sectionmap{$_} = { 'description' => &{$escape}($_),
-                        'amount'    => $sections{$_}{amount},    #subtotal
-                        'calls'       => $sections{$_}{calls},
-                        'duration'    => $sections{$_}{duration},
-                        'summarized'  => '',
-                        'tax_section' => '',
-                        'sort_weight' => $usage_class->weight,
-                        ( $usage_class->format
-                          ? ( map { $_ => $usage_class->$_($format) }
-                              qw( description_generator header_generator total_generator total_line_generator )
-                            )
-                          : ()
-                        ), 
-                      };
-  }
-
-  my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} }
-                 values %sectionmap;
-
-  my @lines = ();
-  foreach my $section ( keys %lines ) {
-    foreach my $line ( keys %{$lines{$section}} ) {
-      my $l = $lines{$section}{$line};
-      $l->{section}     = $sectionmap{$section};
-      $l->{amount}      = sprintf( "%.2f", $l->{amount} );
-      #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
-      push @lines, $l;
-    }
-  }
-
-  return(\@sections, \@lines);
-
-}
-
-sub _did_summary {
-    my $self = shift;
-    my $end = $self->_date;
-
-    # start at date of previous invoice + 1 second or 0 if no previous invoice
-    my $start = $self->scalar_sql("SELECT max(_date) FROM cust_bill WHERE custnum = ? and invnum != ?",$self->custnum,$self->invnum);
-    $start = 0 if !$start;
-    $start++;
-
-    my $cust_main = $self->cust_main;
-    my @pkgs = $cust_main->all_pkgs;
-    my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes)
-	= (0,0,0,0,0);
-    my @seen = ();
-    foreach my $pkg ( @pkgs ) {
-	my @h_cust_svc = $pkg->h_cust_svc($end);
-	foreach my $h_cust_svc ( @h_cust_svc ) {
-	    next if grep {$_ eq $h_cust_svc->svcnum} @seen;
-	    next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone';
+    my $cust_main = $self->cust_main;
+    my @pkgs = $cust_main->all_pkgs;
+    my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes)
+	= (0,0,0,0,0);
+    my @seen = ();
+    foreach my $pkg ( @pkgs ) {
+	my @h_cust_svc = $pkg->h_cust_svc($end);
+	foreach my $h_cust_svc ( @h_cust_svc ) {
+	    next if grep {$_ eq $h_cust_svc->svcnum} @seen;
+	    next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone';
 
 	    my $inserted = $h_cust_svc->date_inserted;
 	    my $deleted = $h_cust_svc->date_deleted;
@@ -4795,23 +2861,6 @@ sub _items_svc_phone_sections {
 
 }
 
-sub _items { # seems to be unused
-  my $self = shift;
-
-  #my @display = scalar(@_)
-  #              ? @_
-  #              : qw( _items_previous _items_pkg );
-  #              #: qw( _items_pkg );
-  #              #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
-  my @display = qw( _items_previous _items_pkg );
-
-  my @b = ();
-  foreach my $display ( @display ) {
-    push @b, $self->$display(@_);
-  }
-  @b;
-}
-
 sub _items_previous {
   my $self = shift;
   my $conf = $self->conf;
@@ -4845,475 +2894,6 @@ 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 = @_;
-
-  warn "$me _items_pkg searching for all package line items\n"
-    if $DEBUG > 1;
-
-  my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
-
-  warn "$me _items_pkg filtering line items\n"
-    if $DEBUG > 1;
-  my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
-
-  if ($options{section} && $options{section}->{condensed}) {
-
-    warn "$me _items_pkg condensing section\n"
-      if $DEBUG > 1;
-
-    my %itemshash = ();
-    local $Storable::canonical = 1;
-    foreach ( @items ) {
-      my $item = { %$_ };
-      delete $item->{ref};
-      delete $item->{ext_description};
-      my $key = freeze($item);
-      $itemshash{$key} ||= 0;
-      $itemshash{$key} ++; # += $item->{quantity};
-    }
-    @items = sort { $a->{description} cmp $b->{description} }
-             map { my $i = thaw($_);
-                   $i->{quantity} = $itemshash{$_};
-                   $i->{amount} =
-                     sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
-                   $i;
-                 }
-             keys %itemshash;
-  }
-
-  warn "$me _items_pkg returning ". scalar(@items). " items\n"
-    if $DEBUG > 1;
-
-  @items;
-}
-
-sub _taxsort {
-  return 0 unless $a->itemdesc cmp $b->itemdesc;
-  return -1 if $b->itemdesc eq 'Tax';
-  return 1 if $a->itemdesc eq 'Tax';
-  return -1 if $b->itemdesc eq 'Other surcharges';
-  return 1 if $a->itemdesc eq 'Other surcharges';
-  $a->itemdesc cmp $b->itemdesc;
-}
-
-sub _items_tax {
-  my $self = shift;
-  my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
-  $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
-}
-
-=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 $no_usage = $opt{no_usage} || '';
-  my $unsquelched = $opt{unsquelched} || ''; #unused
-  my $section = $opt{section}->{description} if $opt{section};
-  my $summary_page = $opt{summary_page} || ''; #unused
-  my $multilocation = $opt{multilocation} || '';
-  my $multisection = $opt{multisection} || '';
-  my $discount_show_always = 0;
-
-  my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
-
-  my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
-
-  my @b = ();
-  my ($s, $r, $u) = ( undef, undef, undef );
-  foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
-  {
-
-    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
-      if ( $_ && !$cust_bill_pkg->hidden ) {
-        $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
-        $_->{amount}      =~ s/^\-0\.00$/0.00/;
-        $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
-        push @b, { %$_ }
-          if $_->{amount} != 0
-          || $discount_show_always
-          || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
-          || (   $_->{_is_setup} && $_->{setup_show_zero} )
-        ;
-        $_ = undef;
-      }
-    }
-
-    my @cust_bill_pkg_display = $cust_bill_pkg->cust_bill_pkg_display;
-
-    warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
-         $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
-      if $DEBUG > 1;
-
-    foreach my $display ( grep { defined($section)
-                                 ? $_->section eq $section
-                                 : 1
-                               }
-                          #grep { !$_->summary || !$summary_page } # bunk!
-                          grep { !$_->summary || $multisection }
-                          @cust_bill_pkg_display
-                        )
-    {
-
-      warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
-           $display->billpkgdisplaynum. "\n"
-        if $DEBUG > 1;
-
-      my $type = $display->type;
-
-      my $desc = $cust_bill_pkg->desc;
-      $desc = substr($desc, 0, $maxlength). '...'
-        if $format eq 'latex' && length($desc) > $maxlength;
-
-      my %details_opt = ( 'format'          => $format,
-                          'escape_function' => $escape_function,
-                          'format_function' => $format_function,
-                          'no_usage'        => $opt{'no_usage'},
-                        );
-
-      if ( $cust_bill_pkg->pkgnum > 0 ) {
-
-        warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
-          if $DEBUG > 1;
- 
-        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
-                )
-           )
-         {
-
-          warn "$me _items_cust_bill_pkg adding setup\n"
-            if $DEBUG > 1;
-
-          my $description = $desc;
-          $description .= ' Setup'
-            if $cust_bill_pkg->recur != 0
-            || $discount_show_always
-            || $cust_bill_pkg->recur_show_zero;
-
-          my @d = ();
-          unless ( $cust_pkg->part_pkg->hide_svc_detail
-                || $cust_bill_pkg->hidden )
-          {
-
-            push @d, map &{$escape_function}($_),
-                         $cust_pkg->h_labels_short($self->_date, undef, 'I')
-              unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
-
-            if ( $multilocation ) {
-              my $loc = $cust_pkg->location_label;
-              $loc = substr($loc, 0, $maxlength). '...'
-                if $format eq 'latex' && length($loc) > $maxlength;
-              push @d, &{$escape_function}($loc);
-            }
-
-          } #unless hiding service details
-
-          push @d, $cust_bill_pkg->details(%details_opt)
-            if $cust_bill_pkg->recur == 0;
-
-          if ( $cust_bill_pkg->hidden ) {
-            $s->{amount}      += $cust_bill_pkg->setup;
-            $s->{unit_amount} += $cust_bill_pkg->unitsetup;
-            push @{ $s->{ext_description} }, @d;
-          } else {
-            $s = {
-              _is_setup       => 1,
-              description     => $description,
-              #pkgpart         => $part_pkg->pkgpart,
-              pkgnum          => $cust_bill_pkg->pkgnum,
-              amount          => $cust_bill_pkg->setup,
-              setup_show_zero => $cust_bill_pkg->setup_show_zero,
-              unit_amount     => $cust_bill_pkg->unitsetup,
-              quantity        => $cust_bill_pkg->quantity,
-              ext_description => \@d,
-            };
-          };
-
-        }
-
-        if (    ( !$type || $type eq 'R' || $type eq 'U' )
-             && (
-                     $cust_bill_pkg->recur != 0
-                  || $cust_bill_pkg->setup == 0
-                  || $discount_show_always
-                  || $cust_bill_pkg->recur_show_zero
-                )
-           )
-        {
-
-          warn "$me _items_cust_bill_pkg adding recur/usage\n"
-            if $DEBUG > 1;
-
-          my $is_summary = $display->summary;
-          my $description = ($is_summary && $type && $type eq 'U')
-                            ? "Usage charges" : $desc;
-
-          #pry be a bit more efficient to look some of this conf stuff up
-          # outside the loop
-          unless (
-            $conf->exists('disable_line_item_date_ranges')
-              || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1)
-          ) {
-            my $time_period;
-            my $date_style = $conf->config( 'cust_bill-line_item-date_style',
-                                            $cust_main->agentnum
-                                          );
-            if ( defined($date_style) && $date_style eq 'month_of' ) {
-              $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
-            } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
-              my $desc = $conf->config( 'cust_bill-line_item-date_description',
-                                         $cust_main->agentnum
-                                      );
-              $desc .= ' ' unless $desc =~ /\s$/;
-              $time_period = $desc. time2str('%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
-          my @dates = ( $self->_date );
-          my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
-          push @dates, $prev->sdate if $prev;
-          push @dates, undef if !$prev;
-
-          unless ( $cust_pkg->part_pkg->hide_svc_detail
-                || $cust_bill_pkg->itemdesc
-                || $cust_bill_pkg->hidden
-                || $is_summary && $type && $type eq 'U' )
-          {
-
-            warn "$me _items_cust_bill_pkg adding service details\n"
-              if $DEBUG > 1;
-
-            push @d, map &{$escape_function}($_),
-                         $cust_pkg->h_labels_short(@dates, 'I')
-                                                   #$cust_bill_pkg->edate,
-                                                   #$cust_bill_pkg->sdate)
-              unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
-
-            warn "$me _items_cust_bill_pkg done adding service details\n"
-              if $DEBUG > 1;
-
-            if ( $multilocation ) {
-              my $loc = $cust_pkg->location_label;
-              $loc = substr($loc, 0, $maxlength). '...'
-                if $format eq 'latex' && length($loc) > $maxlength;
-              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 ) {
-            warn "$me _items_cust_bill_pkg adding details\n"
-              if $DEBUG > 1;
-
-            #instead of omitting details entirely in this case (unwanted side
-            # effects), just omit CDRs
-            $details_opt{'no_usage'} = 1
-              if $type && $type eq 'R';
-
-            push @d, $cust_bill_pkg->details(%details_opt);
-          }
-
-          warn "$me _items_cust_bill_pkg calculating amount\n"
-            if $DEBUG > 1;
-  
-          my $amount = 0;
-          if (!$type) {
-            $amount = $cust_bill_pkg->recur;
-          } elsif ($type eq 'R') {
-            $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
-          } elsif ($type eq 'U') {
-            $amount = $cust_bill_pkg->usage;
-          }
-  
-          if ( !$type || $type eq 'R' ) {
-
-            warn "$me _items_cust_bill_pkg adding recur\n"
-              if $DEBUG > 1;
-
-            if ( $cust_bill_pkg->hidden ) {
-              $r->{amount}      += $amount;
-              $r->{unit_amount} += $cust_bill_pkg->unitrecur;
-              push @{ $r->{ext_description} }, @d;
-            } else {
-              $r = {
-                description     => $description,
-                #pkgpart         => $part_pkg->pkgpart,
-                pkgnum          => $cust_bill_pkg->pkgnum,
-                amount          => $amount,
-                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'
-
-            warn "$me _items_cust_bill_pkg adding usage\n"
-              if $DEBUG > 1;
-
-            if ( $cust_bill_pkg->hidden ) {
-              $u->{amount}      += $amount;
-              $u->{unit_amount} += $cust_bill_pkg->unitrecur;
-              push @{ $u->{ext_description} }, @d;
-            } else {
-              $u = {
-                description     => $description,
-                #pkgpart         => $part_pkg->pkgpart,
-                pkgnum          => $cust_bill_pkg->pkgnum,
-                amount          => $amount,
-                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,
-              };
-            }
-          }
-
-        } # recurring or usage with recurring charge
-
-      } else { #pkgnum tax or one-shot line item (??)
-
-        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),
-          };
-        }
-
-      }
-
-    }
-
-    $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
-                                && $conf->exists('discount-show-always'));
-
-  }
-
-  foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
-    if ( $_  ) {
-      $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
-      $_->{amount}      =~ s/^\-0\.00$/0.00/;
-      $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
-      push @b, { %$_ }
-        if $_->{amount} != 0
-        || $discount_show_always
-        || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
-        || (   $_->{_is_setup} && $_->{setup_show_zero} )
-    }
-  }
-
-  warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
-    if $DEBUG > 1;
-
-  @b;
-
-}
-
 sub _items_credits {
   my( $self, %opt ) = @_;
   my $trim_len = $opt{'trim_len'} || 60;
@@ -5362,51 +2942,6 @@ 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;
-
-    # discounts for non-integer months don't work anyway
-    $months = sprintf("%d", $months);
-
-    +{
-      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
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index b382232..82b09b6 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -8,7 +8,7 @@ use base qw( FS::cust_main::Packages FS::cust_main::Status
              FS::cust_main::Billing_Discount
              FS::cust_main::Location
              FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
-             FS::geocode_Mixin
+             FS::geocode_Mixin FS::Quotable_Mixin
              FS::o2m_Common
              FS::Record
            );
diff --git a/FS/FS/prospect_main.pm b/FS/FS/prospect_main.pm
index 6adc852..b5d51d3 100644
--- a/FS/FS/prospect_main.pm
+++ b/FS/FS/prospect_main.pm
@@ -1,10 +1,10 @@
 package FS::prospect_main;
 
 use strict;
-use base qw( FS::o2m_Common FS::Record );
+use base qw( FS::Quotable_Mixin FS::o2m_Common FS::Record );
 use vars qw( $DEBUG );
 use Scalar::Util qw( blessed );
-use FS::Record qw( dbh qsearch ); #qsearchs );
+use FS::Record qw( dbh qsearch qsearchs );
 use FS::agent;
 use FS::cust_location;
 use FS::contact;
@@ -213,6 +213,9 @@ sub check {
 
 =item name
 
+Returns a name for this prospect, as a string (company name for commercial
+prospects, contact name for residential prospects).
+
 =cut
 
 sub name {
@@ -259,6 +262,16 @@ sub qual {
   qsearch( 'qual', { 'prospectnum' => $self->prospectnum } );
 }
 
+=item agent
+
+Returns the agent (see L<FS::agent>) for this customer.
+
+=cut
+
+sub agent {
+  my $self = shift;
+  qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
+}
 
 =item search HASHREF
 
diff --git a/FS/FS/quotation.pm b/FS/FS/quotation.pm
index 4202335..0cfb11e 100644
--- a/FS/FS/quotation.pm
+++ b/FS/FS/quotation.pm
@@ -1,10 +1,12 @@
 package FS::quotation;
+use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record );
 
 use strict;
-use base qw( FS::otaker_Mixin FS::Record );
-use FS::Record; # qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs );
+use FS::CurrentUser;
 use FS::cust_main;
 use FS::prospect_main;
+use FS::quotation_pkg;
 
 =head1 NAME
 
@@ -73,6 +75,8 @@ points to.  You can ask the object for a copy with the I<hash> method.
 =cut
 
 sub table { 'quotation'; }
+sub notice_name { 'Quotation'; }
+sub template_conf { 'quotation_'; }
 
 =item insert
 
@@ -111,7 +115,7 @@ sub check {
 
   $self->_date(time) unless $self->_date;
 
-  #XXX set usernum
+  $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
   $self->SUPER::check;
 }
@@ -134,6 +138,16 @@ sub cust_main {
   qsearchs('cust_main', { 'custnum' => $self->custnum } );
 }
 
+=item cust_bill_pkg
+
+=cut
+
+sub cust_bill_pkg {
+  my $self = shift;
+  #actually quotation_pkg objects
+  qsearch('quotation_pkg', { quotationnum=>$self->quotationnum });
+}
+
 =back
 
 =head1 BUGS
diff --git a/FS/MANIFEST b/FS/MANIFEST
index e7aba20..590874d 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -641,3 +641,11 @@ FS/part_svc_class.pm
 t/part_svc_class.t
 FS/ftp_target.pm
 t/ftp_target.t
+FS/quotation.pm
+t/quotation.t
+FS/quotation_pkg.pm
+t/quotation_pkg.t
+FS/quotation_pkg_discount.pm
+t/quotation_pkg_discount.t
+FS/Quotable_Mixin.pm
+t/Quotable_Mixin.t
diff --git a/conf/quotation_html b/conf/quotation_html
new file mode 100644
index 0000000..1dfb944
--- /dev/null
+++ b/conf/quotation_html
@@ -0,0 +1,266 @@
+<STYLE TYPE="text/css">
+.invoice { font-family: sans-serif; font-size: 10pt }
+.invoice_header { font-size: 10pt }
+.invoice_headerright TH { border-top: 2px solid #000000; border-bottom: 2px solid #000000 }
+.invoice_headerright TD { font-size: 10pt; empty-cells: show }
+.invoice_summary TH { border-bottom: 2px solid #000000 }
+.invoice_summary TD { font-size: 10pt; empty-cells: show }
+.invoice_longtable table { cellspacing: none }
+.invoice_longtable TH { border-top: 2px solid #000000; border-bottom: 1px solid #000000; padding-left: none; padding-right: none; font-size: 10pt }
+.invoice_desc TD { border-top: 2px solid #000000; font-weight: bold; font-size: 10pt }
+.invoice_desc_more TD { font-weight: bold; font-size: 10pt }
+.invoice_extdesc TD { font-size: 8pt }
+.invoice_totaldesc TD { font-size: 10pt; empty-cells: show }
+.allcaps { text-transform:uppercase }
+</STYLE>
+
+<table class="invoice" bgcolor="#ffffff" WIDTH=625 CELLSPACING=8><tr><td>
+
+  <table class="invoice_header" width="100%">
+    <tr>
+     <td><img src="<%= $cid ? "cid:$cid" : "cust_bill-logo.cgi?invnum=$invnum;template=$template" %>"></td>
+     <td align="left"><%= $returnaddress %></td>
+      <td align="right">
+        <table CLASS="invoice_headerright" cellspacing=0>
+          <tr>
+            <td align="center">
+              <%= emt('Quotation date') %><BR>
+              <B><%= $date %></B>
+            </td>
+            <td>
+            </td>
+            <td align="center">
+              <%= emt('Quotation #') %><BR>
+              <B><%= $quotationnum %></B>
+            </td>
+            <td>
+            </td>
+            <td align="center">
+              <%= $custnum ? emt('Customer #') : $prospectnum ? emt('Prospect #') : '' %><BR>
+              <B><%= $custnum || $prospectnum %></B>
+            </td>
+          </tr>
+          <tr>
+            <th> </th>
+            <th colspan=3 align="center" class="allcaps">
+	      <FONT SIZE="+3"><%= substr(emt($notice_name),0,1) %></FONT><FONT SIZE="+2"><%= substr(emt($notice_name),1) %></FONT>
+            </th>
+            <th> </th>
+          </tr>
+        </table>
+      </td>
+    </tr>
+
+    <tr>
+      <td>
+      </td>
+      <td align="left">
+        <b><%= $name %></b><BR>
+        <%= join('<BR>', grep length($_), $company,
+                                          $address1,
+                                          $address2,
+                                          "$city, $state  $zip",
+                                          $country,
+                )
+        %>
+      </td>
+      <%= $ship_enable ? ('<td align="left">'.
+                          join('<BR>',grep length($_), '<b>'.emt('Service Address').'</b>',
+                                                       $ship_company,
+                                                       $ship_address1,
+                                                       $ship_address2,
+                                                       "$ship_city, $ship_state $ship_zip",
+                                                       $ship_country,
+                                                       ' ',
+                                                       ' ',
+                              ).
+                           '</td><tr><td></td><td></td>'
+                         )
+                       : ''
+      %>
+      <td align="right">
+    <%=
+	if($barcode_cid) {
+	    $OUT .= qq! <img src="cid:$barcode_cid"><br> !;
+	}
+	elsif($barcode_img) {
+	    $OUT .= qq! <img src="cust_bill-barcode.cgi?invnum=$invnum;template=$template"><br> !;
+	}
+    %>
+        <%= $terms ? emt('Terms') . ": $terms" : '' %><BR>
+        <%= $po_line %>
+      </td>
+    </tr>
+
+  </table>
+  <%= $summary %>
+  <%=
+      my $notfirst = 0;
+      my $columncount = $unitprices ? 5 : 3;
+      foreach my $section ( grep { !$summary || $_->{description} ne $finance_section } @sections ) {
+        if ($section->{'pretotal'} && !$summary) {
+          $OUT .= '</table>' if $notfirst;
+          $OUT .=
+            '<table width="100%"><tr><td>'.
+            '<p align="right"><b><font size="+1">'.
+            uc(substr($section->{'pretotal'},0,1)).
+            '</font><font size="+0">'. uc(substr($section->{'pretotal'},1)).
+            '</font></b>'.
+            '<p>'.
+            '</td></tr>';
+        }
+        unless ($section->{'summarized'}) {
+          $OUT .= '</table>' if ( $notfirst || $section->{'pretotal'} && !$summary );
+          $OUT .= '<table><tr><td>';
+          my $sectionhead = $section->{'description'} || emt('Charges');
+          $OUT .=
+              '<p class="allcaps"><b><font size="+1">'. substr($sectionhead,0,1).
+              '</font><font size="+0">'. substr($sectionhead,1).
+              '</font></b>'.
+              '<p>'.
+              '</td></tr></table>';
+
+          $OUT .=
+            '<table class="invoice_longtable" CELLSPACING=0 WIDTH="100%">'.
+            '<tr>';
+
+          if ($section->{header_generator}) {
+            my $header = &{$section->{header_generator}}();
+            $OUT .= $header;
+            $columncount = scalar(my @array = split /<\/th><th/i, $header);
+          } else {
+            $OUT .=  '<th align="center">' . emt('Ref') . '</th>'.
+                     '<th align="left">' . emt('Description') . '</th>'.
+                     ( $unitprices 
+                       ? '<th align="left">' . emt('Unit Price') . '</th>'.
+                         '<th align="left">' . emt('Quantity') . '</th>'
+                        : '' ).
+                     '<th align="right">' . emt('Amount') . '</th>';
+          }
+            $OUT .= '</tr>';
+
+          my $lastref = 0;
+          foreach my $line (
+            grep { ( scalar(@sections) > 1 
+                   ? $section->{'description'} eq $_->{'section'}->{'description'}
+                   : 1
+                 ) }
+            @detail_items )
+          {
+            $OUT .=
+              '<tr class="invoice_desc';
+            if ( $section->{description_generator} ) {
+              $OUT .= &{$section->{description_generator}}($line);
+            } else {
+              $OUT .=  ( ($line->{'ref'} && $line->{'ref'} ne $lastref) ? '' : '_more' ).
+                       '">'.
+                       '<td align="center">'. 
+                       ( $line->{'ref'} ne $lastref ? $line->{'ref'} : '' ). '</td>'.
+                       '<td align="left">'. $line->{'description'}. '</td>'.
+                       ( $unitprices 
+                           ? '<td align="left">'. $line->{'unit_amount'}. '</td>'.
+                             '<td align="left">'. $line->{'quantity'}. '</td>'
+                           : ''
+                       ).
+
+                       '<td align="right">'. $line->{'amount'}. '</td>';
+            }
+            $OUT .= '</tr>';
+            $lastref = $line->{'ref'};
+            if ( @{$line->{'ext_description'} } ) {
+              unless ( $section->{description_generator} ) {
+                $OUT .= '<tr class="invoice_extdesc"><td></td><td';
+                $OUT .= $unitprices ? ' colspan=3' : '';
+                $OUT .= '><table width="100%">';
+              }
+              foreach my $ext_desc ( @{$line->{'ext_description'} } ) {
+                $OUT .=
+                  '<tr class="invoice_extdesc">'.
+                    ( $section->{'description_generator'} ? '<td></td>' : '' ).
+                    '<td align="left" '.
+                         ( $ext_desc =~ /<\/?TD>/i ? '' : 'colspan=99' ). '>'.
+                      '  '. $ext_desc.
+                    '</td>'.
+                  '</tr>'
+              }
+              unless ( $section->{description_generator} ) {
+                $OUT .= '</table></td><td></td>';
+              }
+              $OUT .= '</tr>';
+            }
+          }
+
+
+          if ($section->{'description'} || $multisection and !$section->{no_subtotal}) {
+            my $style = 'border-top: 3px solid #000000;'.
+                        'border-bottom: 3px solid #000000;';
+            $OUT .=
+              '<tr class="invoice_totaldesc">'.
+              qq(<td style="$style"> </td>);
+            if ($section->{total_generator}) {
+              $OUT .= &{$section->{total_generator}}($section);
+            } else {
+              $OUT .= qq(<td align="left" style="$style"). 
+                      ( $unitprices ? ' colspan=3>' : '>' ).
+                      $section->{'description'}. ' ' . emt('Total') . '</td>'.
+                      qq(<td align="right" style="$style">).
+                      $section->{'subtotal'}. '</td>';
+            }
+            $OUT .= '</tr>';
+          }
+        } 
+        if ($section->{'posttotal'}) {
+          $OUT .= '<tr><td align="right" colspan='. $columncount. '>';
+          $OUT .=
+            '<p><font size="+1">'. $section->{'posttotal'}.
+            '</font>'.
+            '<p>';
+          $OUT .= '</td></tr>';
+        }
+
+        $notfirst++;
+
+      }
+
+      my $style = 'border-top: 3px solid #000000;';
+      my $linenum = 0;
+
+      foreach my $line ( @total_items ) {
+
+        $style .= 'border-bottom: 3px solid #000000;'
+          if ++$linenum == scalar(@total_items) - ( $balance_due_below_line ? 1 : 0 );
+
+        $OUT .=
+          '<tr class="invoice_totaldesc">';
+        if ($section->{total_line_generator}) {
+          $OUT .= &{$section->{total_line_generator}}($line);
+        } else {
+          $OUT .= qq(<td style="$style"> </td>).
+                  qq(<td align="left" style="$style" colspan=").
+                    ( $columncount - 2 ). '">'.
+                    $line->{'total_item'}. '</td>'.
+                  qq(<td align="right" style="$style">).
+                    $line->{'total_amount'}. '</td>';
+        }
+        $OUT .= '</tr>';
+
+        $style='';
+
+      }
+
+    %>
+  </table>
+  <br><br>
+
+<%= length($summary)
+      ? ''
+      : ( $smallernotes
+            ? '<FONT SIZE="-1">'.$notes.'</FONT>'
+            : $notes
+        )
+%>
+
+  <hr NOSHADE SIZE=2 COLOR="#000000">
+  <p align="center" <%= $smallerfooter ? 'STYLE="font-size:75%;"' : '' %>><%= $footer %>
+
+</td></tr></table>
diff --git a/conf/quotation_latex b/conf/quotation_latex
new file mode 100644
index 0000000..772c2eb
--- /dev/null
+++ b/conf/quotation_latex
@@ -0,0 +1,362 @@
+%% file: Standard Multipage.tex
+%% Purpose: Multipage bill template for e-Bills
+%% 
+%% Created by Mark Asplen-Taylor
+%% Asplen Management Ltd
+%% www.asplen.co.uk
+%%
+%% Modified for Freeside by Kristian Hoffman
+%%
+%% Changes
+%% 	0.1	4/12/00	Created
+%%	0.2	18/10/01	More fields added
+%%	1.0	16/11/01	RELEASED
+%%	1.2	16/10/02	Invoice number added
+%%	1.3	2/12/02	Logo graphic added
+%%	1.4	7/2/03	Multipage headers/footers added
+%%      n/a     forked for Freeside; checked into CVS
+%%
+
+\documentclass[letterpaper]{article}
+
+\usepackage{fancyhdr,lastpage,ifthen,array,fslongtable,afterpage,caption,multirow,bigstrut}
+\usepackage{graphicx}			% required for logo graphic
+\usepackage[utf8]{inputenc}             % multilanguage support
+\usepackage[T1]{fontenc}
+
+\addtolength{\voffset}{-0.0cm}		% top margin to top of header
+\addtolength{\hoffset}{-0.6cm}		% left margin on page
+\addtolength{\topmargin}{[@-- defined($topmargin) ? $topmargin : '-1.25cm' --@]}
+\setlength{\headheight}{2.0cm} 		% height of header
+\setlength{\headsep}{[@-- defined($headsep) ? $headsep : '1.0cm' --@]}
+\setlength{\footskip}{1.0cm}		% bottom of footer from bottom of text
+
+%\addtolength{\textwidth}{2.1in}    	% width of text
+\setlength{\textwidth}{19.5cm}
+\setlength{\textheight}{[@-- defined($textheight) ? $textheight : '19.5cm' --@]}
+\setlength{\oddsidemargin}{-0.9cm} 	% odd page left margin
+\setlength{\evensidemargin}{-0.9cm} 	% even page left margin
+
+\LTchunksize=40
+
+\renewcommand{\headrulewidth}{0pt}
+\renewcommand{\footrulewidth}{1pt}
+
+\renewcommand{\footrule}{
+[@--
+  $coupon ? '\ifthenelse{\equal{\thepage}{1}}' : '';
+--@]
+  {
+  }
+  {
+    \vbox to 0pt{\rule{\headwidth}{\footrulewidth}\vss}
+  }
+}
+
+\newcommand{\extracouponspace}{[@-- defined($extracouponspace) ? $extracouponspace : '3.6cm' --@]}
+
+% Adjust the inset of the mailing address
+\newcommand{\addressinset}[1][]{\hspace{1.0cm}}
+
+% Adjust the inset of the return address and logo
+\newcommand{\returninset}[1][]{\hspace{-0.25cm}}
+
+% New command for address lines i.e. skip them if blank
+\newcommand{\addressline}[1]{\ifthenelse{\equal{#1}{}}{}{#1\\}}
+
+% Inserts dollar symbol
+\newcommand{\dollar}[1][]{\symbol{36}}
+
+% Remove plain style header/footer
+\fancypagestyle{plain}{
+  \fancyhead{}
+}
+\fancyhf{}
+
+% Define fancy header/footer for first and subsequent pages
+\fancyfoot[C]{
+  \ifthenelse{\equal{\thepage}{1}}
+  { % First page
+[@--
+  if ($coupon) {
+    $OUT .= '\vspace{-\extracouponspace}';
+    $OUT .= '\rule[0.5em]{\textwidth}{\footrulewidth}\\\\';
+    $OUT .= $coupon;
+    $OUT .= '\vspace{'. $couponfootsep. '}' if defined($couponfootsep);
+  }
+  '';
+--@] [@-- $smallerfooter ? '\scriptsize{' : '\small{' --@]
+[@-- $footer --@]
+    }[@-- $coupon ? '\vspace{\extracouponspace}' : '' --@]
+  }
+  { % ... pages
+    [@-- $smallerfooter ? '\scriptsize{' : '\small{' --@]
+[@-- $smallfooter --@]
+    }
+  }
+}
+
+\fancyfoot[R]{
+  \ifthenelse{\equal{\thepage}{1}}
+  { % First page
+  }
+  { % ... pages
+    \small{\thepage\ of \pageref{LastPage}}
+  }
+}
+
+\fancyhead[L]{
+  \ifthenelse{\equal{\thepage}{1}}
+  { % First page
+    \returninset
+    \makebox{
+      \begin{tabular}{ll}
+        \includegraphics{[@-- $logo_file --@]} & [@-- $verticalreturnaddress ? '\\\\' : '' --@]
+        \begin{minipage}[b]{5.5cm}
+[@-- $returnaddress --@]
+        \end{minipage}\\
+      \end{tabular}
+    }
+  }
+  { % ... pages
+    %\includegraphics{[@-- $logo_file --@]}	% Uncomment if you want the logo on all pages.
+  }
+}
+
+\fancyhead[R]{
+  \ifthenelse{\equal{\thepage}{1}}
+  { % First page
+    \begin{tabular}{ccc}
+    [@-- join(' & ', emt('Invoice date'), emt('Invoice #'), emt('Customer #') ) --@]\\
+    \vspace{0.2cm}
+    \textbf{[@-- $date --@]} & \textbf{[@-- $invnum --@]} & \textbf{[@-- $custnum --@]} \\\hline
+    \rule{0pt}{5ex} &~~ \huge{\textsc{[@-- emt($notice_name) --@]}} & \\
+    \vspace{-0.2cm}
+     & & \\\hline
+    \end{tabular}
+  }
+  { % ... pages
+    \small{
+      \begin{tabular}{lll}
+      [@-- join(' & ', emt('Invoice date'), emt('Invoice #'), emt('Customer #') ) --@]\\
+      \textbf{[@-- $date --@]} & \textbf{[@-- $invnum --@]} & \textbf{[@-- $custnum --@]}\\
+      \end{tabular}
+    }
+  }
+}
+
+\pagestyle{fancy}
+
+
+%% Font options are:
+%%	bch	Bitsream Charter
+%% 	put	Utopia
+%%	phv	Adobe Helvetica
+%%	pnc	New Century Schoolbook
+%%	ptm	Times
+%%	pcr	Courier
+
+\renewcommand{\familydefault}{phv}
+
+
+% Commands for freeside table header...
+
+\newcommand{\FSdescriptionlength} { [@-- $unitprices ? '8.2cm' : '12.8cm' --@] }
+\newcommand{\FSdescriptioncolumncount} { [@-- $unitprices ? '4' : '6' --@] }
+\newcommand{\FSunitcolumns}{ [@-- 
+  $unitprices 
+  ? '\makebox[2.5cm][l]{\textbf{~~'.emt('Unit Price').'}}&\makebox[1.4cm]{\textbf{~'.emt('Quantity').'}}&' 
+  : '' --@] }
+
+\newcommand{\FShead}{
+  \hline
+  \rule{0pt}{2.5ex}
+  \makebox[1.4cm]{\textbf{Ref}} &
+  \multicolumn{\FSdescriptioncolumncount}{l}{\makebox[\FSdescriptionlength][l]{\textbf{[@-- emt('Description') --@]}}}&
+  \FSunitcolumns
+  \makebox[1.6cm][r]{\textbf{[@-- emt('Amount') --@]}} \\
+  \hline
+}
+
+% ...description...
+\newcommand{\FSdesc}[5]{
+  \multicolumn{1}{c}{\rule{0pt}{2.5ex}\textbf{#1}} &
+  \multicolumn{[@-- $unitprices ? '4' : '6' --@]}{l}{\textbf{#2}} &
+[@-- $unitprices ? '  \multicolumn{1}{l}{\textbf{#3}} &'."\n".
+                   '  \multicolumn{1}{r}{\textbf{#4}} &'."\n"
+                 : ''
+--@]
+  \multicolumn{1}{r}{\textbf{\dollar #5}}\\
+}
+% ...extended description...
+\newcommand{\FSextdesc}[1]{
+  \multicolumn{1}{l}{\rule{0pt}{1.0ex}} &
+%%  \multicolumn{2}{l}{\small{~-~#1}}\\
+#1\\
+}
+% ...and total line items.
+\newcommand{\FStotaldesc}[2]{
+  & \multicolumn{6}{l}{#1} & #2\\
+}
+
+
+\begin{document}
+%	Headers and footers defined for the first page
+\addressinset \rule{0.5cm}{0cm} 
+\makebox{
+\begin{minipage}[t]{7.0cm}
+\vspace{[@-- defined($addresssep) ? $addresssep : '0.25cm' --@]}
+\textbf{[@-- $payname --@]}\\
+\addressline{[@-- $company --@]}
+\addressline{[@-- $address1 --@]}
+\addressline{[@-- $address2 --@]}
+\addressline{[@-- $city --@], [@-- $state --@]~~[@-- $zip --@]}
+\addressline{[@-- $country --@]}
+\end{minipage}}
+\hfill
+\makebox{
+\begin{minipage}[t]{6.4cm}
+[@--
+  if ($ship_enable) {
+    $OUT .= '\textbf{' . emt('Service Address') . '}\\\\';
+    $OUT .= "\\addressline{$ship_company}";
+    $OUT .= "\\addressline{$ship_address1}";
+    $OUT .= "\\addressline{$ship_address2}";
+    $OUT .= "\\addressline{$ship_city, $ship_state~~$ship_zip}";
+    $OUT .= "\\addressline{$ship_country}";
+    $OUT .= '~\\\\';
+  }else{
+    $OUT .= '';
+  }
+--@]
+\begin{flushright}
+[@-- $terms ? emt('Terms') .": $terms" : '' --@]\\
+[@-- $po_line --@]\\
+\end{flushright}
+\end{minipage}}
+\vspace{1.5cm}
+%
+[@-- $summary --@]
+%
+\section*{}
+[@--
+  foreach my $section ( grep { !$summary || $_->{description} ne $finance_section } @sections ) {
+    if ($section->{'pretotal'} && !$summary) {
+      $OUT .= '\begin{flushright}';
+      $OUT .= '\large\textsc{'. $section->{'pretotal'}. '}\\\\';
+      $OUT .= '\\end{flushright}';
+    }
+    $OUT .= '\pagebreak' if $section->{'post_total'};
+    unless ($section->{'summarized'} ) {
+      $OUT .= '\captionsetup{singlelinecheck=false,justification=raggedright,font={Large,sc,bf}}';
+      $OUT .= '\ifthenelse{\equal{\thepage}{1}}{\setlength{\LTextracouponspace}{\extracouponspace}}{\setlength{\LTextracouponspace}{0pt}}'
+        if $coupon;
+      $OUT .= '\begin{longtable}{cllllllr}';
+      $OUT .= '\caption*{ ';
+      $OUT .= ($section->{'description'}) ? $section->{'description'}: emt('Charges');
+      $OUT .= '}\\\\';
+      if ($section->{header_generator}) {
+        $OUT .= &{$section->{header_generator}}();
+      } else {
+        $OUT .= '\FShead';
+      }
+      $OUT .= '\endfirsthead';
+      $OUT .= '\multicolumn{7}{r}{\rule{0pt}{2.5ex}'.emt('Continued from previous page').'}\\\\';
+      if ($section->{header_generator}) {
+        $OUT .= &{$section->{header_generator}}();
+      } else {
+        $OUT .= '\FShead';
+      }
+      $OUT .= '\endhead';
+      $OUT .= '\multicolumn{7}{r}{\rule{0pt}{2.5ex}'.emt('Continued on next page...').'}\\\\';
+      $OUT .= '\endfoot';
+      $OUT .= '\hline';
+
+      if (scalar(@sections) > 1 and !$section->{no_subtotal}) {
+        if ($section->{total_generator}) {
+          $OUT .= &{$section->{total_generator}}($section);
+        } else {
+          $OUT .= '\FStotaldesc{' . $section->{'description'} . ' Total}' .
+                  '{' . $section->{'subtotal'} . '}' . "\n";
+        }
+      }
+
+      #if ($section == $sections[$#sections]) {
+        foreach my $line (grep {$_->{section}->{description} eq $section->{description}} @total_items) {
+          if ($section->{total_line_generator}) {
+            $OUT .= &{$section->{total_line_generator}}($line);
+          } else {
+            $OUT .= '\FStotaldesc{' . $line->{'total_item'} . '}' .
+                    '{' . $line->{'total_amount'} . '}' . "\n";
+          }
+        }
+      #}
+
+      $OUT .= '\hline';
+      $OUT .= '\endlastfoot';
+
+      my $lastref = 0;
+      foreach my $line (
+        grep { ( scalar( @sections ) > 1 
+               ? $section->{'description'} eq $_->{'section'}->{'description'}
+               : 1
+             ) }
+        @detail_items )
+      {
+        my $ext_description = $line->{'ext_description'};
+  
+        # Don't break-up small packages.
+        my $rowbreak = @$ext_description < 5 ? '*' : '';
+  
+        $OUT .= "\\hline\n" if ($line->{'ref'} && $line->{'ref'} ne $lastref);
+        if ($section->{description_generator}) {
+          $OUT .= &{$section->{description_generator}}($line);
+        } else {
+          $OUT .= '\FSdesc'.
+                  '{' . ( $line->{'ref'} ne $lastref ? $line->{'ref'} : '' ) . '}'.
+                  '{' . $line->{'description'} . '}' .
+                  '{' . ( $unitprices ? $line->{'unit_amount'} : '' ) . '}'.
+                  '{' . ( $unitprices ? $line->{'quantity'} : ''  ) . '}' .
+                  '{' . $line->{'amount'} . "}${rowbreak}\n";
+        }
+        $lastref = $line->{'ref'};
+
+        foreach my $ext_desc (@$ext_description) {
+          if ($section->{extended_description_generator}) {
+            $OUT .= &{$section->{extended_description_generator}}($ext_desc);
+          } else {
+            if ( $ext_desc !~ /[^\\]&/ ) {
+              $ext_desc = substr($ext_desc, 0, 80) . '...'
+                if (length($ext_desc) > 80);
+              $ext_desc = '\multicolumn{6}{l}{\small{~~~'. $ext_desc. '}}';
+            }else{
+              $ext_desc = "~~~$ext_desc";
+            }
+            $OUT .= '\FSextdesc{' . $ext_desc . '}' . "${rowbreak}\n";
+          }
+        }
+
+      }
+
+      $OUT .= '\end{longtable}';
+    }
+    if ($section->{'posttotal'}) {
+      $OUT .= '\begin{flushright}';
+      $OUT .= '\normalfont\large\bfseries\textsc{'. $section->{'posttotal'}. '}\\\\';
+      $OUT .= '\\end{flushright}';
+    }
+  }
+
+--@]
+\vfill
+\begin{minipage}[t]{\textwidth}
+  [@-- length($summary)
+         ? ''
+        : ( $smallernotes
+              ? '\scriptsize{ '.$notes.' }'
+              : $notes
+          )
+  --@]
+  [@-- $coupon ? '\ifthenelse{\equal{\thepage}{1}}{\rule{0pt}{\extracouponspace}}{}' : '' --@]
+\end{minipage}
+\end{document}
diff --git a/conf/quotation_latexnotes b/conf/quotation_latexnotes
new file mode 100644
index 0000000..58fd68a
--- /dev/null
+++ b/conf/quotation_latexnotes
@@ -0,0 +1,8 @@
+%%
+%%	Add any quotation notes in here
+%%
+\section*{\textsc{Notes}}
+\begin{enumerate}
+\item Thank you for your interest in our services.
+\item If you have any questions please email or telephone.
+\end{enumerate}
diff --git a/httemplate/edit/process/quotation.html b/httemplate/edit/process/quotation.html
index 7671c36..a695665 100644
--- a/httemplate/edit/process/quotation.html
+++ b/httemplate/edit/process/quotation.html
@@ -1,6 +1,6 @@
 <% include( 'elements/process.html',
                'table'       => 'quotation',
-               'redirect'    => $p.'view/quotation.html?',
+               'redirect'    => popurl(3).'view/quotation.html?',
            )
 %>
 <%init>
diff --git a/httemplate/edit/quotation.html b/httemplate/edit/quotation.html
index f706425..8b60623 100644
--- a/httemplate/edit/quotation.html
+++ b/httemplate/edit/quotation.html
@@ -3,8 +3,23 @@
                  'table'  => 'quotation',
                  'labels' => { 
                                'quotationnum' => 'Quotation number',
+                               'prospectnum'  => 'Prospect',
+                               'custnum'      => 'Customer',
+                               '_date'        => 'Date',
+                               'disabled'     => 'Disabled',
                              },
+                 'fields' => [
+                   { field=>'prospectnum', type=>'fixed-prospect_main' },
+                   { field=>'custnum',     type=>'fixed-cust_main' },
+                   { field=>'_date',       type=>'fixed-date' },
+                   { field=>'disabled',    type=>'checkbox', value=>'Y'},
+                             ],
                  #XXX some way to disable the "view all"
+                 'new_callback' => sub { my( $cgi, $quotation) = @_;
+                                         $quotation->$_( $cgi->param($_) )
+                                           foreach qw( prospectnum custnum );
+                                         $quotation->_date(time);
+                                       },
            )
 %>
 <%init>
diff --git a/httemplate/elements/tr-fixed-cust_main.html b/httemplate/elements/tr-fixed-cust_main.html
new file mode 100644
index 0000000..00bcb66
--- /dev/null
+++ b/httemplate/elements/tr-fixed-cust_main.html
@@ -0,0 +1,15 @@
+% if ( $cust_main ) {
+  <% include('tr-fixed.html', %opt ) %>
+% }
+<%init>
+
+my %opt = @_;
+
+my $value = $opt{'curr_value'} || $opt{'value'};
+
+my $cust_main = $value ? qsearchs('cust_main', {custnum=>$value} )
+                       : '';
+
+$opt{'formatted_value'} = $cust_main->name if $cust_main;
+
+</%init>
diff --git a/httemplate/elements/tr-fixed-date.html b/httemplate/elements/tr-fixed-date.html
new file mode 100644
index 0000000..716e5ce
--- /dev/null
+++ b/httemplate/elements/tr-fixed-date.html
@@ -0,0 +1,13 @@
+<% include('tr-fixed.html', %opt ) %>
+<%init>
+
+my %opt = @_;
+
+my $value = $opt{'curr_value'} || $opt{'value'};
+
+my $conf = new FS::Conf;
+my $date_format = $conf->config('date_format') || '%m/%d/%Y';
+
+$opt{'formatted_value'} = time2str($date_format, $value);
+
+</%init>
diff --git a/httemplate/elements/tr-fixed-prospect_main.html b/httemplate/elements/tr-fixed-prospect_main.html
new file mode 100644
index 0000000..8da0ffb
--- /dev/null
+++ b/httemplate/elements/tr-fixed-prospect_main.html
@@ -0,0 +1,15 @@
+% if ( $prospect_main ) {
+  <% include('tr-fixed.html', %opt ) %>
+% }
+<%init>
+
+my %opt = @_;
+
+my $value = $opt{'curr_value'} || $opt{'value'};
+
+my $prospect_main = $value ? qsearchs('prospect_main', {prospectnum=>$value} )
+                           : '';
+
+$opt{'formatted_value'} = $prospect_main->name if $prospect_main;
+
+</%init>
diff --git a/httemplate/view/prospect_main.html b/httemplate/view/prospect_main.html
index 9e85348..801d64b 100644
--- a/httemplate/view/prospect_main.html
+++ b/httemplate/view/prospect_main.html
@@ -64,6 +64,31 @@
 
 <BR>
 
+% if ( $curuser->access_right('Generate quotation') ) { 
+  <FONT CLASS="fsinnerbox-title"><% mt( 'Quotations' ) |h %></FONT>
+  <A HREF="<%$p%>edit/quotation.html?prospectnum=<% $prospectnum %>">New quotation</A>
+% my @quotations = $prospect_main->quotation;
+% if ( @quotations ) {
+    <& /elements/table-grid.html &>
+%     my $bgcolor1 = '#eeeeee';
+%     my $bgcolor2 = '#ffffff';
+%     my $bgcolor = '';
+      <TR>
+        <TH CLASS="grid" BGCOLOR="#cccccc">#</TH>
+        <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('Date') |h %></TH>
+      </TR>
+%     foreach my $quotation (@quotations) {
+        <TR>
+          <TD CLASS="grid" BGCOLOR="#cccccc"><% $quotation->quotationnum %></TD>
+          <TD CLASS="grid" BGCOLOR="#cccccc"><% time2str($date_format, $quotation->_date) |h %></TD>
+        </TR>
+%     }
+    </TABLE>
+% }
+    <BR><BR>
+% }
+
+
 % if ( $curuser->access_right('Qualify service') ) { 
 <% include( '/elements/popup_link-prospect_main.html',
               'action'        => $p. 'misc/qual.html',
@@ -80,6 +105,7 @@
     <BR><BR>
 % }
 
+<!--
 <% ntable("#cccccc") %>
 
 <TR>
@@ -87,6 +113,7 @@
 </TR>
 
 </TABLE>
+-->
 
 <%init>
 
@@ -95,6 +122,10 @@ my $curuser = $FS::CurrentUser::CurrentUser;
 die "access denied"
   unless $curuser->access_right('View prospect');
 
+my $conf = new FS::Conf;
+
+my $date_format = $conf->config('date_format') || '%m/%d/%Y';
+
 my $prospectnum;
 if ( $cgi->param('prospectnum') =~ /^(\d+)$/ ) {
   $prospectnum = $1;
diff --git a/httemplate/view/quotation.html b/httemplate/view/quotation.html
index 2c2c6b7..866ade2 100755
--- a/httemplate/view/quotation.html
+++ b/httemplate/view/quotation.html
@@ -22,21 +22,20 @@ XXX resending quotations
 
 % } 
 
-XXX view typset quotation
+</%doc>
 
-% if ( $conf->exists('invoice_latex') ) { 
+% if ( $conf->exists('quotation_latex') ) { 
 
-  <A HREF="<% $p %>view/cust_bill-pdf.cgi?<% $link %>"><% mt('View typeset invoice PDF') |h %></A>
+  <A HREF="<% $p %>view/quotation-pdf.cgi?<% $link %>"><% mt('View typeset quotation PDF') |h %></A>
   <BR><BR>
 % } 
 
-XXX actually show the quotation
-
-% if ( $conf->exists('invoice_html') ) { 
-  <% join('', $cust_bill->print_html(\%opt) ) %>
+% if ( $conf->exists('quotation_html') ) { 
+    <% join('', $quotation->print_html() ) %>
 % } else { 
-  <PRE><% join('', $cust_bill->print_text(\%opt) ) %></PRE>
-% } 
+%   die "quotation_html config missing";
+% }
+% #plaintext quotations? <PRE><% join('', $quotation->print_text() ) %></PRE>
 
 </%doc>
 
@@ -56,7 +55,7 @@ if ( $query =~ /^(\d+)$/ ) {
   $quotationnum = $cgi->param('quotationnum');
 }
 
-#my $conf = new FS::Conf;
+my $conf = new FS::Conf;
 
 my $quotation = qsearchs({
   'select'    => 'quotation.*',
@@ -67,6 +66,7 @@ my $quotation = qsearchs({
 });
 die "Quotation #$quotationnum not found!" unless $quotation;
 
+my $menubar;
 if ( my $custnum = $quotation->custnum ) {
   my $display_custnum = $quotation->cust_main->display_custnum;
   $menubar = menubar(
@@ -78,4 +78,9 @@ if ( my $custnum = $quotation->custnum ) {
   );
 }
 
+my $link = "quotationnum=$quotationnum";
+#$link .= ';template='. uri_escape($template) if $template;
+#$link .= ';notice_name='. $notice_name if $notice_name;
+
+
 </%init>

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/AccessRight.pm                               |    1 +
 FS/FS/Conf.pm                                      |   26 +-
 FS/FS/Schema.pm                                    |   46 +-
 FS/FS/Template_Mixin.pm                            | 2525 ++++++++++++++++++
 FS/FS/Upgrade.pm                                   |    8 +-
 FS/FS/access_right.pm                              |    1 +
 FS/FS/cust_bill.pm                                 | 2731 +-------------------
 FS/FS/cust_main.pm                                 |    2 +-
 FS/FS/prospect_main.pm                             |   17 +-
 FS/FS/quotation.pm                                 |   20 +-
 FS/MANIFEST                                        |    8 +
 conf/{invoice_html => quotation_html}              |   12 +-
 conf/{invoice_latex => quotation_latex}            |    0
 ...e_latexnotes_statement => quotation_latexnotes} |    4 +-
 httemplate/edit/process/quotation.html             |    2 +-
 httemplate/edit/quotation.html                     |   15 +
 httemplate/elements/tr-fixed-cust_main.html        |   15 +
 httemplate/elements/tr-fixed-date.html             |   13 +
 httemplate/elements/tr-fixed-prospect_main.html    |   15 +
 httemplate/view/prospect_main.html                 |   31 +
 httemplate/view/quotation.html                     |   25 +-
 21 files changed, 2890 insertions(+), 2627 deletions(-)
 create mode 100644 FS/FS/Template_Mixin.pm
 copy conf/{invoice_html => quotation_html} (96%)
 copy conf/{invoice_latex => quotation_latex} (100%)
 copy conf/{invoice_latexnotes_statement => quotation_latexnotes} (54%)
 create mode 100644 httemplate/elements/tr-fixed-cust_main.html
 create mode 100644 httemplate/elements/tr-fixed-date.html
 create mode 100644 httemplate/elements/tr-fixed-prospect_main.html




More information about the freeside-commits mailing list