this should fix credits pushing typeset invoices off the right
[freeside.git] / FS / FS / cust_bill.pm
index a01880c..408da99 100644 (file)
@@ -1,25 +1,41 @@
 package FS::cust_bill;
 
 use strict;
-use vars qw( @ISA $conf $money_char );
+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 Date::Format;
 use Text::Template 1.20;
 use File::Temp 0.14;
 use String::ShellQuote;
+use HTML::Entities;
+use Locale::Country;
 use FS::UID qw( datasrc );
-use FS::Record qw( qsearch qsearchs );
-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;
 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;
+use FS::part_bill_event;
+use FS::payby;
 
-@ISA = qw( FS::Record );
+@ISA = qw( FS::cust_main_Mixin FS::Record );
+
+$DEBUG = 0;
+$me = '[FS::cust_bill]';
 
 #ask FS::UID to run this stuff for us later
 FS::UID->install_callback( sub { 
@@ -97,6 +113,13 @@ Invoices are normally created by calling the bill method of a customer object
 
 sub table { 'cust_bill'; }
 
+sub cust_linked { $_[0]->cust_main_custnum; } 
+sub cust_unlinked_msg {
+  my $self = shift;
+  "WARNING: can't find cust_main.custnum ". $self->custnum.
+  ' (cust_bill.invnum '. $self->invnum. ')';
+}
+
 =item insert
 
 Adds this invoice to the database ("Posts" the invoice).  If there is an error,
@@ -104,8 +127,14 @@ returns the error, otherwise returns false.
 
 =item delete
 
-Currently unimplemented.  I don't remove invoices because there would then be
-no record you ever posted this invoice (which is bad, no?)
+This method now works but you probably shouldn't use it.  Instead, apply a
+credit against the invoice.
+
+Using this method to delete invoices outright is really, really bad.  There
+would be no record you ever posted this invoice, and there are no check to
+make sure charged = 0 or that there are no associated cust_bill_pkg records.
+
+Really, don't use it.
 
 =cut
 
@@ -125,14 +154,20 @@ collect method of a customer object (see L<FS::cust_main>).
 
 =cut
 
-sub replace {
+#replace can be inherited from Record.pm
+
+# replace_check is now the preferred way to #implement replace data checks
+# (so $object->replace() works without an argument)
+
+sub replace_check {
   my( $new, $old ) = ( shift, shift );
   return "Can't change custnum!" unless $old->custnum == $new->custnum;
   #return "Can't change _date!" unless $old->_date eq $new->_date;
   return "Can't change _date!" unless $old->_date == $new->_date;
-  return "Can't change charged!" unless $old->charged == $new->charged;
+  return "Can't change charged!" unless $old->charged == $new->charged
+                                     || $old->charged == 0;
 
-  $new->SUPER::replace($old);
+  '';
 }
 
 =item check
@@ -192,13 +227,58 @@ 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_pkg
+
+Returns the packages (see L<FS::cust_pkg>) corresponding to the line items for
+this invoice.
+
+=cut
+
+sub cust_pkg {
+  my $self = shift;
+  my @cust_pkg = map { $_->cust_pkg } $self->cust_bill_pkg;
+  my %saw = ();
+  grep { ! $saw{$_->pkgnum}++ } @cust_pkg;
+}
+
+=item open_cust_bill_pkg
+
+Returns the open line items for this invoice.
+
+Note that cust_bill_pkg with both setup and recur fees are returned as two
+separate line items, each with only one fee.
+
+=cut
+
+# modeled after cust_main::open_cust_bill
+sub open_cust_bill_pkg {
+  my $self = shift;
+
+  # grep { $_->owed > 0 } $self->cust_bill_pkg
+
+  my %other = ( 'recur' => 'setup',
+                'setup' => 'recur', );
+  my @open = ();
+  foreach my $field ( qw( recur setup )) {
+    push @open, map  { $_->set( $other{$field}, 0 ); $_; }
+                grep { $_->owed($field) > 0 }
+                $self->cust_bill_pkg;
+  }
+
+  @open;
 }
 
 =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
 
@@ -207,6 +287,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
 
@@ -219,6 +347,25 @@ sub cust_main {
   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
 }
 
+=item cust_suspend_if_balance_over AMOUNT
+
+Suspends the customer associated with this invoice if the total amount owed on
+this invoice and all older invoices is greater than the specified amount.
+
+Returns a list: an empty list on success or a list of errors.
+
+=cut
+
+sub cust_suspend_if_balance_over {
+  my( $self, $amount ) = ( shift, shift );
+  my $cust_main = $self->cust_main;
+  if ( $cust_main->total_owed_date($self->_date) < $amount ) {
+    return ();
+  } else {
+    $cust_main->suspend(@_);
+  }
+}
+
 =item cust_credit
 
 Depreciated.  See the cust_credited method.
@@ -318,20 +465,125 @@ sub owed {
   $balance;
 }
 
+=item apply_payments_and_credits
+
+=cut
+
+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;
+
+  while ( $self->owed > 0 and ( @payments || @credits ) ) {
+
+    my $app = '';
+    if ( @payments && @credits ) {
+
+      #decide which goes first by weight of top (unapplied) line item
+
+      my @open_lineitems = $self->open_cust_bill_pkg;
+
+      my $max_pay_weight =
+        max( map  { $_->part_pkg->pay_weight || 0 }
+             grep { $_ }
+             map  { $_->cust_pkg }
+                 @open_lineitems
+          );
+      my $max_credit_weight =
+        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
+      if ( $max_pay_weight >= $max_credit_weight ) {
+        $app = 'pay';
+      } else {
+        $app = 'credit';
+      }
+    
+    } elsif ( @payments ) {
+      $app = 'pay';
+    } elsif ( @credits ) {
+      $app = 'credit';
+    } else {
+      die "guru meditation #12 and 35";
+    }
+
+    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 ) ),
+      };
+
+    } 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 ) ),
+      };
+
+    } else {
+      die "guru meditation #12 and 35";
+    }
+
+    $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
 
-=item tempate    => alternate template name, optional
+alternate template name, optional
 
-=item print_text => text attachment arrayref, optional
+=item print_text
 
-=item subject    => email subject, optional
+text attachment arrayref, optional
+
+=item subject
+
+email subject, optional
 
 =back
 
@@ -339,303 +591,520 @@ Returns an argument list to be passed to L<FS::Misc::send_email>.
 
 =cut
 
+use MIME::Entity;
+
 sub generate_email {
 
   my $self = shift;
   my %args = @_;
 
-  my $mimeparts;
-  if ($conf->exists('invoice_email_pdf')) {
-    #warn "[FS::cust_bill::send] creating PDF attachment";
-    #mime parts arguments a la MIME::Entity->build().
-    $mimeparts = [
-      {
-        'Type'        => 'application/pdf',
-        'Encoding'    => 'base64',
-        'Data'        => [ $self->print_pdf('', $args{'template'}) ],
-        'Disposition' => 'attachment',
-        'Filename'    => 'invoice.pdf',
-      },
-    ];
-  }
+  my $me = '[FS::cust_bill::generate_email]';
 
-  my $email_text;
-  if ($conf->exists('invoice_email_pdf')
-      and scalar($conf->config('invoice_email_pdf_note'))) {
+  my %return = (
+    'from'      => $args{'from'},
+    'subject'   => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
+  );
 
-    #warn "[FS::cust_bill::send] using 'invoice_email_pdf_note'";
-    $email_text = [ map { $_ . "\n" } $conf->config('invoice_email_pdf_note') ];
+  if (ref($args{'to'}) eq 'ARRAY') {
+    $return{'to'} = $args{'to'};
   } else {
-    #warn "[FS::cust_bill::send] not using 'invoice_email_pdf_note'";
-    if (ref($args{'print_text'}) eq 'ARRAY') {
-      $email_text = $args{'print_text'};
-    } else {
-      $email_text = [ $self->print_text('', $args{'template'}) ];
-    }
+    $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
+                           $self->cust_main->invoicing_list
+                    ];
   }
 
-  my @invoicing_list;
-  if (ref($args{'to'} eq 'ARRAY')) {
-    @invoicing_list = @{$args{'to'}};
-  } else {
-    @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
-  }
+  if ( $conf->exists('invoice_html') ) {
 
-  return (
-    'from'      => $args{'from'},
-    'to'        => [ @invoicing_list ],
-    'subject'   => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
-    'body'      => $email_text,
-    'mimeparts' => $mimeparts,
-  );
+    warn "$me creating HTML/text multipart message"
+      if $DEBUG;
 
+    $return{'nobody'} = 1;
 
-}
+    my $alternative = build MIME::Entity
+      'Type'        => 'multipart/alternative',
+      'Encoding'    => '7bit',
+      'Disposition' => 'inline'
+    ;
 
-=item send [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
+    my $data;
+    if ( $conf->exists('invoice_email_pdf')
+         and scalar($conf->config('invoice_email_pdf_note')) ) {
 
-Sends this invoice to the destinations configured for this customer: send
-emails or print.  See L<FS::cust_main_invoice>.
+      warn "$me using 'invoice_email_pdf_note' in multipart message"
+        if $DEBUG;
+      $data = [ map { $_ . "\n" }
+                    $conf->config('invoice_email_pdf_note')
+              ];
 
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+    } else {
 
-AGENTNUM, if specified, means that this invoice will only be sent for customers
-of the specified agent.
+      warn "$me not using 'invoice_email_pdf_note' in multipart message"
+        if $DEBUG;
+      if ( ref($args{'print_text'}) eq 'ARRAY' ) {
+        $data = $args{'print_text'};
+      } else {
+        $data = [ $self->print_text('', $args{'template'}) ];
+      }
 
-INVOICE_FROM, if specified, overrides the default email invoice From: address.
+    }
 
-=cut
+    $alternative->attach(
+      'Type'        => 'text/plain',
+      #'Encoding'    => 'quoted-printable',
+      'Encoding'    => '7bit',
+      'Data'        => $data,
+      'Disposition' => 'inline',
+    );
 
-sub send {
-  my $self = shift;
-  my $template = scalar(@_) ? shift : '';
-  return 'N/A' if scalar(@_) && $_[0] && $self->cust_main->agentnum != shift;
-  my $invoice_from =
-    scalar(@_)
-      ? shift
-      : ( $self->_agent_invoice_from || $conf->config('invoice_from') );
+    $args{'from'} =~ /\@([\w\.\-]+)/;
+    my $from = $1 || 'example.com';
+    my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
 
-  my @print_text = $self->print_text('', $template);
-  my @invoicing_list = $self->cust_main->invoicing_list;
+    my $logo;
+    my $agentnum = $self->cust_main->agentnum;
+    if ( defined($args{'template'}) && length($args{'template'})
+         && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
+       )
+    {
+      $logo = 'logo_'. $args{'template'}. '.png';
+    } else {
+      $logo = "logo.png";
+    }
+    my $image_data = $conf->config_binary( $logo, $agentnum);
+
+    my $image = build MIME::Entity
+      'Type'       => 'image/png',
+      'Encoding'   => 'base64',
+      'Data'       => $image_data,
+      'Filename'   => 'logo.png',
+      'Content-ID' => "<$content_id>",
+    ;
+
+    $alternative->attach(
+      'Type'        => 'text/html',
+      'Encoding'    => 'quoted-printable',
+      'Data'        => [ '<html>',
+                         '  <head>',
+                         '    <title>',
+                         '      '. encode_entities($return{'subject'}), 
+                         '    </title>',
+                         '  </head>',
+                         '  <body bgcolor="#e8e8e8">',
+                         $self->print_html('', $args{'template'}, $content_id),
+                         '  </body>',
+                         '</html>',
+                       ],
+      'Disposition' => 'inline',
+      #'Filename'    => 'invoice.pdf',
+    );
 
-  if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list  ) {
-    #email
+    if ( $conf->exists('invoice_email_pdf') ) {
+
+      #attaching pdf too:
+      # multipart/mixed
+      #   multipart/related
+      #     multipart/alternative
+      #       text/plain
+      #       text/html
+      #     image/png
+      #   application/pdf
+
+      my $related = build MIME::Entity 'Type'     => 'multipart/related',
+                                       'Encoding' => '7bit';
+
+      #false laziness w/Misc::send_email
+      $related->head->replace('Content-type',
+        $related->mime_type.
+        '; boundary="'. $related->head->multipart_boundary. '"'.
+        '; type=multipart/alternative'
+      );
 
-    #better to notify this person than silence
-    @invoicing_list = ($invoice_from) unless @invoicing_list;
+      $related->add_part($alternative);
 
-    my $error = send_email(
-      $self->generate_email(
-        'from'       => $invoice_from,
-        'to'         => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
-        'print_text' => [ @print_text ],
-      )
-    );
-    die "can't email invoice: $error\n" if $error;
-    #die "$error\n" if $error;
+      $related->add_part($image);
 
-  }
+      my $pdf = build MIME::Entity $self->mimebuild_pdf('', $args{'template'});
+
+      $return{'mimeparts'} = [ $related, $pdf ];
 
-  if ( grep { $_ =~ /^(POST|FAX)$/ } @invoicing_list ) {
-    my $lpr_data;
-    if ($conf->config('invoice_latex')) {
-      $lpr_data = [ $self->print_ps('', $template) ];
     } else {
-      $lpr_data = \@print_text;
+
+      #no other attachment:
+      # multipart/related
+      #   multipart/alternative
+      #     text/plain
+      #     text/html
+      #   image/png
+
+      $return{'content-type'} = 'multipart/related';
+      $return{'mimeparts'} = [ $alternative, $image ];
+      $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
+      #$return{'disposition'} = 'inline';
+
     }
+  
+  } else {
 
-    if ( grep { $_ eq 'POST' } @invoicing_list ) { #postal
-      my $lpr = $conf->config('lpr');
-      open(LPR, "|$lpr")
-        or die "Can't open pipe to $lpr: $!\n";
-      print LPR @{$lpr_data};
-      close LPR
-        or die $! ? "Error closing $lpr: $!\n"
-                  : "Exit status $? from $lpr\n";
+    if ( $conf->exists('invoice_email_pdf') ) {
+      warn "$me creating PDF attachment"
+        if $DEBUG;
+
+      #mime parts arguments a la MIME::Entity->build().
+      $return{'mimeparts'} = [
+        { $self->mimebuild_pdf('', $args{'template'}) }
+      ];
     }
+  
+    if ( $conf->exists('invoice_email_pdf')
+         and scalar($conf->config('invoice_email_pdf_note')) ) {
+
+      warn "$me using 'invoice_email_pdf_note'"
+        if $DEBUG;
+      $return{'body'} = [ map { $_ . "\n" }
+                              $conf->config('invoice_email_pdf_note')
+                        ];
+
+    } else {
+
+      warn "$me not using 'invoice_email_pdf_note'"
+        if $DEBUG;
+      if ( ref($args{'print_text'}) eq 'ARRAY' ) {
+        $return{'body'} = $args{'print_text'};
+      } else {
+        $return{'body'} = [ $self->print_text('', $args{'template'}) ];
+      }
 
-    if ( grep { $_ eq 'FAX' } @invoicing_list ) { #fax
-      die 'FAX invoice destination not supported with plain text invoices.'
-        unless $conf->exists('invoice_latex');
-      my $dialstring = $self->cust_main->getfield('fax');
-      #Check $dialstring?
-      my $error = send_fax(docdata => $lpr_data, dialstring => $dialstring);
-      die $error if $error;
     }
 
   }
 
-  '';
+  %return;
 
 }
 
-=item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
+=item mimebuild_pdf
 
-Like B<send>, but only sends the invoice if it is the newest open invoice for
-this customer.
+Returns a list suitable for passing to MIME::Entity->build(), representing
+this invoice as PDF attachment.
 
 =cut
 
-sub send_if_newest {
+sub mimebuild_pdf {
   my $self = shift;
-
-  return ''
-    if scalar(
-               grep { $_->owed > 0 } 
-                    qsearch('cust_bill', {
-                      'custnum' => $self->custnum,
-                      #'_date'   => { op=>'>', value=>$self->_date },
-                      'invnum'  => { op=>'>', value=>$self->invnum },
-                    } )
-             );
-    
-  $self->send(@_);
+  (
+    'Type'        => 'application/pdf',
+    'Encoding'    => 'base64',
+    'Data'        => [ $self->print_pdf(@_) ],
+    'Disposition' => 'attachment',
+    'Filename'    => 'invoice.pdf',
+  );
 }
 
-=item send_csv OPTIONS
+=item send [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
 
-Sends invoice as a CSV data-file to a remote host with the specified protocol.
+Sends this invoice to the destinations configured for this customer: sends
+email, prints and/or faxes.  See L<FS::cust_main_invoice>.
 
-Options are:
+TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
 
-protocol - currently only "ftp"
-server
-username
-password
-dir
+AGENTNUM, if specified, means that this invoice will only be sent for customers
+of the specified agent or agent(s).  AGENTNUM can be a scalar agentnum (for a
+single agent) or an arrayref of agentnums.
 
-The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
-and YYMMDDHHMMSS is a timestamp.
+INVOICE_FROM, if specified, overrides the default email invoice From: address.
 
-The fields of the CSV file is as follows:
+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.
 
-record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate
+=cut
 
-=over 4
+sub queueable_send {
+  my %opt = @_;
 
-=item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
+  my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
+    or die "invalid invoice number: " . $opt{invnum};
 
-If B<record_type> is C<cust_bill>, this is a primary invoice record.  The
-last five fields (B<pkg> through B<edate>) are irrelevant, and all other
-fields are filled in.
+  my @args = ( $opt{template}, $opt{agentnum} );
+  push @args, $opt{invoice_from}
+    if exists($opt{invoice_from}) && $opt{invoice_from};
 
-If B<record_type> is C<cust_bill_pkg>, this is a line item record.  Only the
-first two fields (B<record_type> and B<invnum>) and the last five fields
-(B<pkg> through B<edate>) are filled in.
+  my $error = $self->send( @args );
+  die $error if $error;
 
-=item invnum - invoice number
+}
 
-=item custnum - customer number
+sub send {
+  my $self = shift;
+  my $template = scalar(@_) ? shift : '';
+  if ( scalar(@_) && $_[0]  ) {
+    my $agentnums = ref($_[0]) ? shift : [ shift ];
+    return 'N/A' unless grep { $_ == $self->cust_main->agentnum } @$agentnums;
+  }
 
-=item _date - invoice date
+  my $invoice_from =
+    scalar(@_)
+      ? shift
+      : ( $self->_agent_invoice_from ||    #XXX should go away
+          $conf->config('invoice_from', $self->cust_main->agentnum )
+        );
 
-=item charged - total invoice amount
+  my $balance_over = ( scalar(@_) && $_[0] !~ /^\s*$/ ) ? shift : 0;
 
-=item first - customer first name
+  return ''
+    unless $self->cust_main->total_owed_date($self->_date) > $balance_over;
 
-=item last - customer first name
+  my @invoicing_list = $self->cust_main->invoicing_list;
 
-=item company - company name
+  #$self->email_invoice($template, $invoice_from)
+  $self->email($template, $invoice_from)
+    if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list;
 
-=item address1 - address line 1
+  #$self->print_invoice($template)
+  $self->print($template)
+    if grep { $_ eq 'POST' } @invoicing_list; #postal
 
-=item address2 - address line 1
+  $self->fax_invoice($template)
+    if grep { $_ eq 'FAX' } @invoicing_list; #fax
 
-=item city
+  '';
 
-=item state
+}
 
-=item zip
+=item email [ TEMPLATENAME  [ , INVOICE_FROM ] ] 
 
-=item country
+Emails this invoice.
 
-=item pkg - line item description
+TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
 
-=item setup - line item setup fee (one or both of B<setup> and B<recur> will be defined)
+INVOICE_FROM, if specified, overrides the default email invoice From: address.
 
-=item recur - line item recurring fee (one or both of B<setup> and B<recur> will be defined)
+=cut
 
-=item sdate - start date for recurring fee
+sub queueable_email {
+  my %opt = @_;
 
-=item edate - end date for recurring fee
+  my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
+    or die "invalid invoice number: " . $opt{invnum};
 
-=back
+  my @args = ( $opt{template} );
+  push @args, $opt{invoice_from}
+    if exists($opt{invoice_from}) && $opt{invoice_from};
 
-=cut
+  my $error = $self->email( @args );
+  die $error if $error;
 
-sub send_csv {
-  my($self, %opt) = @_;
+}
 
-  #part one: create file
+#sub email_invoice {
+sub email {
+  my $self = shift;
+  my $template = scalar(@_) ? shift : '';
+  my $invoice_from =
+    scalar(@_)
+      ? shift
+      : ( $self->_agent_invoice_from ||    #XXX should go away
+          $conf->config('invoice_from', $self->cust_main->agentnum )
+        );
 
-  my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
-  mkdir $spooldir, 0700 unless -d $spooldir;
 
-  my $file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time);
+  my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } 
+                            $self->cust_main->invoicing_list;
 
-  open(CSV, ">$file") or die "can't open $file: $!";
+  #better to notify this person than silence
+  @invoicing_list = ($invoice_from) unless @invoicing_list;
 
-  eval "use Text::CSV_XS";
-  die $@ if $@;
+  my $subject = $self->email_subject($template);
 
-  my $csv = Text::CSV_XS->new({'always_quote'=>1});
+  my $error = send_email(
+    $self->generate_email(
+      'from'       => $invoice_from,
+      'to'         => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
+      'subject'    => $subject,
+      'template'   => $template,
+    )
+  );
+  die "can't email invoice: $error\n" if $error;
+  #die "$error\n" if $error;
 
-  my $cust_main = $self->cust_main;
+}
 
-  $csv->combine(
-    'cust_bill',
-    $self->invnum,
-    $self->custnum,
-    time2str("%x", $self->_date),
-    sprintf("%.2f", $self->charged),
-    ( map { $cust_main->getfield($_) }
-        qw( first last company address1 address2 city state zip country ) ),
-    map { '' } (1..5),
-  ) or die "can't create csv";
-  print CSV $csv->string. "\n";
-
-  #new charges (false laziness w/print_text)
-  foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
-
-    my($pkg, $setup, $recur, $sdate, $edate);
-    if ( $cust_bill_pkg->pkgnum ) {
-    
-      ($pkg, $setup, $recur, $sdate, $edate) = (
-        $cust_bill_pkg->cust_pkg->part_pkg->pkg,
-        ( $cust_bill_pkg->setup != 0
-          ? sprintf("%.2f", $cust_bill_pkg->setup )
-          : '' ),
-        ( $cust_bill_pkg->recur != 0
-          ? sprintf("%.2f", $cust_bill_pkg->recur )
-          : '' ),
-        time2str("%x", $cust_bill_pkg->sdate),
-        time2str("%x", $cust_bill_pkg->edate),
-      );
+sub email_subject {
+  my $self = shift;
 
-    } 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), '', '', '' );
-    }
+  #my $template = scalar(@_) ? shift : '';
+  #per-template?
 
-    $csv->combine(
-      'cust_bill_pkg',
-      $self->invnum,
-      ( map { '' } (1..11) ),
-      ($pkg, $setup, $recur, $sdate, $edate)
-    ) or die "can't create csv";
-    print CSV $csv->string. "\n";
+  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;
 
-  close CSV or die "can't close CSV: $!";
+  eval qq("$subject");
+}
 
-  #part two: upload it
+=item lpr_data [ TEMPLATENAME ]
 
-  my $net;
+Returns the postscript or plaintext for this invoice as an arrayref.
+
+TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+
+=cut
+
+sub lpr_data {
+  my( $self, $template) = @_;
+  $conf->exists('invoice_latex')
+    ? [ $self->print_ps('', $template) ]
+    : [ $self->print_text('', $template) ];
+}
+
+=item print [ TEMPLATENAME ]
+
+Prints this invoice.
+
+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 : '';
+
+  do_print $self->lpr_data($template);
+}
+
+=item fax_invoice [ TEMPLATENAME ] 
+
+Faxes this invoice.
+
+TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+
+=cut
+
+sub fax_invoice {
+  my $self = shift;
+  my $template = scalar(@_) ? shift : '';
+
+  die 'FAX invoice destination not (yet?) supported with plain text invoices.'
+    unless $conf->exists('invoice_latex');
+
+  my $dialstring = $self->cust_main->getfield('fax');
+  #Check $dialstring?
+
+  my $error = send_fax( 'docdata'    => $self->lpr_data($template),
+                        'dialstring' => $dialstring,
+                      );
+  die $error if $error;
+
+}
+
+=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
+this customer.
+
+=cut
+
+sub send_if_newest {
+  my $self = shift;
+
+  return ''
+    if scalar(
+               grep { $_->owed > 0 } 
+                    qsearch('cust_bill', {
+                      'custnum' => $self->custnum,
+                      #'_date'   => { op=>'>', value=>$self->_date },
+                      'invnum'  => { op=>'>', value=>$self->invnum },
+                    } )
+             );
+    
+  $self->send(@_);
+}
+
+=item send_csv OPTION => VALUE, ...
+
+Sends invoice as a CSV data-file to a remote host with the specified protocol.
+
+Options are:
+
+protocol - currently only "ftp"
+server
+username
+password
+dir
+
+The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
+and YYMMDDHHMMSS is a timestamp.
+
+See L</print_csv> for a description of the output format.
+
+=cut
+
+sub send_csv {
+  my($self, %opt) = @_;
+
+  #create file(s)
+
+  my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
+  mkdir $spooldir, 0700 unless -d $spooldir;
+
+  my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
+  my $file = "$spooldir/$tracctnum.csv";
+  
+  my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
+
+  open(CSV, ">$file") or die "can't open $file: $!";
+  print CSV $header;
+
+  print CSV $detail;
+
+  close CSV;
+
+  my $net;
   if ( $opt{protocol} eq 'ftp' ) {
     eval "use Net::FTP;";
     die $@ if $@;
@@ -659,6 +1128,359 @@ sub send_csv {
 
 }
 
+=item spool_csv
+
+Spools CSV invoice data.
+
+Options are:
+
+=over 4
+
+=item format - 'default' or 'billco'
+
+=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the customer has the corresponding invoice destinations set (see L<FS::cust_main_invoice>).
+
+=item agent_spools - if set to a true value, will spool to per-agent files rather than a single global file
+
+=item balanceover - if set, only spools the invoice if the total amount owed on this invoice and all older invoices is greater than the specified amount.
+
+=back
+
+=cut
+
+sub spool_csv {
+  my($self, %opt) = @_;
+
+  my $cust_main = $self->cust_main;
+
+  if ( $opt{'dest'} ) {
+    my %invoicing_list = map { /^(POST|FAX)$/ or 'EMAIL' =~ /^(.*)$/; $1 => 1 }
+                             $cust_main->invoicing_list;
+    return 'N/A' unless $invoicing_list{$opt{'dest'}}
+                     || ! keys %invoicing_list;
+  }
+
+  if ( $opt{'balanceover'} ) {
+    return 'N/A'
+      if $cust_main->total_owed_date($self->_date) < $opt{'balanceover'};
+  }
+
+  my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
+  mkdir $spooldir, 0700 unless -d $spooldir;
+
+  my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
+
+  my $file =
+    "$spooldir/".
+    ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
+    ( lc($opt{'format'}) eq 'billco' ? '-header' : '' ) .
+    '.csv';
+  
+  my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
+
+  open(CSV, ">>$file") or die "can't open $file: $!";
+  flock(CSV, LOCK_EX);
+  seek(CSV, 0, 2);
+
+  print CSV $header;
+
+  if ( lc($opt{'format'}) eq 'billco' ) {
+
+    flock(CSV, LOCK_UN);
+    close CSV;
+
+    $file =
+      "$spooldir/".
+      ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
+      '-detail.csv';
+
+    open(CSV,">>$file") or die "can't open $file: $!";
+    flock(CSV, LOCK_EX);
+    seek(CSV, 0, 2);
+  }
+
+  print CSV $detail;
+
+  flock(CSV, LOCK_UN);
+  close CSV;
+
+  return '';
+
+}
+
+=item print_csv OPTION => VALUE, ...
+
+Returns CSV data for this invoice.
+
+Options are:
+
+format - 'default' or 'billco'
+
+Returns a list consisting of two scalars.  The first is a single line of CSV
+header information for this invoice.  The second is one or more lines of CSV
+detail information for this invoice.
+
+If I<format> is not specified or "default", the fields of the CSV file are as
+follows:
+
+record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate
+
+=over 4
+
+=item record type - B<record_type> is either C<cust_bill> or C<cust_bill_pkg>
+
+B<record_type> is C<cust_bill> for the initial header line only.  The
+last five fields (B<pkg> through B<edate>) are irrelevant, and all other
+fields are filled in.
+
+B<record_type> is C<cust_bill_pkg> for detail lines.  Only the first two fields
+(B<record_type> and B<invnum>) and the last five fields (B<pkg> through B<edate>)
+are filled in.
+
+=item invnum - invoice number
+
+=item custnum - customer number
+
+=item _date - invoice date
+
+=item charged - total invoice amount
+
+=item first - customer first name
+
+=item last - customer first name
+
+=item company - company name
+
+=item address1 - address line 1
+
+=item address2 - address line 1
+
+=item city
+
+=item state
+
+=item zip
+
+=item country
+
+=item pkg - line item description
+
+=item setup - line item setup fee (one or both of B<setup> and B<recur> will be defined)
+
+=item recur - line item recurring fee (one or both of B<setup> and B<recur> will be defined)
+
+=item sdate - start date for recurring fee
+
+=item edate - end date for recurring fee
+
+=back
+
+If I<format> is "billco", the fields of the header CSV file are as follows:
+
+  +-------------------------------------------------------------------+
+  |                        FORMAT HEADER FILE                         |
+  |-------------------------------------------------------------------|
+  | Field | Description                   | Name       | Type | Width |
+  | 1     | N/A-Leave Empty               | RC         | CHAR |     2 |
+  | 2     | N/A-Leave Empty               | CUSTID     | CHAR |    15 |
+  | 3     | Transaction Account No        | TRACCTNUM  | CHAR |    15 |
+  | 4     | Transaction Invoice No        | TRINVOICE  | CHAR |    15 |
+  | 5     | Transaction Zip Code          | TRZIP      | CHAR |     5 |
+  | 6     | Transaction Company Bill To   | TRCOMPANY  | CHAR |    30 |
+  | 7     | Transaction Contact Bill To   | TRNAME     | CHAR |    30 |
+  | 8     | Additional Address Unit Info  | TRADDR1    | CHAR |    30 |
+  | 9     | Bill To Street Address        | TRADDR2    | CHAR |    30 |
+  | 10    | Ancillary Billing Information | TRADDR3    | CHAR |    30 |
+  | 11    | Transaction City Bill To      | TRCITY     | CHAR |    20 |
+  | 12    | Transaction State Bill To     | TRSTATE    | CHAR |     2 |
+  | 13    | Bill Cycle Close Date         | CLOSEDATE  | CHAR |    10 |
+  | 14    | Bill Due Date                 | DUEDATE    | CHAR |    10 |
+  | 15    | Previous Balance              | BALFWD     | NUM* |     9 |
+  | 16    | Pmt/CR Applied                | CREDAPPLY  | NUM* |     9 |
+  | 17    | Total Current Charges         | CURRENTCHG | NUM* |     9 |
+  | 18    | Total Amt Due                 | TOTALDUE   | NUM* |     9 |
+  | 19    | Total Amt Due                 | AMTDUE     | NUM* |     9 |
+  | 20    | 30 Day Aging                  | AMT30      | NUM* |     9 |
+  | 21    | 60 Day Aging                  | AMT60      | NUM* |     9 |
+  | 22    | 90 Day Aging                  | AMT90      | NUM* |     9 |
+  | 23    | Y/N                           | AGESWITCH  | CHAR |     1 |
+  | 24    | Remittance automation         | SCANLINE   | CHAR |   100 |
+  | 25    | Total Taxes & Fees            | TAXTOT     | NUM* |     9 |
+  | 26    | Customer Reference Number     | CUSTREF    | CHAR |    15 |
+  | 27    | Federal Tax***                | FEDTAX     | NUM* |     9 |
+  | 28    | State Tax***                  | STATETAX   | NUM* |     9 |
+  | 29    | Other Taxes & Fees***         | OTHERTAX   | NUM* |     9 |
+  +-------+-------------------------------+------------+------+-------+
+
+If I<format> is "billco", the fields of the detail CSV file are as follows:
+
+                                  FORMAT FOR DETAIL FILE
+        |                            |           |      |
+  Field | Description                | Name      | Type | Width
+  1     | N/A-Leave Empty            | RC        | CHAR |     2
+  2     | N/A-Leave Empty            | CUSTID    | CHAR |    15
+  3     | Account Number             | TRACCTNUM | CHAR |    15
+  4     | Invoice Number             | TRINVOICE | CHAR |    15
+  5     | Line Sequence (sort order) | LINESEQ   | NUM  |     6
+  6     | Transaction Detail         | DETAILS   | CHAR |   100
+  7     | Amount                     | AMT       | NUM* |     9
+  8     | Line Format Control**      | LNCTRL    | CHAR |     2
+  9     | Grouping Code              | GROUP     | CHAR |     2
+  10    | User Defined               | ACCT CODE | CHAR |    15
+
+=cut
+
+sub print_csv {
+  my($self, %opt) = @_;
+  
+  eval "use Text::CSV_XS";
+  die $@ if $@;
+
+  my $cust_main = $self->cust_main;
+
+  my $csv = Text::CSV_XS->new({'always_quote'=>1});
+
+  if ( lc($opt{'format'}) eq 'billco' ) {
+
+    my $taxtotal = 0;
+    $taxtotal += $_->{'amount'} foreach $self->_items_tax;
+
+    my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format?
+
+    my( $previous_balance, @unused ) = $self->previous; #previous balance
+
+    my $pmt_cr_applied = 0;
+    $pmt_cr_applied += $_->{'amount'}
+      foreach ( $self->_items_payments, $self->_items_credits ) ;
+
+    my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
+
+    $csv->combine(
+      '',                         #  1 | N/A-Leave Empty               CHAR   2
+      '',                         #  2 | N/A-Leave Empty               CHAR  15
+      $opt{'tracctnum'},          #  3 | Transaction Account No        CHAR  15
+      $self->invnum,              #  4 | Transaction Invoice No        CHAR  15
+      $cust_main->zip,            #  5 | Transaction Zip Code          CHAR   5
+      $cust_main->company,        #  6 | Transaction Company Bill To   CHAR  30
+      #$cust_main->payname,        #  7 | Transaction Contact Bill To   CHAR  30
+      $cust_main->contact,        #  7 | Transaction Contact Bill To   CHAR  30
+      $cust_main->address2,       #  8 | Additional Address Unit Info  CHAR  30
+      $cust_main->address1,       #  9 | Bill To Street Address        CHAR  30
+      '',                         # 10 | Ancillary Billing Information CHAR  30
+      $cust_main->city,           # 11 | Transaction City Bill To      CHAR  20
+      $cust_main->state,          # 12 | Transaction State Bill To     CHAR   2
+
+      # XXX ?
+      time2str("%m/%d/%Y", $self->_date), # 13 | Bill Cycle Close Date CHAR  10
+
+      # XXX ?
+      $duedate,                   # 14 | Bill Due Date                 CHAR  10
+
+      $previous_balance,          # 15 | Previous Balance              NUM*   9
+      $pmt_cr_applied,            # 16 | Pmt/CR Applied                NUM*   9
+      sprintf("%.2f", $self->charged), # 17 | Total Current Charges    NUM*   9
+      $totaldue,                  # 18 | Total Amt Due                 NUM*   9
+      $totaldue,                  # 19 | Total Amt Due                 NUM*   9
+      '',                         # 20 | 30 Day Aging                  NUM*   9
+      '',                         # 21 | 60 Day Aging                  NUM*   9
+      '',                         # 22 | 90 Day Aging                  NUM*   9
+      'N',                        # 23 | Y/N                           CHAR   1
+      '',                         # 24 | Remittance automation         CHAR 100
+      $taxtotal,                  # 25 | Total Taxes & Fees            NUM*   9
+      $self->custnum,             # 26 | Customer Reference Number     CHAR  15
+      '0',                        # 27 | Federal Tax***                NUM*   9
+      sprintf("%.2f", $taxtotal), # 28 | State Tax***                  NUM*   9
+      '0',                        # 29 | Other Taxes & Fees***         NUM*   9
+    );
+
+  } else {
+  
+    $csv->combine(
+      'cust_bill',
+      $self->invnum,
+      $self->custnum,
+      time2str("%x", $self->_date),
+      sprintf("%.2f", $self->charged),
+      ( map { $cust_main->getfield($_) }
+          qw( first last company address1 address2 city state zip country ) ),
+      map { '' } (1..5),
+    ) or die "can't create csv";
+  }
+
+  my $header = $csv->string. "\n";
+
+  my $detail = '';
+  if ( lc($opt{'format'}) eq 'billco' ) {
+
+    my $lineseq = 0;
+    foreach my $item ( $self->_items_pkg ) {
+
+      $csv->combine(
+        '',                     #  1 | N/A-Leave Empty            CHAR   2
+        '',                     #  2 | N/A-Leave Empty            CHAR  15
+        $opt{'tracctnum'},      #  3 | Account Number             CHAR  15
+        $self->invnum,          #  4 | Invoice Number             CHAR  15
+        $lineseq++,             #  5 | Line Sequence (sort order) NUM    6
+        $item->{'description'}, #  6 | Transaction Detail         CHAR 100
+        $item->{'amount'},      #  7 | Amount                     NUM*   9
+        '',                     #  8 | Line Format Control**      CHAR   2
+        '',                     #  9 | Grouping Code              CHAR   2
+        '',                     # 10 | User Defined               CHAR  15
+      );
+
+      $detail .= $csv->string. "\n";
+
+    }
+
+  } else {
+
+    foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+
+      my($pkg, $setup, $recur, $sdate, $edate);
+      if ( $cust_bill_pkg->pkgnum ) {
+      
+        ($pkg, $setup, $recur, $sdate, $edate) = (
+          $cust_bill_pkg->part_pkg->pkg,
+          ( $cust_bill_pkg->setup != 0
+            ? sprintf("%.2f", $cust_bill_pkg->setup )
+            : '' ),
+          ( $cust_bill_pkg->recur != 0
+            ? sprintf("%.2f", $cust_bill_pkg->recur )
+            : '' ),
+          ( $cust_bill_pkg->sdate 
+            ? time2str("%x", $cust_bill_pkg->sdate)
+            : '' ),
+          ($cust_bill_pkg->edate 
+            ?time2str("%x", $cust_bill_pkg->edate)
+            : '' ),
+        );
+  
+      } 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), '', '', '' );
+      }
+  
+      $csv->combine(
+        'cust_bill_pkg',
+        $self->invnum,
+        ( map { '' } (1..11) ),
+        ($pkg, $setup, $recur, $sdate, $edate)
+      ) or die "can't create csv";
+
+      $detail .= $csv->string. "\n";
+
+    }
+
+  }
+
+  ( $header, $detail );
+
+}
+
 =item comp
 
 Pays this invoice with a compliemntary payment.  If there is an error,
@@ -739,7 +1561,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");
@@ -752,81 +1574,61 @@ sub realtime_bop {
 
 }
 
-=item batch_card
+=item batch_card OPTION => VALUE...
 
 Adds a payment for this invoice to the pending credit card batch (see
-L<FS::cust_pay_batch>).
+L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
+runs the payment using a realtime gateway.
 
 =cut
 
 sub batch_card {
-  my $self = shift;
+  my ($self, %options) = @_;
   my $cust_main = $self->cust_main;
 
-  my $cust_pay_batch = new FS::cust_pay_batch ( {
-    'invnum'   => $self->getfield('invnum'),
-    'custnum'  => $cust_main->getfield('custnum'),
-    'last'     => $cust_main->getfield('last'),
-    'first'    => $cust_main->getfield('first'),
-    'address1' => $cust_main->getfield('address1'),
-    'address2' => $cust_main->getfield('address2'),
-    'city'     => $cust_main->getfield('city'),
-    'state'    => $cust_main->getfield('state'),
-    'zip'      => $cust_main->getfield('zip'),
-    'country'  => $cust_main->getfield('country'),
-    'cardnum'  => $cust_main->payinfo,
-    'exp'      => $cust_main->getfield('paydate'),
-    'payname'  => $cust_main->getfield('payname'),
-    'amount'   => $self->owed,
-  } );
-  my $error = $cust_pay_batch->insert;
-  die $error if $error;
-
-  '';
+  $options{invnum} = $self->invnum;
+  
+  $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 ".
-                                  $self->cust_main->agentnum.
-                                  "(\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 ) = @_;
+
+  my %params = ( 'format' => 'template' );
+  $params{'time'} = $today if $today;
+  $params{'template'} = $template if $template;
 
+  $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.
@@ -835,684 +1637,1091 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 
 =cut
 
-#still some false laziness w/print_text
-sub print_text {
-
+sub print_latex {
   my( $self, $today, $template ) = @_;
-  $today ||= time;
 
-#  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)$/;
+  my %params = ( 'format' => 'latex' );
+  $params{'time'} = $today if $today;
+  $params{'template'} = $template if $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;
+  $template ||= $self->_agent_template;
+
+  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+  my $lh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
+                           DIR      => $dir,
+                           SUFFIX   => '.eps',
+                           UNLINK   => 0,
+                         ) or die "can't open temp file: $!\n";
 
-  #my @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 @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->pkgnum ) {
+  $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
+  return ($1, $params{'logo_file'});
 
-      my $cust_pkg = qsearchs('cust_pkg', { pkgnum =>$cust_bill_pkg->pkgnum } );
-      my $part_pkg = qsearchs('part_pkg', { pkgpart=>$cust_pkg->pkgpart } );
-      my $pkg = $part_pkg->pkg;
+}
 
-      if ( $cust_bill_pkg->setup != 0 ) {
-        my $description = $pkg;
-        $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_pkg->h_labels($self->_date);
-      }
+=item print_generic OPTIONS_HASH
 
-      if ( $cust_bill_pkg->recur != 0 ) {
-        push @buf, [
-          "$pkg (" . 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_pkg->h_labels($cust_bill_pkg->edate, $cust_bill_pkg->sdate);
-      }
+Internal method - returns a filled-in template for this invoice as a scalar.
 
-      push @buf, map { [ "  $_", '' ] } $cust_bill_pkg->details;
+See print_ps and print_pdf for methods that return PostScript and PDF output.
 
-    } else { #pkgnum tax or one-shot line item
-      my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
-                     ? ( $cust_bill_pkg->itemdesc || 'Tax' )
-                     : 'Tax';
-      if ( $cust_bill_pkg->setup != 0 ) {
-        push @buf, [ $itemdesc,
-                     $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
-      }
-      if ( $cust_bill_pkg->recur != 0 ) {
-        push @buf, [ "$itemdesc (". time2str("%x", $cust_bill_pkg->sdate). " - "
-                                  . time2str("%x", $cust_bill_pkg->edate). ")",
-                     $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
-                   ];
-      }
-    }
-  }
+Non optional options include 
+  format - latex, html, template
 
-  push @buf,['','-----------'];
-  push @buf,['Total New Charges',
-             $money_char. sprintf("%10.2f",$self->charged) ];
-  push @buf,['',''];
+Optional options include
 
-  push @buf,['','-----------'];
-  push @buf,['Total Charges',
-             $money_char. sprintf("%10.2f",$self->charged + $pr_total) ];
-  push @buf,['',''];
+template - a value used as a suffix for a configuration template
 
-  #credits
-  foreach ( $self->cust_credited ) {
+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.
 
-    #something more elaborate if $_->amount ne $_->cust_credit->credited ?
+cid - 
 
-    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)
-  #  ];
-  #}
+unsquelch_cdr - overrides any per customer cdr squelching when true
 
-  #get & print payments
-  foreach ( $self->cust_bill_pay ) {
+=cut
 
-    #something more elaborate if $_->amount ne ->cust_pay->paid ?
+#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 {
 
-    push @buf,[
-      "Payment received ". time2str("%x",$_->cust_pay->_date ),
-      $money_char. sprintf("%10.2f",$_->amount )
-    ];
-  }
+  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;
 
-  #balance due
-  my $balance_due_msg = $self->balance_due_msg;
+  my $format = $params{format};
+  die "Unknown format: $format"
+    unless $format =~ /^(latex|html|template)$/;
 
-  push @buf,['','-----------'];
-  push @buf,[$balance_due_msg, $money_char. 
-    sprintf("%10.2f", $balance_due ) ];
+  my $cust_main = $self->cust_main;
+  $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
+    unless $cust_main->payname
+        && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
+
+  my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
+                     'html'     => [ '<%=', '%>' ],
+                     'template' => [ '{', '}' ],
+                   );
 
   #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( $invnum $date $page $total_pages @address $overdue @buf $agent );
-
-  $invnum = $self->invnum;
-  $date = $self->_date;
-  $page = 1;
-  $agent = $self->cust_main->agent->agent;
-
-  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;
-  $FS::cust_bill::_template::address[$l++] = $cust_main->country
-    unless $cust_main->country eq 'US';
-
-       #  #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 );
-  }
+  push @address, $invoice_data{'country'}
+    if $invoice_data{'country'};
+  push @address, ''
+    while (scalar(@address) < 5);
 
-  #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++;
-  }
+  $invoice_data{'logo_file'} = $params{'logo_file'}
+    if $params{'logo_file'};
 
-  map "$_\n", @collect;
+  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);
 
-}
+  my $agentnum = $self->cust_main->agentnum;
 
-=item print_latex [ TIME [ , TEMPLATE ] ]
+  #do variable substitution in notes, footer, smallfooter
+  foreach my $include (qw( notes footer smallfooter coupon )) {
 
-Internal method - returns a filename of a filled-in LaTeX template for this
-invoice (Note: add ".tex" to get the actual filename).
+    my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
+    my @inc_src;
 
-See print_ps and print_pdf for methods that return PostScript and PDF output.
+    if ( $conf->exists($inc_file, $agentnum)
+         && length( $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.
+      @inc_src = $conf->config($inc_file, $agentnum);
 
-=cut
+    } else {
 
-#still some false laziness w/print_text
-sub print_latex {
+      $inc_file = $conf->key_orbase("invoice_latex$include", $template);
 
-  my( $self, $today, $template ) = @_;
-  $today ||= time;
+      my $convert_map = $convert_maps{$format}{$include};
 
-#  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)$/;
+      @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
+                       s/--\@\]/$delimiters{$format}[1]/g;
+                       $_;
+                     } 
+                 &$convert_map( $conf->config($inc_file, $agentnum) );
 
-  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 @collect = ();
-  #my($description,$amount);
-  @buf = ();
+    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 %invoice_data = (
-    'invnum'       => $self->invnum,
-    'date'         => time2str('%b %o, %Y', $self->_date),
-    '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),
-    'country'      => _latex_escape($cust_main->country),
-    'footer'       => join("\n", $conf->config('invoice_latexfooter') ),
-    'smallfooter'  => join("\n", $conf->config('invoice_latexsmallfooter') ),
-    'returnaddress' => join("\n", $conf->config('invoice_latexreturnaddress') ),
-    '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",
-  );
-
-  my $countrydefault = $conf->config('countrydefault') || 'US';
-  $invoice_data{'country'} = '' if $invoice_data{'country'} eq $countrydefault;
-
-  #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)
-    );
-
-  $invoice_data{'footer'} =~ s/\n+$//;
-  $invoice_data{'smallfooter'} =~ s/\n+$//;
-  $invoice_data{'notes'} =~ s/\n+$//;
-
   $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 += ( $invoice_data{'total_amount'} = $tax->{'amount'} );
-          push @total_fill,
-            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-                @total_item;
-        }
-  
-        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
+      ? &$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;
   
-        # 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;
-      }
-  
-    }
-
-    sub nounder {
-      my $var = $1;
-      $var =~ s/_/\-/g;
-      $var;
-    }
+  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' => '' };
+  }
 
-  } elsif ( $format eq 'Text::Template' ) {
+  unless (    $conf->exists('disable_previous_balance')
+           || $conf->exists('previous_balance-summary_only')
+         )
+  {
 
-    my @detail_items = ();
-    my @total_items = ();
+    foreach my $line_item ( $self->_items_previous ) {
 
-    $invoice_data{'detail_items'} = \@detail_items;
-    $invoice_data{'total_items'} = \@total_items;
-  
-    foreach my $line_item ( $self->_items ) {
       my $detail = {
         ext_description => [],
       };
       $detail->{'ref'} = $line_item->{'pkgnum'};
       $detail->{'quantity'} = 1;
-      $detail->{'description'} = _latex_escape($line_item->{'description'});
+      $detail->{'section'} = $previous_section;
+      $detail->{'description'} = &$escape_function($line_item->{'description'});
       if ( exists $line_item->{'ext_description'} ) {
         @{$detail->{'ext_description'}} = map {
-          _latex_escape($_);
+          &$escape_function($_);
         } @{$line_item->{'ext_description'}};
       }
-      $detail->{'amount'} = $line_item->{'amount'};
+      $detail->{'amount'} = ( $old_latex ? '' : $money_char).
+                            $line_item->{'amount'};
+      $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+
+      push @detail_items, $detail;
+      push @buf, [ $detail->{'description'},
+                   $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+                 ];
+    }
+
+  }
+
+  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) {
+    push @buf, ['','-----------'];
+    push @buf, [ 'Total Previous Balance',
+                 $money_char. sprintf("%10.2f", $pr_total) ];
+    push @buf, ['',''];
+  }
+
+  foreach my $section (@sections, @$late_sections) {
+
+    $section->{'subtotal'} = $other_money_char.
+                             sprintf('%.2f', $section->{'subtotal'})
+      if $multisection;
+
+    if ( $section->{'description'} ) {
+      push @buf, ( [ &$escape_function($section->{'description'}), '' ],
+                   [ '', '' ],
+                 );
+    }
+
+    my %options = ();
+    $options{'section'} = $section if $multisection;
+    $options{'format'} = $format;
+    $options{'escape_function'} = $escape_function;
+    $options{'format_function'} = sub { () } unless $unsquelched;
+    $options{'unsquelched'} = $unsquelched;
+
+    foreach my $line_item ( $self->_items_pkg(%options) ) {
+      my $detail = {
+        ext_description => [],
+      };
+      $detail->{'ref'} = $line_item->{'pkgnum'};
+      $detail->{'quantity'} = $line_item->{'quantity'};
+      $detail->{'section'} = $section;
+      $detail->{'description'} = &$escape_function($line_item->{'description'});
+      if ( exists $line_item->{'ext_description'} ) {
+        @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
+      }
+      $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
+                              $line_item->{'amount'};
+      $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
+                                 $line_item->{'unit_amount'};
       $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
   
       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 += ( $invoice_data{'total_amount'} = $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
+    my $credittotal = 0;
     foreach my $credit ( $self->_items_credits ) {
+
       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;
+      }
+
+      push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
+
     }
+    $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
   
     # 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 ] ]
+
+Returns an postscript 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.
+
+=cut
+
+sub print_ps {
+  my $self = shift;
+
+  my ($file, $lfile) = $self->print_latex(@_);
+  my $ps = generate_ps($file);
+  unlink($lfile);
+
+  $ps;
+}
+
+=item print_pdf [ TIME [ , TEMPLATE ] ]
+
+Returns an PDF 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.
+
+=cut
+
+sub print_pdf {
+  my $self = shift;
+
+  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
 
+sub print_html {
+  my $self = shift;
+  my %params;
+  if ( ref $_[0]  ) {
+    %params = %{ shift() }; 
+  }else{
+    $params{'time'} = shift;
+    $params{'template'} = shift;
+    $params{'cid'} = shift;
+  }
+
+  $params{'format'} = 'html';
+
+  $self->print_generic( %params );
+}
+
+# quick subroutine for print_latex
+#
+# There are ten characters that LaTeX treats as special characters, which
+# means that they do not simply typeset themselves: 
+#      # $ % & ~ _ ^ \ { }
+#
+# TeX ignores blanks following an escaped character; if you want a blank (as
+# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
+
+sub _latex_escape {
+  my $value = shift;
+  $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
+  $value =~ s/([<>])/\$$1\$/g;
+  $value;
 }
 
-=item print_ps [ TIME [ , TEMPLATE ] ]
+#utility methods for print_*
+
+sub _translate_old_latex_format {
+  warn "_translate_old_latex_format called\n"
+    if $DEBUG; 
 
-Returns an postscript invoice, as a scalar.
+  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, '}',
+                      '--@]';
 
-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.
+    } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
 
-=cut
+      push @template, '[@--',
+                      '  foreach my $_tr_line (@total_items) {';
 
-sub print_ps {
-  my $self = shift;
+      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';";
+      }
 
-  my $file = $self->print_latex(@_);
+      push @template, '}',
+                      '--@]';
 
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
-  chdir($dir);
+    } else {
+      $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
+      push @template, $line;  
+    }
+  
+  }
 
-  my $sfile = shell_quote $file;
+  if ($DEBUG) {
+    warn "$_\n" foreach @template;
+  }
 
-  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";
+  (@template);
+}
 
-  system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
-    or die "dvips failed";
+sub terms {
+  my $self = shift;
 
-  open(POSTSCRIPT, "<$file.ps")
-    or die "can't open $file.ps: $! (error in LaTeX template?)\n";
+  #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;
 
-  unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
+  #use configured default or default default
+  $conf->config('invoice_default_terms') || 'Payable upon receipt';
+}
 
-  my $ps = '';
-  while (<POSTSCRIPT>) {
-    $ps .= $_;
+sub due_date {
+  my $self = shift;
+  my $duedate = '';
+  if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
+    $duedate = $self->_date() + ( $1 * 86400 );
   }
+  $duedate;
+}
 
-  close POSTSCRIPT;
-
-  return $ps;
+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 $self->terms;
+  if ( $self->due_date ) {
+    $msg .= ' - Please pay by '. $self->due_date2str('%x');
+  } elsif ( $self->terms ) {
+    $msg .= ' - '. $self->terms;
+  }
+  $msg;
 }
 
-=item print_pdf [ TIME [ , TEMPLATE ] ]
+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;
+}
 
-Returns an PDF invoice, as a scalar.
+=item invnum_date_pretty
 
-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.
+Returns a string with the invoice number and date, for example:
+"Invoice #54 (3/20/2008)"
 
 =cut
 
-sub print_pdf {
+sub invnum_date_pretty {
   my $self = shift;
+  'Invoice #'. $self->invnum. ' ('. $self->_date_pretty. ')';
+}
 
-  my $file = $self->print_latex(@_);
+=item _date_pretty
 
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
-  chdir($dir);
+Returns a string with the date, for example: "3/20/2008"
 
-  #system('pdflatex', "$file.tex");
-  #system('pdflatex', "$file.tex");
-  #! LaTeX Error: Unknown graphics extension: .eps.
+=cut
 
-  my $sfile = shell_quote $file;
+sub _date_pretty {
+  my $self = shift;
+  time2str('%x', $self->_date);
+}
 
-  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";
+sub _items_sections {
+  my $self = shift;
+  my $late = shift;
 
-  #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: $!";
+  my %s = ();
+  my %l = ();
 
-  open(PDF, "<$file.pdf")
-    or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
+  foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
+  {
 
-  unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
+    if ( $cust_bill_pkg->pkgnum > 0 ) {
+      my $usage = $cust_bill_pkg->usage;
 
-  my $pdf = '';
-  while (<PDF>) {
-    $pdf .= $_;
-  }
+      foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
+        my $desc = $display->section;
+        my $type = $display->type;
 
-  close PDF;
+        if ( $display->post_total ) {
+          if (! $type || $type eq 'S') {
+            $l{$desc} += $cust_bill_pkg->setup
+              if ( $cust_bill_pkg->setup != 0 );
+          }
 
-  return $pdf;
+          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;
+          }
 
-# quick subroutine for print_latex
-#
-# There are ten characters that LaTeX treats as special characters, which
-# means that they do not simply typeset themselves: 
-#      # $ % & ~ _ ^ \ { }
-#
-# TeX ignores blanks following an escaped character; if you want a blank (as
-# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
+        } else {
+          if (! $type || $type eq 'S') {
+            $s{$desc} += $cust_bill_pkg->setup
+              if ( $cust_bill_pkg->setup != 0 );
+          }
 
-sub _latex_escape {
-  my $value = shift;
-  $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
-  $value =~ s/([<>])/\$$1\$/g;
-  $value;
-}
+          if (! $type) {
+            $s{$desc} += $cust_bill_pkg->recur
+              if ( $cust_bill_pkg->recur != 0 );
+          }
 
-#utility methods for print_*
+          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;
+          }
+
+        }
+
+      }
+
+    }
 
-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');
   }
-  $msg;
+
+  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(@_);
@@ -1531,7 +2740,7 @@ sub _items_previous {
                        ' ('. time2str('%x',$_->_date). ')',
       #'pkgpart'     => 'N/A',
       'pkgnum'      => 'N/A',
-      'amount'      => sprintf("%10.2f", $_->owed),
+      'amount'      => sprintf("%.2f", $_->owed),
     };
   }
   @b;
@@ -1556,72 +2765,148 @@ 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 ) {
-
-    if ( $cust_bill_pkg->pkgnum ) {
-
-      my $cust_pkg = qsearchs('cust_pkg', { pkgnum =>$cust_bill_pkg->pkgnum } );
-      my $part_pkg = qsearchs('part_pkg', { pkgpart=>$cust_pkg->pkgpart } );
-      my $pkg = $part_pkg->pkg;
-
-      if ( $cust_bill_pkg->setup != 0 ) {
-        my $description = $pkg;
-        $description .= ' Setup' if $cust_bill_pkg->recur != 0;
-        my @d = $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_pkg->pkgnum,
-          amount          => sprintf("%10.2f", $cust_bill_pkg->setup),
-          ext_description => \@d,
-        };
-      }
+  foreach my $cust_bill_pkg ( @$cust_bill_pkg )
+  {
+    foreach my $display ( grep { defined($section)
+                                 ? $_->section eq $section
+                                 : 1
+                               }
+                          $cust_bill_pkg->cust_bill_pkg_display
+                        )
+    {
 
-      if ( $cust_bill_pkg->recur != 0 ) {
-        push @b, {
-          description     => "$pkg (" .
-                               time2str('%x', $cust_bill_pkg->sdate). ' - '.
-                               time2str('%x', $cust_bill_pkg->edate). ')',
-          #pkgpart         => $part_pkg->pkgpart,
-          pkgnum          => $cust_pkg->pkgnum,
-          amount          => sprintf("%10.2f", $cust_bill_pkg->recur),
-          ext_description => [ $cust_pkg->h_labels_short($cust_bill_pkg->edate,
-                                                         $cust_bill_pkg->sdate),
-                               $cust_bill_pkg->details,
-                             ],
-        };
-      }
+      my $type = $display->type;
 
-    } else { #pkgnum tax or one-shot line item (??)
+      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;
+          push @d, $cust_bill_pkg->details(%details_opt)
+            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),
+            unit_amount     => sprintf("%.2f", $cust_bill_pkg->unitsetup),
+            quantity        => $cust_bill_pkg->quantity,
+            ext_description => \@d,
+          };
+
+        }
+
+        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
+          push @d, map &{$escape_function}($_),
+                       $cust_pkg->h_labels_short($self->_date)
+                                                 #$cust_bill_pkg->edate,
+                                                 #$cust_bill_pkg->sdate)
+            unless $cust_pkg->part_pkg->hide_svc_detail
+                || $cust_bill_pkg->itemdesc
+                || $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;
+          }
+  
+          push @b, {
+            description     => $description,
+            #pkgpart         => $part_pkg->pkgpart,
+            pkgnum          => $cust_bill_pkg->pkgnum,
+            amount          => sprintf("%.2f", $amount),
+            unit_amount     => sprintf("%.2f", $cust_bill_pkg->unitrecur),
+            quantity        => $cust_bill_pkg->quantity,
+            ext_description => \@d,
+          } unless ( $type eq 'U' && ! $amount );
+
+        }
+
+      } 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),
+          };
+        }
 
-      my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
-                     ? ( $cust_bill_pkg->itemdesc || 'Tax' )
-                     : 'Tax';
-      if ( $cust_bill_pkg->setup != 0 ) {
-        push @b, {
-          'description' => $itemdesc,
-          'amount'      => sprintf("%10.2f", $cust_bill_pkg->setup),
-        };
-      }
-      if ( $cust_bill_pkg->recur != 0 ) {
-        push @b, {
-          'description' => "$itemdesc (".
-                           time2str("%x", $cust_bill_pkg->sdate). ' - '.
-                           time2str("%x", $cust_bill_pkg->edate). ')',
-          'amount'      => sprintf("%10.2f", $cust_bill_pkg->recur),
-        };
       }
 
     }
@@ -1641,25 +2926,19 @@ 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,32);
+    $reason .= '...' if length($reason) < length($_->cust_credit->reason);
     $reason = " ($reason) " if $reason;
+
     push @b, {
       #'description' => 'Credit ref\#'. $_->crednum.
       #                 " (". time2str("%x",$_->cust_credit->_date) .")".
       #                 $reason,
       'description' => 'Credit applied '.
                        time2str("%x",$_->cust_credit->_date). $reason,
-      'amount'      => sprintf("%10.2f",$_->amount),
+      'amount'      => sprintf("%.2f",$_->amount),
     };
   }
-  #foreach ( @cr_cust_credit ) {
-  #  push @buf,[
-  #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
-  #    $money_char. sprintf("%10.2f",$_->credited)
-  #  ];
-  #}
 
   @b;
 
@@ -1677,7 +2956,7 @@ sub _items_payments {
     push @b, {
       'description' => "Payment received ".
                        time2str("%x",$_->cust_pay->_date ),
-      'amount'      => sprintf("%10.2f", $_->amount )
+      'amount'      => sprintf("%.2f", $_->amount )
     };
   }
 
@@ -1685,15 +2964,291 @@ sub _items_payments {
 
 }
 
+
+=back
+
+=head1 SUBROUTINES
+
+=over 4
+
+=item process_reprint
+
+=cut
+
+sub process_reprint {
+  process_re_X('print', @_);
+}
+
+=item process_reemail
+
+=cut
+
+sub process_reemail {
+  process_re_X('email', @_);
+}
+
+=item process_refax
+
+=cut
+
+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 "$me process_re_X $method for job $job\n" if $DEBUG;
+
+  my $param = thaw(decode_base64(shift));
+  warn Dumper($param) if $DEBUG;
+
+  re_X(
+    $method,
+    $job,
+    %$param,
+  );
+
+}
+
+sub re_X {
+  my($method, $job, %param ) = @_;
+  if ( $DEBUG ) {
+    warn "re_X $method for job $job with param:\n".
+         join( '', map { "  $_ => ". $param{$_}. "\n" } keys %param );
+  }
+
+  #some false laziness w/search/cust_bill.html
+  my $distinct = '';
+  my $orderby = 'ORDER BY cust_bill._date';
+
+  my $extra_sql = ' WHERE '. FS::cust_bill->search_sql(\%param);
+
+  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,
+  } );
+
+  $method .= '_invoice' unless $method eq 'email' || $method eq 'print';
+
+  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 ) {
+    $cust_bill->$method();
+
+    if ( $job ) { #progressbar foo
+      $num++;
+      if ( time - $min_sec > $last ) {
+        my $error = $job->update_statustext(
+          int( 100 * $num / scalar(@cust_bill) )
+        );
+        die $error if $error;
+        $last = time;
+      }
+    }
+
+  }
+
+}
+
+=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>,