doc
[freeside.git] / FS / FS / cust_bill.pm
index 1016b80..68571f7 100644 (file)
@@ -5,7 +5,6 @@ use vars qw( @ISA $DEBUG $me $conf $money_char );
 use vars qw( $invoice_lines @buf ); #yuck
 use Fcntl qw(:flock); #for spool_csv
 use List::Util qw(min max);
-use IPC::Run3;
 use Date::Format;
 use Text::Template 1.20;
 use File::Temp 0.14;
@@ -13,11 +12,12 @@ use String::ShellQuote;
 use HTML::Entities;
 use Locale::Country;
 use FS::UID qw( datasrc );
-use FS::Misc qw( send_email send_fax );
+use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print );
 use FS::Record qw( qsearch qsearchs dbh );
 use FS::cust_main_Mixin;
 use FS::cust_main;
 use FS::cust_bill_pkg;
+use FS::cust_bill_pkg_display;
 use FS::cust_credit;
 use FS::cust_pay;
 use FS::cust_pkg;
@@ -25,6 +25,7 @@ use FS::cust_credit_bill;
 use FS::pay_batch;
 use FS::cust_pay_batch;
 use FS::cust_bill_event;
+use FS::cust_event;
 use FS::part_pkg;
 use FS::cust_bill_pay;
 use FS::cust_bill_pay_batch;
@@ -226,7 +227,31 @@ Returns the line items (see L<FS::cust_bill_pkg>) for this invoice.
 
 sub cust_bill_pkg {
   my $self = shift;
-  qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum } );
+  qsearch(
+    { 'table'    => 'cust_bill_pkg',
+      'hashref'  => { 'invnum' => $self->invnum },
+      'order_by' => 'ORDER BY billpkgnum',
+    }
+  );
+}
+
+=item cust_bill_pkg_pkgnum PKGNUM
+
+Returns the line items (see L<FS::cust_bill_pkg>) for this invoice and
+specified pkgnum.
+
+=cut
+
+sub cust_bill_pkg_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  qsearch(
+    { 'table'    => 'cust_bill_pkg',
+      'hashref'  => { 'invnum' => $self->invnum,
+                      'pkgnum' => $pkgnum,
+                    },
+      'order_by' => 'ORDER BY billpkgnum',
+    }
+  );
 }
 
 =item cust_pkg
@@ -272,8 +297,7 @@ sub open_cust_bill_pkg {
 
 =item cust_bill_event
 
-Returns the completed invoice events (see L<FS::cust_bill_event>) for this
-invoice.
+Returns the completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
 
 =cut
 
@@ -282,6 +306,54 @@ sub cust_bill_event {
   qsearch( 'cust_bill_event', { 'invnum' => $self->invnum } );
 }
 
+=item num_cust_bill_event
+
+Returns the number of completed invoice events (deprecated, old-style events - see L<FS::cust_bill_event>) for this invoice.
+
+=cut
+
+sub num_cust_bill_event {
+  my $self = shift;
+  my $sql =
+    "SELECT COUNT(*) FROM cust_bill_event WHERE invnum = ?";
+  my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
+  $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
+  $sth->fetchrow_arrayref->[0];
+}
+
+=item cust_event
+
+Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
+
+=cut
+
+#false laziness w/cust_pkg.pm
+sub cust_event {
+  my $self = shift;
+  qsearch({
+    'table'     => 'cust_event',
+    'addl_from' => 'JOIN part_event USING ( eventpart )',
+    'hashref'   => { 'tablenum' => $self->invnum },
+    'extra_sql' => " AND eventtable = 'cust_bill' ",
+  });
+}
+
+=item num_cust_event
+
+Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
+
+=cut
+
+#false laziness w/cust_pkg.pm
+sub num_cust_event {
+  my $self = shift;
+  my $sql =
+    "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
+    "  WHERE tablenum = ? AND eventtable = 'cust_bill'";
+  my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
+  $sth->execute($self->invnum) or die $sth->errstr. " executing $sql";
+  $sth->fetchrow_arrayref->[0];
+}
 
 =item cust_main
 
@@ -379,6 +451,38 @@ sub cust_credited {
   ;
 }
 
+=item cust_bill_pay_pkgnum PKGNUM
+
+Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
+with matching pkgnum.
+
+=cut
+
+sub cust_bill_pay_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum,
+                                'pkgnum' => $pkgnum,
+                              }
+           );
+}
+
+=item cust_credited_pkgnum PKGNUM
+
+Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice
+with matching pkgnum.
+
+=cut
+
+sub cust_credited_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum,
+                                   'pkgnum' => $pkgnum,
+                                 }
+           );
+}
+
 =item tax
 
 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
@@ -412,6 +516,21 @@ sub owed {
   $balance;
 }
 
+sub owed_pkgnum {
+  my( $self, $pkgnum ) = @_;
+
+  #my $balance = $self->charged;
+  my $balance = 0;
+  $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum);
+
+  $balance -= $_->amount            for $self->cust_bill_pay_pkgnum($pkgnum);
+  $balance -= $_->amount            for $self->cust_credited_pkgnum($pkgnum);
+
+  $balance = sprintf( "%.2f", $balance);
+  $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
+  $balance;
+}
+
 =item apply_payments_and_credits
 
 =cut
@@ -419,9 +538,29 @@ sub owed {
 sub apply_payments_and_credits {
   my $self = shift;
 
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  $self->select_for_update; #mutex
+
   my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay;
   my @credits  = grep { $_->credited > 0 } $self->cust_main->cust_credit;
 
+  if ( $conf->exists('pkg-balances') ) {
+    # limit @payments & @credits to those w/ a pkgnum grepped from $self
+    my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg;
+    @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
+    @credits  = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
+  }
+
   while ( $self->owed > 0 and ( @payments || @credits ) ) {
 
     my $app = '';
@@ -432,12 +571,16 @@ sub apply_payments_and_credits {
       my @open_lineitems = $self->open_cust_bill_pkg;
 
       my $max_pay_weight =
-        max( map { $_->cust_pkg->part_pkg->pay_weight || 0 }
-                @open_lineitems
+        max( map  { $_->part_pkg->pay_weight || 0 }
+             grep { $_ }
+             map  { $_->cust_pkg }
+                 @open_lineitems
           );
       my $max_credit_weight =
-        max( map { $_->cust_pkg->part_pkg->credit_weight || 0 }
-                @open_lineitems
+        max( map  { $_->part_pkg->credit_weight || 0 }
+            grep { $_ } 
+             map  { $_->cust_pkg }
+                  @open_lineitems
            );
 
       #if both are the same... payments first?  it has to be something
@@ -455,50 +598,76 @@ sub apply_payments_and_credits {
       die "guru meditation #12 and 35";
     }
 
+    my $unapp_amount;
     if ( $app eq 'pay' ) {
 
       my $payment = shift @payments;
-
-      $app = new FS::cust_bill_pay {
-        'paynum'  => $payment->paynum,
-       'amount'  => sprintf('%.2f', min( $payment->unapplied, $self->owed ) ),
-      };
+      $unapp_amount = $payment->unapplied;
+      $app = new FS::cust_bill_pay { 'paynum'  => $payment->paynum };
+      $app->pkgnum( $payment->pkgnum )
+        if $conf->exists('pkg-balances') && $payment->pkgnum;
 
     } elsif ( $app eq 'credit' ) {
 
       my $credit = shift @credits;
-
-      $app = new FS::cust_credit_bill {
-        'crednum' => $credit->crednum,
-       'amount'  => sprintf('%.2f', min( $credit->credited, $self->owed ) ),
-      };
+      $unapp_amount = $credit->credited;
+      $app = new FS::cust_credit_bill { 'crednum' => $credit->crednum };
+      $app->pkgnum( $credit->pkgnum )
+        if $conf->exists('pkg-balances') && $credit->pkgnum;
 
     } else {
       die "guru meditation #12 and 35";
     }
 
+    my $owed;
+    if ( $conf->exists('pkg-balances') && $app->pkgnum ) {
+      warn "owed_pkgnum ". $app->pkgnum;
+      $owed = $self->owed_pkgnum($app->pkgnum);
+    } else {
+      $owed = $self->owed;
+    }
+    next unless $owed > 0;
+
+    warn "min ( $unapp_amount, $owed )\n";
+    $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) );
+
     $app->invnum( $self->invnum );
 
     my $error = $app->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error inserting ". $app->table. " record: $error";
+    }
     die $error if $error;
 
   }
 
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  ''; #no error
+
 }
 
-=item generate_email PARAMHASH
+=item generate_email OPTION => VALUE ...
 
-PARAMHASH can contain the following:
+Options:
 
 =over 4
 
-=item from       => sender address, required
+=item from
+
+sender address, required
 
-=item tempate    => alternate template name, optional
+=item tempate
 
-=item print_text => text attachment arrayref, optional
+alternate template name, optional
 
-=item subject    => email subject, optional
+=item print_text
+
+text attachment arrayref, optional
+
+=item subject
+
+email subject, optional
 
 =back
 
@@ -520,7 +689,9 @@ sub generate_email {
     'subject'   => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
   );
 
-  if (ref($args{'to'} eq 'ARRAY')) {
+  my %cdrs = ( 'unsquelch_cdr' => $conf->exists('voip-cdr_email') );
+
+  if (ref($args{'to'}) eq 'ARRAY') {
     $return{'to'} = $args{'to'};
   } else {
     $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
@@ -558,7 +729,7 @@ sub generate_email {
       if ( ref($args{'print_text'}) eq 'ARRAY' ) {
         $data = $args{'print_text'};
       } else {
-        $data = [ $self->print_text('', $args{'template'}) ];
+        $data = [ $self->print_text('', $args{'template'}, %cdrs) ];
       }
 
     }
@@ -575,21 +746,22 @@ sub generate_email {
     my $from = $1 || 'example.com';
     my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
 
-    my $path = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
-    my $file;
+    my $logo;
+    my $agentnum = $self->cust_main->agentnum;
     if ( defined($args{'template'}) && length($args{'template'})
-         && -e "$path/logo_". $args{'template'}. ".png"
+         && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
        )
     {
-      $file = "$path/logo_". $args{'template'}. ".png";
+      $logo = 'logo_'. $args{'template'}. '.png';
     } else {
-      $file = "$path/logo.png";
+      $logo = "logo.png";
     }
+    my $image_data = $conf->config_binary( $logo, $agentnum);
 
     my $image = build MIME::Entity
       'Type'       => 'image/png',
       'Encoding'   => 'base64',
-      'Path'       => $file,
+      'Data'       => $image_data,
       'Filename'   => 'logo.png',
       'Content-ID' => "<$content_id>",
     ;
@@ -604,7 +776,11 @@ sub generate_email {
                          '    </title>',
                          '  </head>',
                          '  <body bgcolor="#e8e8e8">',
-                         $self->print_html('', $args{'template'}, $content_id),
+                         $self->print_html({ time          => '',
+                                             template      => $args{'template'},
+                                             cid           => $content_id,
+                                             %cdrs,
+                                          }),
                          '  </body>',
                          '</html>',
                        ],
@@ -612,6 +788,21 @@ sub generate_email {
       #'Filename'    => 'invoice.pdf',
     );
 
+    my @otherparts = ();
+    if ( $self->cust_main->email_csv_cdr ) {
+
+      push @otherparts, build MIME::Entity
+        'Type'        => 'text/csv',
+        'Encoding'    => '7bit',
+        'Data'        => [ map { "$_\n" }
+                             $self->call_details('prepend_billed_number' => 1)
+                         ],
+        'Disposition' => 'attachment',
+        'Filename'    => 'usage-'. $self->invnum. '.csv',
+      ;
+
+    }
+
     if ( $conf->exists('invoice_email_pdf') ) {
 
       #attaching pdf too:
@@ -637,9 +828,9 @@ sub generate_email {
 
       $related->add_part($image);
 
-      my $pdf = build MIME::Entity $self->mimebuild_pdf('', $args{'template'});
+      my $pdf = build MIME::Entity $self->mimebuild_pdf('', $args{'template'}, %cdrs);
 
-      $return{'mimeparts'} = [ $related, $pdf ];
+      $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
 
     } else {
 
@@ -651,7 +842,7 @@ sub generate_email {
       #   image/png
 
       $return{'content-type'} = 'multipart/related';
-      $return{'mimeparts'} = [ $alternative, $image ];
+      $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
       $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
       #$return{'disposition'} = 'inline';
 
@@ -665,7 +856,7 @@ sub generate_email {
 
       #mime parts arguments a la MIME::Entity->build().
       $return{'mimeparts'} = [
-        { $self->mimebuild_pdf('', $args{'template'}) }
+        { $self->mimebuild_pdf('', $args{'template'}, %cdrs) }
       ];
     }
   
@@ -685,7 +876,7 @@ sub generate_email {
       if ( ref($args{'print_text'}) eq 'ARRAY' ) {
         $return{'body'} = $args{'print_text'};
       } else {
-        $return{'body'} = [ $self->print_text('', $args{'template'}) ];
+        $return{'body'} = [ $self->print_text('', $args{'template'}, %cdrs) ];
       }
 
     }
@@ -710,7 +901,7 @@ sub mimebuild_pdf {
     'Encoding'    => 'base64',
     'Data'        => [ $self->print_pdf(@_) ],
     'Disposition' => 'attachment',
-    'Filename'    => 'invoice.pdf',
+    'Filename'    => 'invoice-'. $self->invnum. '.pdf',
   );
 }
 
@@ -727,6 +918,9 @@ single agent) or an arrayref of agentnums.
 
 INVOICE_FROM, if specified, overrides the default email invoice From: address.
 
+AMOUNT, if specified, only sends the invoice if the total amount owed on this
+invoice and all older invoices is greater than the specified amount.
+
 =cut
 
 sub queueable_send {
@@ -735,9 +929,13 @@ sub queueable_send {
   my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
     or die "invalid invoice number: " . $opt{invnum};
 
-  my $error = $self->send($opt{template}, $opt{agentnum}, $opt{invoice_from});
+  my @args = ( $opt{template}, $opt{agentnum} );
+  push @args, $opt{invoice_from}
+    if exists($opt{invoice_from}) && $opt{invoice_from};
 
+  my $error = $self->send( @args );
   die $error if $error;
+
 }
 
 sub send {
@@ -751,17 +949,26 @@ sub send {
   my $invoice_from =
     scalar(@_)
       ? shift
-      : ( $self->_agent_invoice_from || $conf->config('invoice_from') );
+      : ( $self->_agent_invoice_from ||    #XXX should go away
+          $conf->config('invoice_from', $self->cust_main->agentnum )
+        );
+
+  my $balance_over = ( scalar(@_) && $_[0] !~ /^\s*$/ ) ? shift : 0;
+
+  return ''
+    unless $self->cust_main->total_owed_date($self->_date) > $balance_over;
 
   my @invoicing_list = $self->cust_main->invoicing_list;
 
+  #$self->email_invoice($template, $invoice_from)
   $self->email($template, $invoice_from)
     if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list;
 
+  #$self->print_invoice($template)
   $self->print($template)
     if grep { $_ eq 'POST' } @invoicing_list; #postal
 
-  $self->fax($template)
+  $self->fax_invoice($template)
     if grep { $_ eq 'FAX' } @invoicing_list; #fax
 
   '';
@@ -778,13 +985,32 @@ INVOICE_FROM, if specified, overrides the default email invoice From: address.
 
 =cut
 
+sub queueable_email {
+  my %opt = @_;
+
+  my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
+    or die "invalid invoice number: " . $opt{invnum};
+
+  my @args = ( $opt{template} );
+  push @args, $opt{invoice_from}
+    if exists($opt{invoice_from}) && $opt{invoice_from};
+
+  my $error = $self->email( @args );
+  die $error if $error;
+
+}
+
+#sub email_invoice {
 sub email {
   my $self = shift;
   my $template = scalar(@_) ? shift : '';
   my $invoice_from =
     scalar(@_)
       ? shift
-      : ( $self->_agent_invoice_from || $conf->config('invoice_from') );
+      : ( $self->_agent_invoice_from ||    #XXX should go away
+          $conf->config('invoice_from', $self->cust_main->agentnum )
+        );
+
 
   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } 
                             $self->cust_main->invoicing_list;
@@ -792,10 +1018,13 @@ sub email {
   #better to notify this person than silence
   @invoicing_list = ($invoice_from) unless @invoicing_list;
 
+  my $subject = $self->email_subject($template);
+
   my $error = send_email(
     $self->generate_email(
       'from'       => $invoice_from,
       'to'         => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
+      'subject'    => $subject,
       'template'   => $template,
     )
   );
@@ -804,6 +1033,24 @@ sub email {
 
 }
 
+sub email_subject {
+  my $self = shift;
+
+  #my $template = scalar(@_) ? shift : '';
+  #per-template?
+
+  my $subject = $conf->config('invoice_subject', $self->cust_main->agentnum)
+                || 'Invoice';
+
+  my $cust_main = $self->cust_main;
+  my $name = $cust_main->name;
+  my $name_short = $cust_main->name_short;
+  my $invoice_number = $self->invnum;
+  my $invoice_date = $self->_date_pretty;
+
+  eval qq("$subject");
+}
+
 =item lpr_data [ TEMPLATENAME ]
 
 Returns the postscript or plaintext for this invoice as an arrayref.
@@ -827,22 +1074,15 @@ TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
 
 =cut
 
+#sub print_invoice {
 sub print {
   my $self = shift;
   my $template = scalar(@_) ? shift : '';
 
-  my $lpr = $conf->config('lpr');
-
-  my $outerr = '';
-  run3 $lpr, $self->lpr_data($template), \$outerr, \$outerr;
-  if ( $? ) {
-    $outerr = ": $outerr" if length($outerr);
-    die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
-  }
-
+  do_print $self->lpr_data($template);
 }
 
-=item fax [ TEMPLATENAME ] 
+=item fax_invoice [ TEMPLATENAME ] 
 
 Faxes this invoice.
 
@@ -850,7 +1090,7 @@ TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
 
 =cut
 
-sub fax {
+sub fax_invoice {
   my $self = shift;
   my $template = scalar(@_) ? shift : '';
 
@@ -867,6 +1107,46 @@ sub fax {
 
 }
 
+=item ftp_invoice [ TEMPLATENAME ] 
+
+Sends this invoice data via FTP.
+
+TEMPLATENAME is unused?
+
+=cut
+
+sub ftp_invoice {
+  my $self = shift;
+  my $template = scalar(@_) ? shift : '';
+
+  $self->send_csv(
+    'protocol'   => 'ftp',
+    'server'     => $conf->config('cust_bill-ftpserver'),
+    'username'   => $conf->config('cust_bill-ftpusername'),
+    'password'   => $conf->config('cust_bill-ftppassword'),
+    'dir'        => $conf->config('cust_bill-ftpdir'),
+    'format'     => $conf->config('cust_bill-ftpformat'),
+  );
+}
+
+=item spool_invoice [ TEMPLATENAME ] 
+
+Spools this invoice data (see L<FS::spool_csv>)
+
+TEMPLATENAME is unused?
+
+=cut
+
+sub spool_invoice {
+  my $self = shift;
+  my $template = scalar(@_) ? shift : '';
+
+  $self->spool_csv(
+    'format'       => $conf->config('cust_bill-spoolformat'),
+    'agent_spools' => $conf->exists('cust_bill-spoolagent'),
+  );
+}
+
 =item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
 
 Like B<send>, but only sends the invoice if it is the newest open invoice for
@@ -1170,11 +1450,7 @@ sub print_csv {
     my $taxtotal = 0;
     $taxtotal += $_->{'amount'} foreach $self->_items_tax;
 
-    my $duedate = '';
-    if (    $conf->exists('invoice_default_terms') 
-         && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
-      $duedate = time2str("%m/%d/%Y", $self->_date + ($1*86400) );
-    }
+    my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format?
 
     my( $previous_balance, @unused ) = $self->previous; #previous balance
 
@@ -1269,7 +1545,7 @@ sub print_csv {
       if ( $cust_bill_pkg->pkgnum ) {
       
         ($pkg, $setup, $recur, $sdate, $edate) = (
-          $cust_bill_pkg->cust_pkg->part_pkg->pkg,
+          $cust_bill_pkg->part_pkg->pkg,
           ( $cust_bill_pkg->setup != 0
             ? sprintf("%.2f", $cust_bill_pkg->setup )
             : '' ),
@@ -1286,11 +1562,9 @@ sub print_csv {
   
       } else { #pkgnum tax
         next unless $cust_bill_pkg->setup != 0;
-        my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
-                         ? ( $cust_bill_pkg->itemdesc || 'Tax' )
-                         : 'Tax';
-        ($pkg, $setup, $recur, $sdate, $edate) =
-          ( $itemdesc, sprintf("%10.2f",$cust_bill_pkg->setup), '', '', '' );
+        $pkg = $cust_bill_pkg->desc;
+        $setup = sprintf('%10.2f', $cust_bill_pkg->setup );
+        ( $sdate, $edate ) = ( '', '' );
       }
   
       $csv->combine(
@@ -1390,7 +1664,7 @@ sub realtime_bop {
              $cust_main->agentnum. ")";
     my $agent = $agent_obj->agent;
     my $pkgs = join(', ',
-      map { $_->cust_pkg->part_pkg->pkg }
+      map { $_->part_pkg->pkg }
         grep { $_->pkgnum } $self->cust_bill_pkg
     );
     $description = eval qq("$dtempl");
@@ -1415,153 +1689,50 @@ sub batch_card {
   my ($self, %options) = @_;
   my $cust_main = $self->cust_main;
 
-  my $amount = sprintf("%.2f", $cust_main->balance - $cust_main->in_transit_payments);
-  return '' unless $amount > 0;
-  
-  if ($options{'realtime'}) {
-    return $cust_main->realtime_bop( FS::payby->payby2bop($cust_main->payby),
-                                     $amount,
-                                     %options,
-                                   );
-  }
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
-    or return "Cannot lock pay_batch: " . $dbh->errstr;
-
-  my %pay_batch = (
-    'status' => 'O',
-    'payby'  => FS::payby->payby2payment($cust_main->payby),
-  );
-
-  my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
-
-  unless ( $pay_batch ) {
-    $pay_batch = new FS::pay_batch \%pay_batch;
-    my $error = $pay_batch->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      die "error creating new batch: $error\n";
-    }
-  }
-
-  my $old_cust_pay_batch = qsearchs('cust_pay_batch', {
-      'batchnum' => $pay_batch->batchnum,
-      'custnum'  => $cust_main->custnum,
-  } );
-
-  my $cust_pay_batch = new FS::cust_pay_batch ( {
-    'batchnum' => $pay_batch->batchnum,
-    'invnum'   => $self->getfield('invnum'),       # is there a better value?
-                                                   # this field should be
-                                                  # removed...
-                                                  # cust_bill_pay_batch now
-    'custnum'  => $cust_main->custnum,
-    'last'     => $cust_main->getfield('last'),
-    'first'    => $cust_main->getfield('first'),
-    'address1' => $cust_main->address1,
-    'address2' => $cust_main->address2,
-    'city'     => $cust_main->city,
-    'state'    => $cust_main->state,
-    'zip'      => $cust_main->zip,
-    'country'  => $cust_main->country,
-    'payby'    => $cust_main->payby,
-    'payinfo'  => $cust_main->payinfo,
-    'exp'      => $cust_main->paydate,
-    'payname'  => $cust_main->payname,
-    'amount'   => $amount,                          # consolidating
-  } );
+  $options{invnum} = $self->invnum;
   
-  $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum)
-    if $old_cust_pay_batch;
-
-  my $error;
-  if ($old_cust_pay_batch) {
-    $error = $cust_pay_batch->replace($old_cust_pay_batch)
-  } else {
-    $error = $cust_pay_batch->insert;
-  }
-
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    die $error;
-  }
-
-  my $unapplied = $cust_main->total_credited + $cust_main->total_unapplied_payments + $cust_main->in_transit_payments;
-  foreach my $cust_bill ($cust_main->open_cust_bill) {
-    #$dbh->commit or die $dbh->errstr if $oldAutoCommit;
-    my $cust_bill_pay_batch = new FS::cust_bill_pay_batch {
-      'invnum' => $cust_bill->invnum,
-      'paybatchnum' => $cust_pay_batch->paybatchnum,
-      'amount' => $cust_bill->owed,
-      '_date' => time,
-    };
-    if ($unapplied >= $cust_bill_pay_batch->amount){
-      $unapplied -= $cust_bill_pay_batch->amount;
-      next;
-    }else{
-      $cust_bill_pay_batch->amount(sprintf ( "%.2f", 
-                                   $cust_bill_pay_batch->amount - $unapplied ));
-      $unapplied = 0;
-    }
-    $error = $cust_bill_pay_batch->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      die $error;
-    }
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
+  $cust_main->batch_card(%options);
 }
 
 sub _agent_template {
   my $self = shift;
-  $self->_agent_plandata('agent_templatename');
+  $self->cust_main->agent_template;
 }
 
 sub _agent_invoice_from {
   my $self = shift;
-  $self->_agent_plandata('agent_invoice_from');
+  $self->cust_main->agent_invoice_from;
 }
 
-sub _agent_plandata {
-  my( $self, $option ) = @_;
+=item print_text [ TIME [ , TEMPLATE ] ]
 
-  my $part_bill_event = qsearchs( 'part_bill_event',
-    {
-      'payby'     => $self->cust_main->payby,
-      'plan'      => 'send_agent',
-      'plandata'  => { 'op'    => '~',
-                       'value' => "(^|\n)agentnum ".
-                                   '([0-9]*, )*'.
-                                  $self->cust_main->agentnum.
-                                   '(, [0-9]*)*'.
-                                  "(\n|\$)",
-                     },
-    },
-    '',
-    'ORDER BY seconds LIMIT 1'
-  );
+Returns an text invoice, as a list of lines.
 
-  return '' unless $part_bill_event;
+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.
 
-  if ( $part_bill_event->plandata =~ /^$option (.*)$/m ) {
-    return $1;
-  } else {
-    warn "can't parse part_bill_event eventpart#". $part_bill_event->eventpart.
-         " plandata for $option";
-    return '';
-  }
+=cut
 
+sub print_text {
+  my( $self, $today, $template, %opt ) = @_;
+
+  my %params = ( 'format' => 'template' );
+  $params{'time'} = $today if $today;
+  $params{'template'} = $template if $template;
+  $params{'unsquelch_cdr'} = $opt{'unsquelch_cdr'} if $opt{'unsquelch_cdr'};
+
+  $self->print_generic( %params );
 }
 
-=item print_text [ TIME [ , TEMPLATE ] ]
+=item print_latex [ TIME [ , TEMPLATE ] ]
 
-Returns an text invoice, as a list of lines.
+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.
 
 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.
@@ -1570,564 +1741,786 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 
 =cut
 
-#still some false laziness w/_items stuff (and send_csv)
-sub print_text {
+sub print_latex {
+  my( $self, $today, $template, %opt ) = @_;
 
-  my( $self, $today, $template ) = @_;
-  $today ||= time;
+  my %params = ( 'format' => 'latex' );
+  $params{'time'} = $today if $today;
+  $params{'template'} = $template if $template;
+  $params{'unsquelch_cdr'} = $opt{'unsquelch_cdr'} if $opt{'unsquelch_cdr'};
 
-#  my $invnum = $self->invnum;
-  my $cust_main = $self->cust_main;
-  $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
-    unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/;
+  $template ||= $self->_agent_template;
 
-  my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
-#  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
-  #my $balance_due = $self->owed + $pr_total - $cr_total;
-  my $balance_due = $self->owed + $pr_total;
+  my $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 @collect = ();
-  #my($description,$amount);
-  @buf = ();
+  my $agentnum = $self->cust_main->agentnum;
 
-  #previous balance
-  foreach ( @pr_cust_bill ) {
-    push @buf, [
-      "Previous Balance, Invoice #". $_->invnum. 
-                 " (". time2str("%x",$_->_date). ")",
-      $money_char. sprintf("%10.2f",$_->owed)
-    ];
-  }
-  if (@pr_cust_bill) {
-    push @buf,['','-----------'];
-    push @buf,[ 'Total Previous Balance',
-                $money_char. sprintf("%10.2f",$pr_total ) ];
-    push @buf,['',''];
+  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;
 
-  #new charges
-  foreach my $cust_bill_pkg (
-    ( grep {   $_->pkgnum } $self->cust_bill_pkg ),  #packages first
-    ( grep { ! $_->pkgnum } $self->cust_bill_pkg ),  #then taxes
-  ) {
-
-    my $desc = $cust_bill_pkg->desc;
-
-    if ( $cust_bill_pkg->pkgnum > 0 ) {
+  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";
+  print $fh join('', @filled_in );
+  close $fh;
 
-      if ( $cust_bill_pkg->setup != 0 ) {
-        my $description = $desc;
-        $description .= ' Setup' if $cust_bill_pkg->recur != 0;
-        push @buf, [ $description,
-                     $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
-        push @buf,
-          map { [ "  ". $_->[0]. ": ". $_->[1], '' ] }
-              $cust_bill_pkg->cust_pkg->h_labels($self->_date);
-      }
+  $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
+  return ($1, $params{'logo_file'});
 
-      if ( $cust_bill_pkg->recur != 0 ) {
-        push @buf, [
-          "$desc (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
-                      time2str("%x", $cust_bill_pkg->edate) . ")",
-          $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
-        ];
-        push @buf,
-          map { [ "  ". $_->[0]. ": ". $_->[1], '' ] }
-              $cust_bill_pkg->cust_pkg->h_labels( $cust_bill_pkg->edate,
-                                                  $cust_bill_pkg->sdate );
-      }
+}
 
-      push @buf, map { [ "  $_", '' ] } $cust_bill_pkg->details;
+=item print_generic OPTIONS_HASH
 
-    } else { #pkgnum tax or one-shot line item
+Internal method - returns a filled-in template for this invoice as a scalar.
 
-      if ( $cust_bill_pkg->setup != 0 ) {
-        push @buf, [ $desc,
-                     $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
-      }
-      if ( $cust_bill_pkg->recur != 0 ) {
-        push @buf, [ "$desc (". time2str("%x", $cust_bill_pkg->sdate). " - "
-                              . time2str("%x", $cust_bill_pkg->edate). ")",
-                     $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
-                   ];
-      }
+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
 
-  push @buf,['','-----------'];
-  push @buf,['Total New Charges',
-             $money_char. sprintf("%10.2f",$self->charged) ];
-  push @buf,['',''];
+template - a value used as a suffix for a configuration template
 
-  push @buf,['','-----------'];
-  push @buf,['Total Charges',
-             $money_char. sprintf("%10.2f",$self->charged + $pr_total) ];
-  push @buf,['',''];
+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.
 
-  #credits
-  foreach ( $self->cust_credited ) {
+cid - 
 
-    #something more elaborate if $_->amount ne $_->cust_credit->credited ?
+unsquelch_cdr - overrides any per customer cdr squelching when true
 
-    my $reason = substr($_->cust_credit->reason,0,32);
-    $reason .= '...' if length($reason) < length($_->cust_credit->reason);
-    $reason = " ($reason) " if $reason;
-    push @buf,[
-      "Credit #". $_->crednum. " (". time2str("%x",$_->cust_credit->_date) .")".
-        $reason,
-      $money_char. sprintf("%10.2f",$_->amount)
-    ];
-  }
-  #foreach ( @cr_cust_credit ) {
-  #  push @buf,[
-  #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
-  #    $money_char. sprintf("%10.2f",$_->credited)
-  #  ];
-  #}
+=cut
 
-  #get & print payments
-  foreach ( $self->cust_bill_pay ) {
+#what's with all the sprintf('%10.2f')'s in here?  will it cause any
+# (alignment?) problems to change them all to '%.2f' ?
+sub print_generic {
 
-    #something more elaborate if $_->amount ne ->cust_pay->paid ?
+  my( $self, %params ) = @_;
+  my $today = $params{today} ? $params{today} : time;
+  warn "FS::cust_bill::print_generic called on $self with suffix $params{template}\n"
+    if $DEBUG;
 
-    push @buf,[
-      "Payment received ". time2str("%x",$_->cust_pay->_date ),
-      $money_char. sprintf("%10.2f",$_->amount )
-    ];
-  }
+  my $format = $params{format};
+  die "Unknown format: $format"
+    unless $format =~ /^(latex|html|template)$/;
 
-  #balance due
-  my $balance_due_msg = $self->balance_due_msg;
+  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)$/;
 
-  push @buf,['','-----------'];
-  push @buf,[$balance_due_msg, $money_char. 
-    sprintf("%10.2f", $balance_due ) ];
+  my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
+                     'html'     => [ '<%=', '%>' ],
+                     'template' => [ '{', '}' ],
+                   );
 
   #create the template
-  $template ||= $self->_agent_template;
-  my $templatefile = 'invoice_template';
-  $templatefile .= "_$template" if length($template);
-  my @invoice_template = $conf->config($templatefile)
-    or die "cannot load config file $templatefile";
-  $invoice_lines = 0;
-  my $wasfunc = 0;
-  foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
-    /invoice_lines\((\d*)\)/;
-    $invoice_lines += $1 || scalar(@buf);
-    $wasfunc=1;
+  my $template = $params{template} ? $params{template} : $self->_agent_template;
+  my $templatefile = "invoice_$format";
+  $templatefile .= "_$template"
+    if length($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);
+  } 
+
+  my $text_template = new Text::Template(
+    TYPE => 'ARRAY',
+    SOURCE => \@invoice_template,
+    DELIMITERS => $delimiters{$format},
+  );
+
+  $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 "$_", @_ },
+               },
+    '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/~/&nbsp;/g;
+                       $_;
+                     }  @_
+                   },
+                 'footer' =>
+                   sub { map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
+                 'smallfooter' =>
+                   sub { map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
+                 'returnaddress' =>
+                   sub {
+                     map { 
+                       s/~/&nbsp;/g;
+                       s/\\\\\*?\s*$/<BR>/;
+                       s/\\hyphenation\{[\w\s\-]+}//;
+                       s/\\([&])/$1/g;
+                       $_;
+                     }  @_
+                   },
+                 'coupon'        => 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 { "" },
+               },
+  );
+
+
+  # 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'     => \&encode_entities,
+                           'template' => sub { shift },
+                         );
+  my $escape_function = $escape_functions{$format};
+
+  my %date_formats = ( 'latex'    => '%b %o, %Y',
+                       'html'     => '%b&nbsp;%o,&nbsp;%Y',
+                       'template' => '%s',
+                     );
+  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};
+
+
+  # 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),
+                                     )
+                                 )
+                     );
+
+  } 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;
+
+  }
+
+  my %invoice_data = (
+    'company_name'    => scalar( $conf->config('company_name', $self->cust_main->agentnum) ),
+    'company_address' => join("\n", $conf->config('company_address', $self->cust_main->agentnum) ). "\n",
+    'custnum'         => $cust_main->display_custnum,
+    'invnum'          => $self->invnum,
+    'date'            => time2str($date_format, $self->_date),
+    'today'           => time2str('%b %o, %Y', $today),
+    'agent'           => &$escape_function($cust_main->agent->agent),
+    'agent_custid'    => &$escape_function($cust_main->agent_custid),
+    'payname'         => &$escape_function($cust_main->payname),
+    'company'         => &$escape_function($cust_main->company),
+    'address1'        => &$escape_function($cust_main->address1),
+    'address2'        => &$escape_function($cust_main->address2),
+    'city'            => &$escape_function($cust_main->city),
+    'state'           => &$escape_function($cust_main->state),
+    'zip'             => &$escape_function($cust_main->zip),
+    'fax'             => &$escape_function($cust_main->fax),
+    'returnaddress'   => $returnaddress,
+    #'quantity'        => 1,
+    'terms'           => $self->terms,
+    'template'        => $template, #params{'template'},
+    #'notes'           => join("\n", $conf->config('invoice_latexnotes') ),
+    # better hang on to conf_dir for a while
+    'conf_dir'        => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
+    'page'            => 1,
+    'total_pages'     => 1,
+    'current_charges' => sprintf("%.2f", $self->charged),
+    'duedate'         => $self->due_date2str('%m/%d/%Y'), #date_format?
+    'ship_enable'     => $conf->exists('invoice-ship_address'),
+    'unitprices'      => $conf->exists('invoice-unitprice'),
+  );
+
+  my $countrydefault = $conf->config('countrydefault') || 'US';
+  my $prefix = $cust_main->has_ship_address ? 'ship_' : '';
+  foreach ( qw( contact company address1 address2 city state zip country fax) ){
+    my $method = $prefix.$_;
+    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
   }
-  die "no invoice_lines() functions in template?" unless $wasfunc;
-  my $invoice_template = new Text::Template (
-    TYPE   => 'ARRAY',
-    SOURCE => [ map "$_\n", @invoice_template ],
-  ) or die "can't create new Text::Template object: $Text::Template::ERROR";
-  $invoice_template->compile()
-    or die "can't compile template: $Text::Template::ERROR";
-
-  #setup template variables
-  package FS::cust_bill::_template; #!
-  use vars qw( $custnum $invnum $date $agent @address $overdue
-               $page $total_pages @buf );
-
-  $custnum = $self->custnum;
-  $invnum = $self->invnum;
-  $date = $self->_date;
-  $agent = $self->cust_main->agent->agent;
-  $page = 1;
-
-  if ( $FS::cust_bill::invoice_lines ) {
-    $total_pages =
-      int( scalar(@FS::cust_bill::buf) / $FS::cust_bill::invoice_lines );
-    $total_pages++
-      if scalar(@FS::cust_bill::buf) % $FS::cust_bill::invoice_lines;
+  $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 {
-    $total_pages = 1;
+    $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
   }
 
-  #format address (variable for the template)
-  my $l = 0;
-  @address = ( '', '', '', '', '', '' );
-  package FS::cust_bill; #!
-  $FS::cust_bill::_template::address[$l++] =
+  my @address = ();
+  $invoice_data{'address'} = \@address;
+  push @address,
     $cust_main->payname.
       ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
         ? " (P.O. #". $cust_main->payinfo. ")"
         : ''
       )
   ;
-  $FS::cust_bill::_template::address[$l++] = $cust_main->company
+  push @address, $cust_main->company
     if $cust_main->company;
-  $FS::cust_bill::_template::address[$l++] = $cust_main->address1;
-  $FS::cust_bill::_template::address[$l++] = $cust_main->address2
+  push @address, $cust_main->address1;
+  push @address, $cust_main->address2
     if $cust_main->address2;
-  $FS::cust_bill::_template::address[$l++] =
+  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);
 
-  my $countrydefault = $conf->config('countrydefault') || 'US';
-  $FS::cust_bill::_template::address[$l++] = code2country($cust_main->country)
-    unless $cust_main->country eq $countrydefault;
-
-       #  #overdue? (variable for the template)
-       #  $FS::cust_bill::_template::overdue = ( 
-       #    $balance_due > 0
-       #    && $today > $self->_date 
-       ##    && $self->printed > 1
-       #    && $self->printed > 0
-       #  );
-
-  #and subroutine for the template
-  sub FS::cust_bill::_template::invoice_lines {
-    my $lines = shift || scalar(@buf);
-    map { 
-      scalar(@buf) ? shift @buf : [ '', '' ];
-    }
-    ( 1 .. $lines );
-  }
+  $invoice_data{'logo_file'} = $params{'logo_file'}
+    if $params{'logo_file'};
 
-  #and fill it in
-  $FS::cust_bill::_template::page = 1;
-  my $lines;
-  my @collect;
-  while (@buf) {
-    push @collect, split("\n",
-      $invoice_template->fill_in( PACKAGE => 'FS::cust_bill::_template' )
-    );
-    $FS::cust_bill::_template::page++;
-  }
+  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;
+  $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
+  $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
 
-  map "$_\n", @collect;
+  my $agentnum = $self->cust_main->agentnum;
 
-}
+  #do variable substitution in notes, footer, smallfooter
+  foreach my $include (qw( notes footer smallfooter coupon )) {
 
-=item print_latex [ TIME [ , TEMPLATE ] ]
+    my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
+    my @inc_src;
 
-Internal method - returns a filename of a filled-in LaTeX template for this
-invoice (Note: add ".tex" to get the actual filename).
+    if ( $conf->exists($inc_file, $agentnum)
+         && length( $conf->config($inc_file, $agentnum) ) ) {
 
-See print_ps and print_pdf for methods that return PostScript and PDF output.
+      @inc_src = $conf->config($inc_file, $agentnum);
 
-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.
+    } else {
 
-=cut
+      $inc_file = $conf->key_orbase("invoice_latex$include", $template);
 
-#still some false laziness w/print_text and print_html (and send_csv) (mostly print_text should use _items stuff though)
-sub print_latex {
+      my $convert_map = $convert_maps{$format}{$include};
 
-  my( $self, $today, $template ) = @_;
-  $today ||= time;
-  warn "FS::cust_bill::print_latex called on $self with suffix $template\n"
-    if $DEBUG;
+      @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
+                       s/--\@\]/$delimiters{$format}[1]/g;
+                       $_;
+                     } 
+                 &$convert_map( $conf->config($inc_file, $agentnum) );
 
-  my $cust_main = $self->cust_main;
-  $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
-    unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/;
+    }
 
-  my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
-#  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
-  #my $balance_due = $self->owed + $pr_total - $cr_total;
-  my $balance_due = $self->owed + $pr_total;
+    my $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";
 
-  #create the template
-  $template ||= $self->_agent_template;
-  my $templatefile = 'invoice_latex';
-  my $suffix = length($template) ? "_$template" : '';
-  $templatefile .= $suffix;
-  my @invoice_template = map "$_\n", $conf->config($templatefile)
-    or die "cannot load config file $templatefile";
+    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($format, $text_template);
-  if ( 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";
-    $format = 'old';
-  } else {
-    $format = 'Text::Template';
-    $text_template = new Text::Template(
-      TYPE => 'ARRAY',
-      SOURCE => \@invoice_template,
-      DELIMITERS => [ '[@--', '--@]' ],
-    );
+    $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
 
-    $text_template->compile()
-      or die 'While compiling ' . $templatefile . ': ' . $Text::Template::ERROR;
+    $invoice_data{$include} =~ s/\n+$//
+      if ($format eq 'latex');
   }
 
-  my $returnaddress;
-  if ( length($conf->config_orbase('invoice_latexreturnaddress', $template)) ) {
-    $returnaddress = join("\n",
-      $conf->config_orbase('invoice_latexreturnaddress', $template)
-    );
-  } else {
-    $returnaddress = '~';
+  $invoice_data{'po_line'} =
+    (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
+      ? &$escape_function("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};
+
+  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;
+  
+  my $previous_section = { 'description' => 'Previous Charges',
+                           'subtotal'    => $other_money_char.
+                                            sprintf('%.2f', $pr_total),
+                         };
+
+  my $taxtotal = 0;
+  my $tax_section = { 'description' => 'Taxes, Surcharges, and Fees',
+                      'subtotal'    => $taxtotal }; # adjusted below
+
+  my $adjusttotal = 0;
+  my $adjust_section = { 'description' => 'Credits, Payments, and Adjustments',
+                         'subtotal'    => 0 }; # adjusted below
+
+  my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
+  my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum);
+  my $late_sections = [];
+  if ( $multisection ) {
+    push @sections, $self->_items_sections( $late_sections );
+  }else{
+    push @sections, { 'description' => '', 'subtotal' => '' };
   }
 
-  my %invoice_data = (
-    'custnum'      => $self->custnum,
-    'invnum'       => $self->invnum,
-    'date'         => time2str('%b %o, %Y', $self->_date),
-    'today'        => time2str('%b %o, %Y', $today),
-    'agent'        => _latex_escape($cust_main->agent->agent),
-    'payname'      => _latex_escape($cust_main->payname),
-    'company'      => _latex_escape($cust_main->company),
-    'address1'     => _latex_escape($cust_main->address1),
-    'address2'     => _latex_escape($cust_main->address2),
-    'city'         => _latex_escape($cust_main->city),
-    'state'        => _latex_escape($cust_main->state),
-    'zip'          => _latex_escape($cust_main->zip),
-    'footer'       => join("\n", $conf->config_orbase('invoice_latexfooter', $template) ),
-    'smallfooter'  => join("\n", $conf->config_orbase('invoice_latexsmallfooter', $template) ),
-    'returnaddress' => $returnaddress,
-    'quantity'     => 1,
-    'terms'        => $conf->config('invoice_default_terms') || 'Payable upon receipt',
-    #'notes'        => join("\n", $conf->config('invoice_latexnotes') ),
-    'conf_dir'     => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
-  );
+  unless (    $conf->exists('disable_previous_balance')
+           || $conf->exists('previous_balance-summary_only')
+         )
+  {
 
-  my $countrydefault = $conf->config('countrydefault') || 'US';
-  if ( $cust_main->country eq $countrydefault ) {
-    $invoice_data{'country'} = '';
-  } else {
-    $invoice_data{'country'} = _latex_escape(code2country($cust_main->country));
-  }
+    foreach my $line_item ( $self->_items_previous ) {
 
-  $invoice_data{'notes'} =
-    join("\n",
-#  #do variable substitutions in notes
-#      map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-        $conf->config_orbase('invoice_latexnotes', $template)
-    );
-  warn "invoice notes: ". $invoice_data{'notes'}. "\n"
-    if $DEBUG;
+      my $detail = {
+        ext_description => [],
+      };
+      $detail->{'ref'} = $line_item->{'pkgnum'};
+      $detail->{'quantity'} = 1;
+      $detail->{'section'} = $previous_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';
 
-  $invoice_data{'footer'} =~ s/\n+$//;
-  $invoice_data{'smallfooter'} =~ s/\n+$//;
-  $invoice_data{'notes'} =~ s/\n+$//;
+      push @detail_items, $detail;
+      push @buf, [ $detail->{'description'},
+                   $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+                 ];
+    }
 
-  $invoice_data{'po_line'} =
-    (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
-      ? _latex_escape("Purchase Order #". $cust_main->payinfo)
-      : '~';
+  }
 
-  my @filled_in = ();
-  if ( $format eq 'old' ) {
-  
-    my @line_item = ();
-    my @total_item = ();
-    while ( @invoice_template ) {
-      my $line = shift @invoice_template;
-  
-      if ( $line =~ /^%%Detail\s*$/ ) {
-  
-        while ( ( my $line_item_line = shift @invoice_template )
-                !~ /^%%EndDetail\s*$/                            ) {
-          push @line_item, $line_item_line;
-        }
-        foreach my $line_item ( $self->_items ) {
-        #foreach my $line_item ( $self->_items_pkg ) {
-          $invoice_data{'ref'} = $line_item->{'pkgnum'};
-          $invoice_data{'description'} =
-            _latex_escape($line_item->{'description'});
-          if ( exists $line_item->{'ext_description'} ) {
-            $invoice_data{'description'} .=
-              "\\tabularnewline\n~~".
-              join( "\\tabularnewline\n~~",
-                    map _latex_escape($_), @{$line_item->{'ext_description'}}
-                  );
-          }
-          $invoice_data{'amount'} = $line_item->{'amount'};
-          $invoice_data{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
-          push @filled_in,
-            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } @line_item;
-        }
-  
-      } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
-  
-        while ( ( my $total_item_line = shift @invoice_template )
-                !~ /^%%EndTotalDetails\s*$/                      ) {
-          push @total_item, $total_item_line;
-        }
-  
-        my @total_fill = ();
-  
-        my $taxtotal = 0;
-        foreach my $tax ( $self->_items_tax ) {
-          $invoice_data{'total_item'} = _latex_escape($tax->{'description'});
-          $taxtotal += $tax->{'amount'};
-          $invoice_data{'total_amount'} = '\dollar '. $tax->{'amount'};
-          push @total_fill,
-            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-                @total_item;
-        }
+  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) {
+    push @buf, ['','-----------'];
+    push @buf, [ 'Total Previous Balance',
+                 $money_char. sprintf("%10.2f", $pr_total) ];
+    push @buf, ['',''];
+  }
 
-        if ( $taxtotal ) {
-          $invoice_data{'total_item'} = 'Sub-total';
-          $invoice_data{'total_amount'} =
-            '\dollar '. sprintf('%.2f', $self->charged - $taxtotal );
-          unshift @total_fill,
-            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-                @total_item;
-        }
-  
-        $invoice_data{'total_item'} = '\textbf{Total}';
-        $invoice_data{'total_amount'} =
-          '\textbf{\dollar '. sprintf('%.2f', $self->charged + $pr_total ). '}';
-        push @total_fill,
-          map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-              @total_item;
-  
-        #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
-  
-        # credits
-        foreach my $credit ( $self->_items_credits ) {
-          $invoice_data{'total_item'} = _latex_escape($credit->{'description'});
-          #$credittotal
-          $invoice_data{'total_amount'} = '-\dollar '. $credit->{'amount'};
-          push @total_fill, 
-            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-                @total_item;
-        }
-  
-        # payments
-        foreach my $payment ( $self->_items_payments ) {
-          $invoice_data{'total_item'} = _latex_escape($payment->{'description'});
-          #$paymenttotal
-          $invoice_data{'total_amount'} = '-\dollar '. $payment->{'amount'};
-          push @total_fill, 
-            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-                @total_item;
-        }
-  
-        $invoice_data{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
-        $invoice_data{'total_amount'} =
-          '\textbf{\dollar '. sprintf('%.2f', $self->owed + $pr_total ). '}';
-        push @total_fill,
-          map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-              @total_item;
-  
-        push @filled_in, @total_fill;
-  
-      } else {
-        #$line =~ s/\$(\w+)/$invoice_data{$1}/eg;
-        $line =~ s/\$(\w+)/exists($invoice_data{$1}) ? $invoice_data{$1} : nounder($1)/eg;
-        push @filled_in, $line;
-      }
-  
-    }
+  foreach my $section (@sections, @$late_sections) {
 
-    sub nounder {
-      my $var = $1;
-      $var =~ s/_/\-/g;
-      $var;
-    }
+    $section->{'subtotal'} = $other_money_char.
+                             sprintf('%.2f', $section->{'subtotal'})
+      if $multisection;
 
-  } elsif ( $format eq 'Text::Template' ) {
+    if ( $section->{'description'} ) {
+      push @buf, ( [ &$escape_function($section->{'description'}), '' ],
+                   [ '', '' ],
+                 );
+    }
 
-    my @detail_items = ();
-    my @total_items = ();
+    my %options = ();
+    $options{'section'} = $section if $multisection;
+    $options{'format'} = $format;
+    $options{'escape_function'} = $escape_function;
+    $options{'format_function'} = sub { () } unless $unsquelched;
+    $options{'unsquelched'} = $unsquelched;
 
-    $invoice_data{'detail_items'} = \@detail_items;
-    $invoice_data{'total_items'} = \@total_items;
-  
-    foreach my $line_item ( $self->_items ) {
+    foreach my $line_item ( $self->_items_pkg(%options) ) {
       my $detail = {
         ext_description => [],
       };
       $detail->{'ref'} = $line_item->{'pkgnum'};
-      $detail->{'quantity'} = 1;
-      $detail->{'description'} = _latex_escape($line_item->{'description'});
+      $detail->{'quantity'} = $line_item->{'quantity'};
+      $detail->{'section'} = $section;
+      $detail->{'description'} = &$escape_function($line_item->{'description'});
       if ( exists $line_item->{'ext_description'} ) {
-        @{$detail->{'ext_description'}} = map {
-          _latex_escape($_);
-        } @{$line_item->{'ext_description'}};
+        @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
       }
-      $detail->{'amount'} = $line_item->{'amount'};
+      $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';
   
       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',
+                      $money_char. sprintf("%10.2f", $section->{'subtotal'})
+                   ],
+                   [ '', '' ],
+                   [ '', '' ],
+                 );
     }
   
+  }
   
-    my $taxtotal = 0;
-    foreach my $tax ( $self->_items_tax ) {
-      my $total = {};
-      $total->{'total_item'} = _latex_escape($tax->{'description'});
-      $taxtotal += $tax->{'amount'};
-      $total->{'total_amount'} = '\dollar '. $tax->{'amount'};
-      push @total_items, $total;
+  if ( $multisection && !$conf->exists('disable_previous_balance') ) {
+    unshift @sections, $previous_section if $pr_total;
+  }
+
+  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'} = 'Sub-total';
-      $total->{'total_amount'} =
-        '\dollar '. sprintf('%.2f', $self->charged - $taxtotal );
+  if ( $taxtotal ) {
+    my $total = {};
+    $total->{'total_item'} = '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);
   
-    {
-      my $total = {};
-      $total->{'total_item'} = '\textbf{Total}';
-      $total->{'total_amount'} =
-        '\textbf{\dollar '. sprintf('%.2f', $self->charged + $pr_total ). '}';
+  push @buf,['','-----------'];
+  push @buf,[( $conf->exists('disable_previous_balance') 
+               ? 'Total Charges'
+               : 'Total New Charges'
+             ),
+             $money_char. sprintf("%10.2f",$self->charged) ];
+  push @buf,['',''];
+
+  {
+    my $total = {};
+    $total->{'total_item'} = &$embolden_function('Total');
+    $total->{'total_amount'} =
+      &$embolden_function(
+        $other_money_char.
+        sprintf( '%.2f',
+                 $self->charged + ( $conf->exists('disable_previous_balance')
+                                    ? 0
+                                    : $pr_total
+                                  )
+               )
+      );
+    if ( $multisection ) {
+      $adjust_section->{'pretotal'} = 'New charges total '. $other_money_char.
+                                      sprintf('%.2f', $self->charged );
+    }else{
       push @total_items, $total;
     }
+    push @buf,['','-----------'];
+    push @buf,['Total Charges',
+               $money_char.
+               sprintf( '%10.2f', $self->charged +
+                                    ( $conf->exists('disable_previous_balance')
+                                        ? 0
+                                        : $pr_total
+                                    )
+                      )
+              ];
+    push @buf,['',''];
+  }
   
+  unless ( $conf->exists('disable_previous_balance') ) {
     #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
   
     # credits
-    foreach my $credit ( $self->_items_credits ) {
+    my $credittotal = 0;
+    foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
+
       my $total;
-      $total->{'total_item'} = _latex_escape($credit->{'description'});
-      #$credittotal
-      $total->{'total_amount'} = '-\dollar '. $credit->{'amount'};
-      push @total_items, $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'} = _latex_escape($payment->{'description'});
-      #$paymenttotal
-      $total->{'total_amount'} = '-\dollar '. $payment->{'amount'};
-      push @total_items, $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;
+    }
+
     { 
       my $total;
-      $total->{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
+      $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
       $total->{'total_amount'} =
-        '\textbf{\dollar '. sprintf('%.2f', $self->owed + $pr_total ). '}';
-      push @total_items, $total;
+        &$embolden_function(
+          $other_money_char. sprintf('%.2f', $self->owed + $pr_total )
+        );
+      if ( $multisection ) {
+        $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 ) ];
     }
+  }
 
-  } else {
-    die "guru meditation #54";
+  if ( $multisection ) {
+    push @sections, @$late_sections
+      if $unsquelched;
   }
 
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
-  my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
-                           DIR      => $dir,
-                           SUFFIX   => '.tex',
-                           UNLINK   => 0,
-                         ) or die "can't open temp file: $!\n";
-  if ( $format eq 'old' ) {
-    print $fh join('', @filled_in );
-  } elsif ( $format eq 'Text::Template' ) {
-    $text_template->fill_in(OUTPUT => $fh, HASH => \%invoice_data);
-  } else {
-    die "guru meditation #32";
+  $invoice_lines = 0;
+  my $wasfunc = 0;
+  foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
+    /invoice_lines\((\d*)\)/;
+    $invoice_lines += $1 || scalar(@buf);
+    $wasfunc=1;
   }
-  close $fh;
+  die "no invoice_lines() functions in template?"
+    if ( $format eq 'template' && !$wasfunc );
 
-  $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
-  return $1;
+  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
+    sub FS::cust_bill::_template::invoice_lines {
+      my $lines = shift || scalar(@FS::cust_bill::_template::buf);
+      map { 
+        scalar(@FS::cust_bill::_template::buf)
+          ? shift @FS::cust_bill::_template::buf
+          : [ '', '' ];
+      }
+      ( 1 .. $lines );
+    }
+
+    my $lines;
+    my @collect;
+    while (@buf) {
+      push @collect, split("\n",
+        $text_template->fill_in( HASH => \%invoice_data,
+                                 PACKAGE => 'FS::cust_bill::_template'
+                               )
+      );
+      $FS::cust_bill::_template::page++;
+    }
+    map "$_\n", @collect;
+  }else{
+    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);
+  }
 }
 
 =item print_ps [ TIME [ , TEMPLATE ] ]
@@ -2144,35 +2537,11 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 sub print_ps {
   my $self = shift;
 
-  my $file = $self->print_latex(@_);
-
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
-  chdir($dir);
-
-  my $sfile = shell_quote $file;
-
-  system("pslatex $sfile.tex >/dev/null 2>&1") == 0
-    or die "pslatex $file.tex failed; see $file.log for details?\n";
-  system("pslatex $sfile.tex >/dev/null 2>&1") == 0
-    or die "pslatex $file.tex failed; see $file.log for details?\n";
-
-  system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
-    or die "dvips failed";
-
-  open(POSTSCRIPT, "<$file.ps")
-    or die "can't open $file.ps: $! (error in LaTeX template?)\n";
-
-  unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
-
-  my $ps = '';
-  while (<POSTSCRIPT>) {
-    $ps .= $_;
-  }
-
-  close POSTSCRIPT;
-
-  return $ps;
+  my ($file, $lfile) = $self->print_latex(@_);
+  my $ps = generate_ps($file);
+  unlink($lfile);
 
+  $ps;
 }
 
 =item print_pdf [ TIME [ , TEMPLATE ] ]
@@ -2189,254 +2558,41 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 sub print_pdf {
   my $self = shift;
 
-  my $file = $self->print_latex(@_);
-
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
-  chdir($dir);
-
-  #system('pdflatex', "$file.tex");
-  #system('pdflatex', "$file.tex");
-  #! LaTeX Error: Unknown graphics extension: .eps.
-
-  my $sfile = shell_quote $file;
-
-  system("pslatex $sfile.tex >/dev/null 2>&1") == 0
-    or die "pslatex $file.tex failed; see $file.log for details?\n";
-  system("pslatex $sfile.tex >/dev/null 2>&1") == 0
-    or die "pslatex $file.tex failed; see $file.log for details?\n";
-
-  #system('dvipdf', "$file.dvi", "$file.pdf" );
-  system(
-    "dvips -q -t letter -f $sfile.dvi ".
-    "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
-    "     -c save pop -"
-  ) == 0
-    or die "dvips | gs failed: $!";
-
-  open(PDF, "<$file.pdf")
-    or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
-
-  unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
-
-  my $pdf = '';
-  while (<PDF>) {
-    $pdf .= $_;
-  }
-
-  close PDF;
-
-  return $pdf;
+  my ($file, $lfile) = $self->print_latex(@_);
+  my $pdf = generate_pdf($file);
+  unlink($lfile);
 
+  $pdf;
 }
 
-=item print_html [ TIME [ , TEMPLATE [ , CID ] ] ]
-
-Returns an HTML invoice, as a scalar.
-
-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.
-
-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
-
-#some falze laziness w/print_text and print_latex (and send_csv)
-sub print_html {
-  my( $self, $today, $template, $cid ) = @_;
-  $today ||= time;
-
-  my $cust_main = $self->cust_main;
-  $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
-    unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/;
-
-  $template ||= $self->_agent_template;
-  my $templatefile = 'invoice_html';
-  my $suffix = length($template) ? "_$template" : '';
-  $templatefile .= $suffix;
-  my @html_template = map "$_\n", $conf->config($templatefile)
-    or die "cannot load config file $templatefile";
-
-  my $html_template = new Text::Template(
-    TYPE   => 'ARRAY',
-    SOURCE => \@html_template,
-    DELIMITERS => [ '<%=', '%>' ],
-  );
-
-  $html_template->compile()
-    or die 'While compiling ' . $templatefile . ': ' . $Text::Template::ERROR;
-
-  my %invoice_data = (
-    'custnum'      => $self->custnum,
-    'invnum'       => $self->invnum,
-    'date'         => time2str('%b&nbsp;%o,&nbsp;%Y', $self->_date),
-    'today'        => time2str('%b %o, %Y', $today),
-    'agent'        => encode_entities($cust_main->agent->agent),
-    'payname'      => encode_entities($cust_main->payname),
-    'company'      => encode_entities($cust_main->company),
-    'address1'     => encode_entities($cust_main->address1),
-    'address2'     => encode_entities($cust_main->address2),
-    'city'         => encode_entities($cust_main->city),
-    'state'        => encode_entities($cust_main->state),
-    'zip'          => encode_entities($cust_main->zip),
-    'terms'        => $conf->config('invoice_default_terms')
-                      || 'Payable upon receipt',
-    'cid'          => $cid,
-    'template'     => $template,
-#    'conf_dir'     => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
-  );
-
-  if (
-         defined( $conf->config_orbase('invoice_htmlreturnaddress', $template) )
-      && length(  $conf->config_orbase('invoice_htmlreturnaddress', $template) )
-  ) {
-    $invoice_data{'returnaddress'} =
-      join("\n", $conf->config('invoice_htmlreturnaddress', $template) );
-  } else {
-    $invoice_data{'returnaddress'} =
-      join("\n", map { 
-                       s/~/&nbsp;/g;
-                       s/\\\\\*?\s*$/<BR>/;
-                       s/\\hyphenation\{[\w\s\-]+\}//;
-                       $_;
-                     }
-                     $conf->config_orbase( 'invoice_latexreturnaddress',
-                                           $template
-                                         )
-          );
-  }
-
-  my $countrydefault = $conf->config('countrydefault') || 'US';
-  if ( $cust_main->country eq $countrydefault ) {
-    $invoice_data{'country'} = '';
-  } else {
-    $invoice_data{'country'} =
-      encode_entities(code2country($cust_main->country));
-  }
-
-  if (
-         defined( $conf->config_orbase('invoice_htmlnotes', $template) )
-      && length(  $conf->config_orbase('invoice_htmlnotes', $template) )
-  ) {
-    $invoice_data{'notes'} =
-      join("\n", $conf->config_orbase('invoice_htmlnotes', $template) );
-  } else {
-    $invoice_data{'notes'} = 
-      join("\n", map { 
-                       s/%%(.*)$/<!-- $1 -->/;
-                       s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/;
-                       s/\\begin\{enumerate\}/<ol>/;
-                       s/\\item /  <li>/;
-                       s/\\end\{enumerate\}/<\/ol>/;
-                       s/\\textbf\{(.*)\}/<b>$1<\/b>/;
-                       $_;
-                     } 
-                     $conf->config_orbase('invoice_latexnotes', $template)
-          );
-  }
-
-#  #do variable substitutions in notes
-#  $invoice_data{'notes'} =
-#    join("\n",
-#      map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-#        $conf->config_orbase('invoice_latexnotes', $suffix)
-#    );
-
-  if (
-         defined( $conf->config_orbase('invoice_htmlfooter', $template) )
-      && length(  $conf->config_orbase('invoice_htmlfooter', $template) )
-  ) {
-   $invoice_data{'footer'} =
-     join("\n", $conf->config_orbase('invoice_htmlfooter', $template) );
-  } else {
-   $invoice_data{'footer'} =
-       join("\n", map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; }
-                      $conf->config_orbase('invoice_latexfooter', $template)
-           );
-  }
-
-  $invoice_data{'po_line'} =
-    (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
-      ? encode_entities("Purchase Order #". $cust_main->payinfo)
-      : '';
-
-  my $money_char = $conf->config('money_char') || '$';
-
-  foreach my $line_item ( $self->_items ) {
-    my $detail = {
-      ext_description => [],
-    };
-    $detail->{'ref'} = $line_item->{'pkgnum'};
-    $detail->{'description'} = encode_entities($line_item->{'description'});
-    if ( exists $line_item->{'ext_description'} ) {
-      @{$detail->{'ext_description'}} = map {
-        encode_entities($_);
-      } @{$line_item->{'ext_description'}};
-    }
-    $detail->{'amount'} = $money_char. $line_item->{'amount'};
-    $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
-
-    push @{$invoice_data{'detail_items'}}, $detail;
-  }
-
-
-  my $taxtotal = 0;
-  foreach my $tax ( $self->_items_tax ) {
-    my $total = {};
-    $total->{'total_item'} = encode_entities($tax->{'description'});
-    $taxtotal += $tax->{'amount'};
-    $total->{'total_amount'} = $money_char. $tax->{'amount'};
-    push @{$invoice_data{'total_items'}}, $total;
-  }
-
-  if ( $taxtotal ) {
-    my $total = {};
-    $total->{'total_item'} = 'Sub-total';
-    $total->{'total_amount'} =
-      $money_char. sprintf('%.2f', $self->charged - $taxtotal );
-    unshift @{$invoice_data{'total_items'}}, $total;
-  }
-
-  my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
-  {
-    my $total = {};
-    $total->{'total_item'} = '<b>Total</b>';
-    $total->{'total_amount'} =
-      "<b>$money_char".  sprintf('%.2f', $self->charged + $pr_total ). '</b>';
-    push @{$invoice_data{'total_items'}}, $total;
-  }
+=item print_html [ TIME [ , TEMPLATE [ , CID ] ] ]
 
-  #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
+Returns an HTML invoice, as a scalar.
 
-  # credits
-  foreach my $credit ( $self->_items_credits ) {
-    my $total;
-    $total->{'total_item'} = encode_entities($credit->{'description'});
-    #$credittotal
-    $total->{'total_amount'} = "-$money_char". $credit->{'amount'};
-    push @{$invoice_data{'total_items'}}, $total;
-  }
+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.
 
-  # payments
-  foreach my $payment ( $self->_items_payments ) {
-    my $total = {};
-    $total->{'total_item'} = encode_entities($payment->{'description'});
-    #$paymenttotal
-    $total->{'total_amount'} = "-$money_char". $payment->{'amount'};
-    push @{$invoice_data{'total_items'}}, $total;
-  }
+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.
 
-  { 
-    my $total;
-    $total->{'total_item'} = '<b>'. $self->balance_due_msg. '</b>';
-    $total->{'total_amount'} =
-      "<b>$money_char".  sprintf('%.2f', $self->owed + $pr_total ). '</b>';
-    push @{$invoice_data{'total_items'}}, $total;
+=cut
+
+sub print_html {
+  my $self = shift;
+  my %params;
+  if ( ref $_[0]  ) {
+    %params = %{ shift() }; 
+  }else{
+    $params{'time'} = shift;
+    $params{'template'} = shift;
+    $params{'cid'} = shift;
   }
 
-  $html_template->fill_in( HASH => \%invoice_data);
+  $params{'format'} = 'html';
+
+  $self->print_generic( %params );
 }
 
 # quick subroutine for print_latex
@@ -2457,25 +2613,223 @@ sub _latex_escape {
 
 #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, '}',
+                      '--@]';
+
+    } 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;
+
+  #check for an invoice- specific override (eventually)
+  
+  #check for a customer- specific override
+  return $self->cust_main->invoice_terms
+    if $self->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 = 'Balance Due';
-  return $msg unless $conf->exists('invoice_default_terms');
-  if ( $conf->config('invoice_default_terms') =~ /^\s*Net\s*(\d+)\s*$/ ) {
-    $msg .= ' - Please pay by '. time2str("%x", $self->_date + ($1*86400) );
-  } elsif ( $conf->config('invoice_default_terms') ) {
-    $msg .= ' - '. $conf->config('invoice_default_terms');
+  return $msg unless $self->terms;
+  if ( $self->due_date ) {
+    $msg .= ' - Please pay by '. $self->due_date2str('%x');
+  } elsif ( $self->terms ) {
+    $msg .= ' - '. $self->terms;
   }
   $msg;
 }
 
+sub balance_due_date {
+  my $self = shift;
+  my $duedate = '';
+  if (    $conf->exists('invoice_default_terms') 
+       && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
+    $duedate = time2str("%m/%d/%Y", $self->_date + ($1*86400) );
+  }
+  $duedate;
+}
+
+=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;
+  '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('%x', $self->_date);
+}
+
+sub _items_sections {
+  my $self = shift;
+  my $late = shift;
+
+  my %s = ();
+  my %l = ();
+
+  foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
+  {
+
+    if ( $cust_bill_pkg->pkgnum > 0 ) {
+      my $usage = $cust_bill_pkg->usage;
+
+      foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
+        my $desc = $display->section;
+        my $type = $display->type;
+
+        if ( $display->post_total ) {
+          if (! $type || $type eq 'S') {
+            $l{$desc} += $cust_bill_pkg->setup
+              if ( $cust_bill_pkg->setup != 0 );
+          }
+
+          if (! $type) {
+            $l{$desc} += $cust_bill_pkg->recur
+              if ( $cust_bill_pkg->recur != 0 );
+          }
+
+          if ($type && $type eq 'R') {
+            $l{$desc} += $cust_bill_pkg->recur - $usage
+              if ( $cust_bill_pkg->recur != 0 );
+          }
+          
+          if ($type && $type eq 'U') {
+            $l{$desc} += $usage;
+          }
+
+        } else {
+          if (! $type || $type eq 'S') {
+            $s{$desc} += $cust_bill_pkg->setup
+              if ( $cust_bill_pkg->setup != 0 );
+          }
+
+          if (! $type) {
+            $s{$desc} += $cust_bill_pkg->recur
+              if ( $cust_bill_pkg->recur != 0 );
+          }
+
+          if ($type && $type eq 'R') {
+            $s{$desc} += $cust_bill_pkg->recur - $usage
+              if ( $cust_bill_pkg->recur != 0 );
+          }
+          
+          if ($type && $type eq 'U') {
+            $s{$desc} += $usage;
+          }
+
+        }
+
+      }
+
+    }
+
+  }
+
+  push @$late, map { { 'description' => $_,
+                       'subtotal'    => $l{$_},
+                       'post_total'  => 1,
+                   } } sort keys %l;
+
+  map { {'description' => $_, 'subtotal' => $s{$_}} } sort keys %s;
+
+}
+
 sub _items {
   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 = 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(@_);
@@ -2519,80 +2873,220 @@ sub _items_pkg {
   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
 }
 
+sub _taxsort {
+  return 0 unless $a cmp $b;
+  return -1 if $b eq 'Tax';
+  return 1 if $a eq 'Tax';
+  return -1 if $b eq 'Other surcharges';
+  return 1 if $a eq 'Other surcharges';
+  $a cmp $b;
+}
+
 sub _items_tax {
   my $self = shift;
-  my @cust_bill_pkg = grep { ! $_->pkgnum } $self->cust_bill_pkg;
+  my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
 }
 
 sub _items_cust_bill_pkg {
   my $self = shift;
   my $cust_bill_pkg = shift;
+  my %opt = @_;
+
+  my $format = $opt{format} || '';
+  my $escape_function = $opt{escape_function} || sub { shift };
+  my $format_function = $opt{format_function} || '';
+  my $unsquelched = $opt{unsquelched} || '';
+  my $section = $opt{section}->{description} if $opt{section};
 
   my @b = ();
-  foreach my $cust_bill_pkg ( @$cust_bill_pkg ) {
+  my ($s, $r, $u) = ( undef, undef, undef );
+  foreach my $cust_bill_pkg ( @$cust_bill_pkg )
+  {
 
-    my $desc = $cust_bill_pkg->desc;
+    foreach ( $s, $r, $u ) {
+      if ( $_ && !$cust_bill_pkg->hidden ) {
+        $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+        $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+        push @b, { %$_ };
+        $_ = undef;
+      }
+    }
 
-    if ( $cust_bill_pkg->pkgnum > 0 ) {
+    foreach my $display ( grep { defined($section)
+                                 ? $_->section eq $section
+                                 : 1
+                               }
+                          $cust_bill_pkg->cust_bill_pkg_display
+                        )
+    {
 
-      if ( $cust_bill_pkg->setup != 0 ) {
-        my $description = $desc;
-        $description .= ' Setup' if $cust_bill_pkg->recur != 0;
-        my @d = $cust_bill_pkg->cust_pkg->h_labels_short($self->_date);
-        push @d, $cust_bill_pkg->details if $cust_bill_pkg->recur == 0;
-        push @b, {
-          description     => $description,
-          #pkgpart         => $part_pkg->pkgpart,
-          pkgnum          => $cust_bill_pkg->pkgnum,
-          amount          => sprintf("%.2f", $cust_bill_pkg->setup),
-          ext_description => \@d,
-        };
-      }
+      my $type = $display->type;
+
+      my $cust_pkg = $cust_bill_pkg->cust_pkg;
+
+      my $desc = $cust_bill_pkg->desc;
+      $desc = substr($desc, 0, 50). '...'
+        if $format eq 'latex' && length($desc) > 50;
+
+      my %details_opt = ( 'format'          => $format,
+                          'escape_function' => $escape_function,
+                          'format_function' => $format_function,
+                        );
+
+      if ( $cust_bill_pkg->pkgnum > 0 ) {
+
+        if ( $cust_bill_pkg->setup != 0 && (!$type || $type eq 'S') ) {
+
+          my $description = $desc;
+          $description .= ' Setup' if $cust_bill_pkg->recur != 0;
+
+          my @d = ();
+          push @d, map &{$escape_function}($_),
+                       $cust_pkg->h_labels_short($self->_date)
+            unless $cust_pkg->part_pkg->hide_svc_detail
+                || $cust_bill_pkg->hidden;
+          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 = {
+              description     => $description,
+              #pkgpart         => $part_pkg->pkgpart,
+              pkgnum          => $cust_bill_pkg->pkgnum,
+              amount          => $cust_bill_pkg->setup,
+              unit_amount     => $cust_bill_pkg->unitsetup,
+              quantity        => $cust_bill_pkg->quantity,
+              ext_description => \@d,
+            };
+          };
 
-      if ( $cust_bill_pkg->recur != 0 ) {
-        push @b, {
-          description     => "$desc (" .
-                               time2str('%x', $cust_bill_pkg->sdate). ' - '.
-                               time2str('%x', $cust_bill_pkg->edate). ')',
-          #pkgpart         => $part_pkg->pkgpart,
-          pkgnum          => $cust_bill_pkg->pkgnum,
-          amount          => sprintf("%.2f", $cust_bill_pkg->recur),
-          ext_description =>
-            [ $cust_bill_pkg->cust_pkg->h_labels_short( $cust_bill_pkg->edate,
-                                                        $cust_bill_pkg->sdate),
-              $cust_bill_pkg->details,
-            ],
-        };
-      }
+        }
 
-    } else { #pkgnum tax or one-shot line item (??)
+        if ( $cust_bill_pkg->recur != 0 &&
+             ( !$type || $type eq 'R' || $type eq 'U' )
+           )
+        {
+
+          my $is_summary = $display->summary;
+          my $description = $is_summary ? "Usage charges" : $desc;
+
+          unless ( $conf->exists('disable_line_item_date_ranges') ) {
+            $description .= " (" . time2str("%x", $cust_bill_pkg->sdate).
+                            " - ". time2str("%x", $cust_bill_pkg->edate). ")";
+          }
+
+          my @d = ();
+
+          #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 @d, map &{$escape_function}($_),
+                       $cust_pkg->h_labels_short(@dates)
+                                                 #$cust_bill_pkg->edate,
+                                                 #$cust_bill_pkg->sdate)
+            unless $cust_pkg->part_pkg->hide_svc_detail
+                || $cust_bill_pkg->itemdesc
+                || $cust_bill_pkg->hidden
+                || $is_summary;
+
+          push @d, $cust_bill_pkg->details(%details_opt)
+            unless ($is_summary || $type && $type eq 'R');
+  
+          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' ) {
+
+            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,
+                unit_amount     => $cust_bill_pkg->unitrecur,
+                quantity        => $cust_bill_pkg->quantity,
+                ext_description => \@d,
+              };
+            }
+
+          } elsif ( $amount ) {  # && $type eq 'U'
+
+            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,
+                unit_amount     => $cust_bill_pkg->unitrecur,
+                quantity        => $cust_bill_pkg->quantity,
+                ext_description => \@d,
+              };
+            }
+
+          }
+
+        } # recurring or usage with recurring charge
+
+      } else { #pkgnum tax or one-shot line item (??)
+
+        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("%x", $cust_bill_pkg->sdate). ' - '.
+                             time2str("%x", $cust_bill_pkg->edate). ')',
+            'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
+          };
+        }
 
-      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("%x", $cust_bill_pkg->sdate). ' - '.
-                           time2str("%x", $cust_bill_pkg->edate). ')',
-          'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
-        };
       }
 
     }
 
   }
 
+  foreach ( $s, $r, $u ) {
+    if ( $_ ) {
+      $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+      $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+      push @b, { %$_ };
+    }
+  }
+
   @b;
 
 }
 
 sub _items_credits {
-  my $self = shift;
+  my( $self, %opt ) = @_;
+  my $trim_len = $opt{'trim_len'} || 60;
 
   my @b;
   #credits
@@ -2600,10 +3094,10 @@ sub _items_credits {
 
     #something more elaborate if $_->amount ne $_->cust_credit->credited ?
 
-    my $reason = $_->cust_credit->reason;
-    #my $reason = substr($_->cust_credit->reason,0,32);
-    #$reason .= '...' if length($reason) < length($_->cust_credit->reason);
+    my $reason = substr($_->cust_credit->reason, 0, $trim_len);
+    $reason .= '...' if length($reason) < length($_->cust_credit->reason);
     $reason = " ($reason) " if $reason;
+
     push @b, {
       #'description' => 'Credit ref\#'. $_->crednum.
       #                 " (". time2str("%x",$_->cust_credit->_date) .")".
@@ -2613,12 +3107,6 @@ sub _items_credits {
       'amount'      => sprintf("%.2f",$_->amount),
     };
   }
-  #foreach ( @cr_cust_credit ) {
-  #  push @buf,[
-  #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
-  #    $money_char. sprintf("%10.2f",$_->credited)
-  #  ];
-  #}
 
   @b;
 
@@ -2644,6 +3132,38 @@ sub _items_payments {
 
 }
 
+=item call_details [ OPTION => VALUE ... ]
+
+Returns an array of CSV strings representing the call details for this invoice
+The only option available is the boolean prepend_billed_number
+
+=cut
+
+sub call_details {
+  my ($self, %opt) = @_;
+
+  my $format_function = sub { shift };
+
+  if ($opt{prepend_billed_number}) {
+    $format_function = sub {
+      my $detail = shift;
+      my $row = shift;
+
+      $row->amount ? $row->phonenum. ",". $detail : '"Billed number",'. $detail;
+      
+    };
+  }
+
+  my @details = map { $_->details( 'format_function' => $format_function,
+                                   'escape_function' => sub{ return() },
+                                 )
+                    }
+                  grep { $_->pkgnum }
+                  $self->cust_bill_pkg;
+  my $header = $details[0];
+  ( $header, grep { $_ ne $header } @details );
+}
+
 
 =back
 
@@ -2651,7 +3171,7 @@ sub _items_payments {
 
 =over 4
 
-=item reprint
+=item process_reprint
 
 =cut
 
@@ -2659,7 +3179,7 @@ sub process_reprint {
   process_re_X('print', @_);
 }
 
-=item reemail
+=item process_reemail
 
 =cut
 
@@ -2667,7 +3187,7 @@ sub process_reemail {
   process_re_X('email', @_);
 }
 
-=item refax
+=item process_refax
 
 =cut
 
@@ -2675,12 +3195,28 @@ sub process_refax {
   process_re_X('fax', @_);
 }
 
+=item process_reftp
+
+=cut
+
+sub process_reftp {
+  process_re_X('ftp', @_);
+}
+
+=item respool
+
+=cut
+
+sub process_respool {
+  process_re_X('spool', @_);
+}
+
 use Storable qw(thaw);
 use Data::Dumper;
 use MIME::Base64;
 sub process_re_X {
   my( $method, $job ) = ( shift, shift );
-  warn "process_re_X $method for job $job\n" if $DEBUG;
+  warn "$me process_re_X $method for job $job\n" if $DEBUG;
 
   my $param = thaw(decode_base64(shift));
   warn Dumper($param) if $DEBUG;
@@ -2695,7 +3231,6 @@ sub process_re_X {
 
 sub re_X {
   my($method, $job, %param ) = @_;
-#              [ 'begin', 'end', 'agentnum', 'open', 'days', 'newest_percust' ],
   if ( $DEBUG ) {
     warn "re_X $method for job $job with param:\n".
          join( '', map { "  $_ => ". $param{$_}. "\n" } keys %param );
@@ -2705,47 +3240,24 @@ sub re_X {
   my $distinct = '';
   my $orderby = 'ORDER BY cust_bill._date';
 
-  my @where;
-
-  if ( $param{'begin'} =~ /^(\d+)$/ ) {
-    push @where, "cust_bill._date >= $1";
-  }
-  if ( $param{'end'} =~ /^(\d+)$/ ) {
-    push @where, "cust_bill._date < $1";
-  }
-  if ( $param{'agentnum'} =~ /^(\d+)$/ ) {
-    push @where, "cust_main.agentnum = $1";
-  }
-
-  my $owed =
-    "charged - ( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
-                 WHERE cust_bill_pay.invnum = cust_bill.invnum )
-             - ( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
-                 WHERE cust_credit_bill.invnum = cust_bill.invnum )";
-
-  push @where, "0 != $owed"
-    if $param{'open'};
+  my $extra_sql = ' WHERE '. FS::cust_bill->search_sql(\%param);
 
-  push @where, "cust_bill._date < ". (time-86400*$param{'days'})
-    if $param{'days'};
-
-  my $extra_sql = scalar(@where) ? 'WHERE '. join(' AND ', @where) : '';
+  my $addl_from = 'LEFT JOIN cust_main USING ( custnum )';
+     
+  my @cust_bill = qsearch( {
+    #'select'    => "cust_bill.*",
+    'table'     => 'cust_bill',
+    'addl_from' => $addl_from,
+    'hashref'   => {},
+    'extra_sql' => $extra_sql,
+    'order_by'  => $orderby,
+    'debug' => 1,
+  } );
 
-  my $addl_from = 'left join cust_main using ( custnum )';
+  $method .= '_invoice' unless $method eq 'email' || $method eq 'print';
 
-  if ( $param{'newest_percust'} ) {
-    $distinct = 'DISTINCT ON ( cust_bill.custnum )';
-    $orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
-    #$count_query = "SELECT COUNT(DISTINCT cust_bill.custnum), 'N/A', 'N/A'";
-  }
-     
-  my @cust_bill = qsearch( 'cust_bill',
-                           {},
-                           "$distinct cust_bill.*",
-                           $extra_sql,
-                           '',
-                           $addl_from
-                         );
+  warn " $me re_X $method: ". scalar(@cust_bill). " invoices found\n"
+    if $DEBUG;
 
   my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
   foreach my $cust_bill ( @cust_bill ) {
@@ -2768,13 +3280,175 @@ sub re_X {
 
 =back
 
+=head1 CLASS METHODS
+
+=over 4
+
+=item owed_sql
+
+Returns an SQL fragment to retreive the amount owed (charged minus credited and paid).
+
+=cut
+
+sub owed_sql {
+  my $class = shift;
+  'charged - '. $class->paid_sql. ' - '. $class->credited_sql;
+}
+
+=item net_sql
+
+Returns an SQL fragment to retreive the net amount (charged minus credited).
+
+=cut
+
+sub net_sql {
+  my $class = shift;
+  'charged - '. $class->credited_sql;
+}
+
+=item paid_sql
+
+Returns an SQL fragment to retreive the amount paid against this invoice.
+
+=cut
+
+sub paid_sql {
+  #my $class = shift;
+  "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
+       WHERE cust_bill.invnum = cust_bill_pay.invnum   )";
+}
+
+=item credited_sql
+
+Returns an SQL fragment to retreive the amount credited against this invoice.
+
+=cut
+
+sub credited_sql {
+  #my $class = shift;
+  "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
+       WHERE cust_bill.invnum = cust_credit_bill.invnum   )";
+}
+
+=item search_sql HASHREF
+
+Class method which returns an SQL WHERE fragment to search for parameters
+specified in HASHREF.  Valid parameters are
+
+=over 4
+
+=item begin
+
+Epoch date (UNIX timestamp) setting a lower bound for _date values
+
+=item end
+
+Epoch date (UNIX timestamp) setting an upper bound for _date values
+
+=item invnum_min
+
+=item invnum_max
+
+=item agentnum
+
+=item owed
+
+=item net
+
+=item days
+
+=item newest_percust
+
+=back
+
+Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
+
+=cut
+
+sub search_sql {
+  my($class, $param) = @_;
+  if ( $DEBUG ) {
+    warn "$me search_sql called with params: \n".
+         join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
+  }
+
+  my @search = ();
+
+  if ( $param->{'begin'} =~ /^(\d+)$/ ) {
+    push @search, "cust_bill._date >= $1";
+  }
+  if ( $param->{'end'} =~ /^(\d+)$/ ) {
+    push @search, "cust_bill._date < $1";
+  }
+  if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) {
+    push @search, "cust_bill.invnum >= $1";
+  }
+  if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) {
+    push @search, "cust_bill.invnum <= $1";
+  }
+  if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
+    push @search, "cust_main.agentnum = $1";
+  }
+
+  push @search, '0 != '. FS::cust_bill->owed_sql
+    if $param->{'open'};
+
+  push @search, '0 != '. FS::cust_bill->net_sql
+    if $param->{'net'};
+
+  push @search, "cust_bill._date < ". (time-86400*$param->{'days'})
+    if $param->{'days'};
+
+  if ( $param->{'newest_percust'} ) {
+
+    #$distinct = 'DISTINCT ON ( cust_bill.custnum )';
+    #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
+
+    my @newest_where = map { my $x = $_;
+                             $x =~ s/\bcust_bill\./newest_cust_bill./g;
+                             $x;
+                           }
+                           grep ! /^cust_main./, @search;
+    my $newest_where = scalar(@newest_where)
+                         ? ' AND '. join(' AND ', @newest_where)
+                        : '';
+
+
+    push @search, "cust_bill._date = (
+      SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill
+        WHERE newest_cust_bill.custnum = cust_bill.custnum
+          $newest_where
+    )";
+
+  }
+
+  my $curuser = $FS::CurrentUser::CurrentUser;
+  if ( $curuser->username eq 'fs_queue'
+       && $param->{'CurrentUser'} =~ /^(\w+)$/ ) {
+    my $username = $1;
+    my $newuser = qsearchs('access_user', {
+      'username' => $username,
+      'disabled' => '',
+    } );
+    if ( $newuser ) {
+      $curuser = $newuser;
+    } else {
+      warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n";
+    }
+  }
+
+  push @search, $curuser->agentnums_sql;
+
+  join(' AND ', @search );
+
+}
+
+=back
+
 =head1 BUGS
 
 The delete method.
 
-print_text formatting (and some logic :/) is in source, but needs to be
-slurped in from a file.  Also number of lines ($=).
-
 =head1 SEE ALSO
 
 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,