add cpanel export
[freeside.git] / FS / FS / cust_bill.pm
index d6e4ab6..46809f9 100644 (file)
@@ -1,13 +1,19 @@
 package FS::cust_bill;
 
 use strict;
-use vars qw( @ISA $conf $money_char );
+use vars qw( @ISA $DEBUG $conf $money_char );
 use vars qw( $invoice_lines @buf ); #yuck
+use IPC::Run3;
 use Date::Format;
-use Text::Template;
+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::Misc qw( send_email send_fax );
 use FS::Record qw( qsearch qsearchs );
-use FS::Misc qw( send_email );
+use FS::cust_main_Mixin;
 use FS::cust_main;
 use FS::cust_bill_pkg;
 use FS::cust_credit;
@@ -16,8 +22,13 @@ use FS::cust_pkg;
 use FS::cust_credit_bill;
 use FS::cust_pay_batch;
 use FS::cust_bill_event;
+use FS::part_pkg;
+use FS::cust_bill_pay;
+use FS::part_bill_event;
 
-@ISA = qw( FS::Record );
+@ISA = qw( FS::cust_main_Mixin FS::Record );
+
+$DEBUG = 0;
 
 #ask FS::UID to run this stuff for us later
 FS::UID->install_callback( sub { 
@@ -95,6 +106,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,
@@ -316,47 +334,399 @@ sub owed {
   $balance;
 }
 
-=item send
 
-Sends this invoice to the destinations configured for this customer: send
-emails or print.  See L<FS::cust_main_invoice>.
+=item generate_email PARAMHASH
+
+PARAMHASH can contain the following:
+
+=over 4
+
+=item from       => sender address, required
+
+=item tempate    => alternate template name, optional
+
+=item print_text => text attachment arrayref, optional
+
+=item subject    => email subject, optional
+
+=back
+
+Returns an argument list to be passed to L<FS::Misc::send_email>.
 
 =cut
 
-sub send {
-  my($self,$template) = @_;
-  my @print_text = $self->print_text('', $template);
-  my @invoicing_list = $self->cust_main->invoicing_list;
+use MIME::Entity;
+
+sub generate_email {
+
+  my $self = shift;
+  my %args = @_;
+
+  my $me = '[FS::cust_bill::generate_email]';
+
+  my %return = (
+    'from'      => $args{'from'},
+    'subject'   => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
+  );
+
+  if (ref($args{'to'} eq 'ARRAY')) {
+    $return{'to'} = $args{'to'};
+  } else {
+    $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
+                           $self->cust_main->invoicing_list
+                    ];
+  }
+
+  if ( $conf->exists('invoice_html') ) {
 
-  if ( grep { $_ ne 'POST' } @invoicing_list or !@invoicing_list  ) { #email
+    warn "$me creating HTML/text multipart message"
+      if $DEBUG;
 
-    #better to notify this person than silence
-    @invoicing_list = ($conf->config('invoice_from')) unless @invoicing_list;
+    $return{'nobody'} = 1;
 
-    my $error = send_email(
-      'from'    => $conf->config('invoice_from'),
-      'to'      => [ grep { $_ ne 'POST' } @invoicing_list ],
-      'subject' => 'Invoice',
-      'body'    => \@print_text,
+    my $alternative = build MIME::Entity
+      'Type'        => 'multipart/alternative',
+      'Encoding'    => '7bit',
+      'Disposition' => 'inline'
+    ;
+
+    my $data;
+    if ( $conf->exists('invoice_email_pdf')
+         and scalar($conf->config('invoice_email_pdf_note')) ) {
+
+      warn "$me using 'invoice_email_pdf_note' in multipart message"
+        if $DEBUG;
+      $data = [ map { $_ . "\n" }
+                    $conf->config('invoice_email_pdf_note')
+              ];
+
+    } else {
+
+      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'}) ];
+      }
+
+    }
+
+    $alternative->attach(
+      'Type'        => 'text/plain',
+      #'Encoding'    => 'quoted-printable',
+      'Encoding'    => '7bit',
+      'Data'        => $data,
+      'Disposition' => 'inline',
     );
-    return "can't send invoice: $error" if $error;
+
+    $args{'from'} =~ /\@([\w\.\-]+)/ or $1 = 'example.com';
+    my $content_id = join('.', rand()*(2**32), $$, time). "\@$1";
+
+    my $path = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
+    my $file;
+    if ( defined($args{'_template'}) && length($args{'_template'})
+         && -e "$path/logo_". $args{'_template'}. ".png"
+       )
+    {
+      $file = "$path/logo_". $args{'_template'}. ".png";
+    } else {
+      $file = "$path/logo.png";
+    }
+
+    my $image = build MIME::Entity
+      'Type'       => 'image/png',
+      'Encoding'   => 'base64',
+      'Path'       => $file,
+      '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 ( $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'
+      );
+
+      $related->add_part($alternative);
+
+      $related->add_part($image);
+
+      my $pdf = build MIME::Entity $self->mimebuild_pdf('', $args{'template'});
+
+      $return{'mimeparts'} = [ $related, $pdf ];
+
+    } else {
+
+      #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 ( $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 'POST' } @invoicing_list ) { #postal
-    my $lpr = $conf->config('lpr');
-    open(LPR, "|$lpr")
-      or return "Can't open pipe to $lpr: $!";
-    print LPR @print_text;
-    close LPR
-      or return $! ? "Error closing $lpr: $!"
-                   : "Exit status $? from $lpr";
+  %return;
+
+}
+
+=item mimebuild_pdf
+
+Returns a list suitable for passing to MIME::Entity->build(), representing
+this invoice as PDF attachment.
+
+=cut
+
+sub mimebuild_pdf {
+  my $self = shift;
+  (
+    'Type'        => 'application/pdf',
+    'Encoding'    => 'base64',
+    'Data'        => [ $self->print_pdf(@_) ],
+    'Disposition' => 'attachment',
+    'Filename'    => 'invoice.pdf',
+  );
+}
+
+=item send [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
+
+Sends this invoice to the destinations configured for this customer: sends
+email, prints and/or faxes.  See L<FS::cust_main_invoice>.
+
+TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+
+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.
+
+INVOICE_FROM, if specified, overrides the default email invoice From: address.
+
+=cut
+
+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;
   }
 
+  my $invoice_from =
+    scalar(@_)
+      ? shift
+      : ( $self->_agent_invoice_from || $conf->config('invoice_from') );
+
+  my @invoicing_list = $self->cust_main->invoicing_list;
+
+  $self->email($template, $invoice_from)
+    if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list;
+
+  $self->print($template)
+    if grep { $_ eq 'POST' } @invoicing_list; #postal
+
+  $self->fax($template)
+    if grep { $_ eq 'FAX' } @invoicing_list; #fax
+
   '';
 
 }
 
+=item email [ TEMPLATENAME  [ , INVOICE_FROM ] ] 
+
+Emails this invoice.
+
+TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+
+INVOICE_FROM, if specified, overrides the default email invoice From: address.
+
+=cut
+
+sub email {
+  my $self = shift;
+  my $template = scalar(@_) ? shift : '';
+  my $invoice_from =
+    scalar(@_)
+      ? shift
+      : ( $self->_agent_invoice_from || $conf->config('invoice_from') );
+
+  my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } 
+                            $self->cust_main->invoicing_list;
+
+  #better to notify this person than silence
+  @invoicing_list = ($invoice_from) unless @invoicing_list;
+
+  my $error = send_email(
+    $self->generate_email(
+      'from'       => $invoice_from,
+      'to'         => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
+      'template'   => $template,
+    )
+  );
+  die "can't email invoice: $error\n" if $error;
+  #die "$error\n" if $error;
+
+}
+
+=item lpr_data [ TEMPLATENAME ]
+
+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 {
+  my $self = shift;
+  my $template = scalar(@_) ? shift : '';
+
+  my $lpr = $conf->config('lpr');
+
+  my $outerr = '';
+  run3 $lpr, $self->lpr_data($template), \$outerr, \$outerr;
+  if ( $? ) {
+    $outerr = ": $outerr" if length($outerr);
+    die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
+  }
+
+}
+
+=item fax [ TEMPLATENAME ] 
+
+Faxes this invoice.
+
+TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+
+=cut
+
+sub fax {
+  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 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 OPTIONS
 
 Sends invoice as a CSV data-file to a remote host with the specified protocol.
@@ -459,7 +829,7 @@ sub send_csv {
   ) or die "can't create csv";
   print CSV $csv->string. "\n";
 
-  #new charges (false laziness w/print_text)
+  #new charges (false laziness w/print_text and _items stuff)
   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
 
     my($pkg, $setup, $recur, $sdate, $edate);
@@ -590,7 +960,10 @@ sub realtime_bop {
   my( $self, $method ) = @_;
 
   my $cust_main = $self->cust_main;
-  my $amount = $self->owed;
+  my $balance = $cust_main->balance;
+  my $amount = ( $balance < $self->owed ) ? $balance : $self->owed;
+  $amount = sprintf("%.2f", $amount);
+  return "not run (balance $balance)" unless $amount > 0;
 
   my $description = 'Internet Services';
   if ( $conf->exists('business-onlinepayment-description') ) {
@@ -636,7 +1009,7 @@ sub batch_card {
     'state'    => $cust_main->getfield('state'),
     'zip'      => $cust_main->getfield('zip'),
     'country'  => $cust_main->getfield('country'),
-    'cardnum'  => $cust_main->getfield('payinfo'),
+    'cardnum'  => $cust_main->payinfo,
     'exp'      => $cust_main->getfield('paydate'),
     'payname'  => $cust_main->getfield('payname'),
     'amount'   => $self->owed,
@@ -647,6 +1020,47 @@ sub batch_card {
   '';
 }
 
+sub _agent_template {
+  my $self = shift;
+  $self->_agent_plandata('agent_templatename');
+}
+
+sub _agent_invoice_from {
+  my $self = shift;
+  $self->_agent_plandata('agent_invoice_from');
+}
+
+sub _agent_plandata {
+  my( $self, $option ) = @_;
+
+  my $part_bill_event = qsearchs( 'part_bill_event',
+    {
+      'payby'     => $self->cust_main->payby,
+      'plan'      => 'send_agent',
+      'plandata'  => { 'op'    => '~',
+                       'value' => "(^|\n)agentnum ".
+                                   '([0-9]*, )*'.
+                                  $self->cust_main->agentnum.
+                                   '(, [0-9]*)*'.
+                                  "(\n|\$)",
+                     },
+    },
+    '',
+    'ORDER BY seconds LIMIT 1'
+  );
+
+  return '' unless $part_bill_event;
+
+  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 '';
+  }
+
+}
+
 =item print_text [ TIME [ , TEMPLATE ] ]
 
 Returns an text invoice, as a list of lines.
@@ -658,14 +1072,16 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 
 =cut
 
+#still some false laziness w/_items stuff (and send_csv)
 sub print_text {
 
   my( $self, $today, $template ) = @_;
   $today ||= time;
+
 #  my $invnum = $self->invnum;
-  my $cust_main = qsearchs('cust_main', { 'custnum', $self->custnum } );
+  my $cust_main = $self->cust_main;
   $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
-    unless $cust_main->payname && $cust_main->payby ne 'CHEK';
+    unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/;
 
   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
@@ -697,46 +1113,49 @@ sub print_text {
     ( grep { ! $_->pkgnum } $self->cust_bill_pkg ),  #then taxes
   ) {
 
-    if ( $cust_bill_pkg->pkgnum ) {
+    my $desc = $cust_bill_pkg->desc;
 
-      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->pkgnum > 0 ) {
 
       if ( $cust_bill_pkg->setup != 0 ) {
-        push @buf, [ "$pkg Setup",
+        my $description = $desc;
+        $description .= ' Setup' if $cust_bill_pkg->recur != 0;
+        push @buf, [ $description,
                      $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
         push @buf,
-          map { [ "  ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels;
+          map { [ "  ". $_->[0]. ": ". $_->[1], '' ] }
+              $cust_bill_pkg->cust_pkg->h_labels($self->_date);
       }
 
       if ( $cust_bill_pkg->recur != 0 ) {
         push @buf, [
-          "$pkg (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
-                                time2str("%x", $cust_bill_pkg->edate) . ")",
+          "$desc (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
+                      time2str("%x", $cust_bill_pkg->edate) . ")",
           $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
         ];
         push @buf,
-          map { [ "  ". $_->[0]. ": ". $_->[1], '' ] } $cust_pkg->labels;
+          map { [ "  ". $_->[0]. ": ". $_->[1], '' ] }
+              $cust_bill_pkg->cust_pkg->h_labels( $cust_bill_pkg->edate,
+                                                  $cust_bill_pkg->sdate );
       }
 
       push @buf, map { [ "  $_", '' ] } $cust_bill_pkg->details;
 
     } 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,
+        push @buf, [ $desc,
                      $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). ")",
+        push @buf, [ "$desc (". time2str("%x", $cust_bill_pkg->sdate). " - "
+                              . time2str("%x", $cust_bill_pkg->edate). ")",
                      $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
                    ];
       }
+
     }
+
   }
 
   push @buf,['','-----------'];
@@ -782,24 +1201,18 @@ sub print_text {
   }
 
   #balance due
-  my $balance_due_msg = 'Balance Due';
-
-  if ( $conf->config('invoice_default_terms') =~ /^\s*Net\s*(\d+)\s*$/ ) {
-    $balance_due_msg .=
-      ' - Please pay by '. time2str("%x", $self->_date + ($1*86400) );
-  } elsif ( $conf->config('invoice_default_terms') ) {
-    $balance_due_msg .= ' - '. $conf->config('invoice_default_terms');
-  }
+  my $balance_due_msg = $self->balance_due_msg;
 
   push @buf,['','-----------'];
   push @buf,[$balance_due_msg, $money_char. 
     sprintf("%10.2f", $balance_due ) ];
 
   #create the template
+  $template ||= $self->_agent_template;
   my $templatefile = 'invoice_template';
-  $templatefile .= "_$template" if $template;
+  $templatefile .= "_$template" if length($template);
   my @invoice_template = $conf->config($templatefile)
-  or die "cannot load config file $templatefile";
+    or die "cannot load config file $templatefile";
   $invoice_lines = 0;
   my $wasfunc = 0;
   foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
@@ -851,8 +1264,10 @@ sub print_text {
     if $cust_main->address2;
   $FS::cust_bill::_template::address[$l++] =
     $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
-  $FS::cust_bill::_template::address[$l++] = $cust_main->country
-    unless $cust_main->country eq 'US';
+
+  my $countrydefault = $conf->config('countrydefault') || 'US';
+  $FS::cust_bill::_template::address[$l++] = code2country($cust_main->country)
+    unless $cust_main->country eq $countrydefault;
 
        #  #overdue? (variable for the template)
        #  $FS::cust_bill::_template::overdue = ( 
@@ -886,9 +1301,12 @@ sub print_text {
 
 }
 
-=item print_ps [ TIME [ , TEMPLATE ] ]
+=item print_latex [ TIME [ , TEMPLATE ] ]
 
-Returns an postscript invoice, as a scalar.
+Internal method - returns a filename of a filled-in LaTeX template for this
+invoice (Note: add ".tex" to get the actual filename).
+
+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.
@@ -897,164 +1315,353 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 
 =cut
 
-#still some false laziness w/print_text
-sub print_ps {
+#still some false laziness w/print_text (mostly print_text should use _items stuff though)
+sub print_latex {
 
   my( $self, $today, $template ) = @_;
   $today ||= time;
+  warn "FS::cust_bill::print_latex called on $self with suffix $template\n"
+    if $DEBUG;
 
-#  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 ne 'CHEK';
+    unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/;
 
   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
   #my $balance_due = $self->owed + $pr_total - $cr_total;
   my $balance_due = $self->owed + $pr_total;
 
-  #my @collect = ();
-  #my($description,$amount);
-  @buf = ();
-
   #create the template
-  my $templatefile = 'invoice_template_latex';
-  $templatefile .= "_$template" if $template;
-  my @invoice_template = $conf->config($templatefile)
+  $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";
 
-  my %invoice_data = (
-    'invnum'   => $self->invnum,
-    'date'     => time2str('%b %o, %Y', $self->_date),
-    'agent'    => $cust_main->agent->agent,
-    'payname'  => $cust_main->payname,
-    'company'  => $cust_main->company,
-    'address1' => $cust_main->address1,
-    'address2' => $cust_main->address2,
-    'city'     => $cust_main->city,
-    'state'    => $cust_main->state,
-    'zip'      => $cust_main->zip,
-    'country'  => $cust_main->country,
-    'footer'   => <<'END', #should come from config value
-Ivan Kohler\\
-1339 Hayes St.\\
-San Francisco, CA~~94117\\
-ivan@sisd.com~~~~+1 415 462 1624\\
-Freeside - open-source billing - http://www.sisd.com/freeside\\
-END
+  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 => [ '[@--', '--@]' ],
+    );
 
-    'quantity'     => 1,
+    $text_template->compile()
+      or die 'While compiling ' . $templatefile . ': ' . $Text::Template::ERROR;
+  }
 
-  );
+  my $returnaddress;
+  if ( length($conf->config_orbase('invoice_latexreturnaddress', $template)) ) {
+    $returnaddress = join("\n",
+      $conf->config_orbase('invoice_latexreturnaddress', $template)
+    );
+  } else {
+    $returnaddress = '~';
+  }
 
-  #$invoice_data{'footer'} =~ s/\n+$//;
+  my %invoice_data = (
+    'invnum'       => $self->invnum,
+    'date'         => time2str('%b %o, %Y', $self->_date),
+    'today'        => time2str('%b %o, %Y', $today),
+    'agent'        => _latex_escape($cust_main->agent->agent),
+    'payname'      => _latex_escape($cust_main->payname),
+    'company'      => _latex_escape($cust_main->company),
+    'address1'     => _latex_escape($cust_main->address1),
+    'address2'     => _latex_escape($cust_main->address2),
+    'city'         => _latex_escape($cust_main->city),
+    'state'        => _latex_escape($cust_main->state),
+    'zip'          => _latex_escape($cust_main->zip),
+    'footer'       => join("\n", $conf->config_orbase('invoice_latexfooter', $template) ),
+    'smallfooter'  => join("\n", $conf->config_orbase('invoice_latexsmallfooter', $template) ),
+    'returnaddress' => $returnaddress,
+    'quantity'     => 1,
+    'terms'        => $conf->config('invoice_default_terms') || 'Payable upon receipt',
+    #'notes'        => join("\n", $conf->config('invoice_latexnotes') ),
+    'conf_dir'     => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
+  );
 
   my $countrydefault = $conf->config('countrydefault') || 'US';
-  $invoice_data{'country'} = '' if $invoice_data{'country'} eq $countrydefault;
+  if ( $cust_main->country eq $countrydefault ) {
+    $invoice_data{'country'} = '';
+  } else {
+    $invoice_data{'country'} = _latex_escape(code2country($cust_main->country));
+  }
+
+  $invoice_data{'notes'} =
+    join("\n",
+#  #do variable substitutions in notes
+#      map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
+        $conf->config_orbase('invoice_latexnotes', $template)
+    );
+  warn "invoice notes: ". $invoice_data{'notes'}. "\n"
+    if $DEBUG;
+
+  $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 )
-      ? "Purchase Order #". $cust_main->payinfo
+      ? _latex_escape("Purchase Order #". $cust_main->payinfo)
       : '~';
 
-  my @line_item = ();
-  my @total_item = ();
   my @filled_in = ();
-  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'} = $line_item->{'description'};
-        if ( exists $line_item->{'ext_description'} ) {
-          $invoice_data{'description'} .=
-            "\\tabularnewline\n~~".
-            join("\\tabularnewline\n~~", @{$line_item->{'ext_description'}} );
+  if ( $format eq 'old' ) {
+  
+    my @line_item = ();
+    my @total_item = ();
+    while ( @invoice_template ) {
+      my $line = shift @invoice_template;
+  
+      if ( $line =~ /^%%Detail\s*$/ ) {
+  
+        while ( ( my $line_item_line = shift @invoice_template )
+                !~ /^%%EndDetail\s*$/                            ) {
+          push @line_item, $line_item_line;
+        }
+        foreach my $line_item ( $self->_items ) {
+        #foreach my $line_item ( $self->_items_pkg ) {
+          $invoice_data{'ref'} = $line_item->{'pkgnum'};
+          $invoice_data{'description'} =
+            _latex_escape($line_item->{'description'});
+          if ( exists $line_item->{'ext_description'} ) {
+            $invoice_data{'description'} .=
+              "\\tabularnewline\n~~".
+              join( "\\tabularnewline\n~~",
+                    map _latex_escape($_), @{$line_item->{'ext_description'}}
+                  );
+          }
+          $invoice_data{'amount'} = $line_item->{'amount'};
+          $invoice_data{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+          push @filled_in,
+            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b } @line_item;
+        }
+  
+      } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
+  
+        while ( ( my $total_item_line = shift @invoice_template )
+                !~ /^%%EndTotalDetails\s*$/                      ) {
+          push @total_item, $total_item_line;
+        }
+  
+        my @total_fill = ();
+  
+        my $taxtotal = 0;
+        foreach my $tax ( $self->_items_tax ) {
+          $invoice_data{'total_item'} = _latex_escape($tax->{'description'});
+          $taxtotal += $tax->{'amount'};
+          $invoice_data{'total_amount'} = '\dollar '. $tax->{'amount'};
+          push @total_fill,
+            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
+                @total_item;
         }
-        $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'} = $tax->{'description'};
-        $taxtotal += ( $invoice_data{'total_amount'} = $tax->{'amount'} );
+        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;
-      }
-
-      $invoice_data{'total_item'} = '\textbf{Total}';
-      $invoice_data{'total_amount'} =
-        '\textbf{\dollar '. sprintf('%.2f', $self->owed ). '}';
-      push @total_fill,
-        map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-            @total_item;
-
-      if ( $taxtotal ) {
-        $invoice_data{'total_item'} = 'Sub-total';
+  
+        #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
+  
+        # credits
+        foreach my $credit ( $self->_items_credits ) {
+          $invoice_data{'total_item'} = _latex_escape($credit->{'description'});
+          #$credittotal
+          $invoice_data{'total_amount'} = '-\dollar '. $credit->{'amount'};
+          push @total_fill, 
+            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
+                @total_item;
+        }
+  
+        # payments
+        foreach my $payment ( $self->_items_payments ) {
+          $invoice_data{'total_item'} = _latex_escape($payment->{'description'});
+          #$paymenttotal
+          $invoice_data{'total_amount'} = '-\dollar '. $payment->{'amount'};
+          push @total_fill, 
+            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
+                @total_item;
+        }
+  
+        $invoice_data{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
         $invoice_data{'total_amount'} =
-          '\dollar '. sprintf('%.2f', $self->owed - $taxtotal );
-        unshift @total_fill,
+          '\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;
       }
+  
+    }
 
-      push @filled_in, @total_fill;
+    sub nounder {
+      my $var = $1;
+      $var =~ s/_/\-/g;
+      $var;
+    }
 
-    } 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;
+  } elsif ( $format eq 'Text::Template' ) {
+
+    my @detail_items = ();
+    my @total_items = ();
+
+    $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'});
+      if ( exists $line_item->{'ext_description'} ) {
+        @{$detail->{'ext_description'}} = map {
+          _latex_escape($_);
+        } @{$line_item->{'ext_description'}};
+      }
+      $detail->{'amount'} = $line_item->{'amount'};
+      $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+  
+      push @detail_items, $detail;
+    }
+  
+  
+    my $taxtotal = 0;
+    foreach my $tax ( $self->_items_tax ) {
+      my $total = {};
+      $total->{'total_item'} = _latex_escape($tax->{'description'});
+      $taxtotal += $tax->{'amount'};
+      $total->{'total_amount'} = '\dollar '. $tax->{'amount'};
+      push @total_items, $total;
+    }
+  
+    if ( $taxtotal ) {
+      my $total = {};
+      $total->{'total_item'} = 'Sub-total';
+      $total->{'total_amount'} =
+        '\dollar '. sprintf('%.2f', $self->charged - $taxtotal );
+      unshift @total_items, $total;
+    }
+  
+    {
+      my $total = {};
+      $total->{'total_item'} = '\textbf{Total}';
+      $total->{'total_amount'} =
+        '\textbf{\dollar '. sprintf('%.2f', $self->charged + $pr_total ). '}';
+      push @total_items, $total;
+    }
+  
+    #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
+  
+    # credits
+    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;
+    }
+  
+    # payments
+    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;
+    }
+  
+    { 
+      my $total;
+      $total->{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
+      $total->{'total_amount'} =
+        '\textbf{\dollar '. sprintf('%.2f', $self->owed + $pr_total ). '}';
+      push @total_items, $total;
     }
 
+  } else {
+    die "guru meditation #54";
   }
 
-  sub nounder {
-    my $var = $1;
-    $var =~ s/_/\-/g;
-    $var;
+  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";
   }
+  close $fh;
 
-  my $dir = '/tmp'; #! /usr/local/etc/freeside/invoices.datasrc/
-  my $unique = int(rand(2**31)); #UGH... use File::Temp or something
+  $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
+  return $1;
 
+}
+
+=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 = $self->print_latex(@_);
+
+  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
   chdir($dir);
-  my $file = $self->invnum. ".$unique";
 
-  open(TEX,">$file.tex") or die "can't open $file.tex: $!\n";
-  print TEX join("\n", @filled_in ), "\n";
-  close TEX;
+  my $sfile = shell_quote $file;
 
-  #error checking!!
-  system('pslatex', "$file.tex");
-  system('pslatex', "$file.tex");
-  #system('dvips', '-t', 'letter', "$file.dvi", "$file.ps");
-  system('dvips', '-t', 'letter', "$file.dvi" );
+  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";
 
-  open(POSTSCRIPT, "<$file.ps") or die "can't open $file.ps: $!\n";
+  system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
+    or die "dvips failed";
 
-  #rm $file.dvi $file.log $file.aux
-  #unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps");
-  unlink("$file.dvi", "$file.log", "$file.aux");
+  open(POSTSCRIPT, "<$file.ps")
+    or die "can't open $file.ps: $! (error in LaTeX template?)\n";
+
+  unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
 
   my $ps = '';
   while (<POSTSCRIPT>) {
@@ -1067,13 +1674,304 @@ END
 
 }
 
+=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 = $self->print_latex(@_);
+
+  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  chdir($dir);
+
+  #system('pdflatex', "$file.tex");
+  #system('pdflatex', "$file.tex");
+  #! LaTeX Error: Unknown graphics extension: .eps.
+
+  my $sfile = shell_quote $file;
+
+  system("pslatex $sfile.tex >/dev/null 2>&1") == 0
+    or die "pslatex $file.tex failed; see $file.log for details?\n";
+  system("pslatex $sfile.tex >/dev/null 2>&1") == 0
+    or die "pslatex $file.tex failed; see $file.log for details?\n";
+
+  #system('dvipdf', "$file.dvi", "$file.pdf" );
+  system(
+    "dvips -q -t letter -f $sfile.dvi ".
+    "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
+    "     -c save pop -"
+  ) == 0
+    or die "dvips | gs failed: $!";
+
+  open(PDF, "<$file.pdf")
+    or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
+
+  unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
+
+  my $pdf = '';
+  while (<PDF>) {
+    $pdf .= $_;
+  }
+
+  close PDF;
+
+  return $pdf;
+
+}
+
+=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, $today, $template, $cid ) = @_;
+  $today ||= time;
+
+  my $cust_main = $self->cust_main;
+  $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
+    unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/;
+
+  $template ||= $self->_agent_template;
+  my $templatefile = 'invoice_html';
+  my $suffix = length($template) ? "_$template" : '';
+  $templatefile .= $suffix;
+  my @html_template = map "$_\n", $conf->config($templatefile)
+    or die "cannot load config file $templatefile";
+
+  my $html_template = new Text::Template(
+    TYPE   => 'ARRAY',
+    SOURCE => \@html_template,
+    DELIMITERS => [ '<%=', '%>' ],
+  );
+
+  $html_template->compile()
+    or die 'While compiling ' . $templatefile . ': ' . $Text::Template::ERROR;
+
+  my %invoice_data = (
+    'invnum'       => $self->invnum,
+    'date'         => time2str('%b&nbsp;%o,&nbsp;%Y', $self->_date),
+    'today'        => time2str('%b %o, %Y', $today),
+    'agent'        => encode_entities($cust_main->agent->agent),
+    'payname'      => encode_entities($cust_main->payname),
+    'company'      => encode_entities($cust_main->company),
+    'address1'     => encode_entities($cust_main->address1),
+    'address2'     => encode_entities($cust_main->address2),
+    'city'         => encode_entities($cust_main->city),
+    'state'        => encode_entities($cust_main->state),
+    'zip'          => encode_entities($cust_main->zip),
+    'terms'        => $conf->config('invoice_default_terms')
+                      || 'Payable upon receipt',
+    'cid'          => $cid,
+    'template'     => $template,
+#    'conf_dir'     => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
+  );
+
+  if (
+         defined( $conf->config_orbase('invoice_htmlreturnaddress', $template) )
+      && length(  $conf->config_orbase('invoice_htmlreturnaddress', $template) )
+  ) {
+    $invoice_data{'returnaddress'} =
+      join("\n", $conf->config('invoice_htmlreturnaddress', $template) );
+  } else {
+    $invoice_data{'returnaddress'} =
+      join("\n", map { 
+                       s/~/&nbsp;/g;
+                       s/\\\\\*?\s*$/<BR>/;
+                       s/\\hyphenation\{[\w\s\-]+\}//;
+                       $_;
+                     }
+                     $conf->config_orbase( 'invoice_latexreturnaddress',
+                                           $template
+                                         )
+          );
+  }
+
+  my $countrydefault = $conf->config('countrydefault') || 'US';
+  if ( $cust_main->country eq $countrydefault ) {
+    $invoice_data{'country'} = '';
+  } else {
+    $invoice_data{'country'} =
+      encode_entities(code2country($cust_main->country));
+  }
+
+  if (
+         defined( $conf->config_orbase('invoice_htmlnotes', $template) )
+      && length(  $conf->config_orbase('invoice_htmlnotes', $template) )
+  ) {
+    $invoice_data{'notes'} =
+      join("\n", $conf->config_orbase('invoice_htmlnotes', $template) );
+  } else {
+    $invoice_data{'notes'} = 
+      join("\n", map { 
+                       s/%%(.*)$/<!-- $1 -->/;
+                       s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/;
+                       s/\\begin\{enumerate\}/<ol>/;
+                       s/\\item /  <li>/;
+                       s/\\end\{enumerate\}/<\/ol>/;
+                       s/\\textbf\{(.*)\}/<b>$1<\/b>/;
+                       $_;
+                     } 
+                     $conf->config_orbase('invoice_latexnotes', $template)
+          );
+  }
+
+#  #do variable substitutions in notes
+#  $invoice_data{'notes'} =
+#    join("\n",
+#      map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
+#        $conf->config_orbase('invoice_latexnotes', $suffix)
+#    );
+
+  if (
+         defined( $conf->config_orbase('invoice_htmlfooter', $template) )
+      && length(  $conf->config_orbase('invoice_htmlfooter', $template) )
+  ) {
+   $invoice_data{'footer'} =
+     join("\n", $conf->config_orbase('invoice_htmlfooter', $template) );
+  } else {
+   $invoice_data{'footer'} =
+       join("\n", map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; }
+                      $conf->config_orbase('invoice_latexfooter', $template)
+           );
+  }
+
+  $invoice_data{'po_line'} =
+    (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
+      ? encode_entities("Purchase Order #". $cust_main->payinfo)
+      : '';
+
+  my $money_char = $conf->config('money_char') || '$';
+
+  foreach my $line_item ( $self->_items ) {
+    my $detail = {
+      ext_description => [],
+    };
+    $detail->{'ref'} = $line_item->{'pkgnum'};
+    $detail->{'description'} = encode_entities($line_item->{'description'});
+    if ( exists $line_item->{'ext_description'} ) {
+      @{$detail->{'ext_description'}} = map {
+        encode_entities($_);
+      } @{$line_item->{'ext_description'}};
+    }
+    $detail->{'amount'} = $money_char. $line_item->{'amount'};
+    $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+
+    push @{$invoice_data{'detail_items'}}, $detail;
+  }
+
+
+  my $taxtotal = 0;
+  foreach my $tax ( $self->_items_tax ) {
+    my $total = {};
+    $total->{'total_item'} = encode_entities($tax->{'description'});
+    $taxtotal += $tax->{'amount'};
+    $total->{'total_amount'} = $money_char. $tax->{'amount'};
+    push @{$invoice_data{'total_items'}}, $total;
+  }
+
+  if ( $taxtotal ) {
+    my $total = {};
+    $total->{'total_item'} = 'Sub-total';
+    $total->{'total_amount'} =
+      $money_char. sprintf('%.2f', $self->charged - $taxtotal );
+    unshift @{$invoice_data{'total_items'}}, $total;
+  }
+
+  my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
+  {
+    my $total = {};
+    $total->{'total_item'} = '<b>Total</b>';
+    $total->{'total_amount'} =
+      "<b>$money_char".  sprintf('%.2f', $self->charged + $pr_total ). '</b>';
+    push @{$invoice_data{'total_items'}}, $total;
+  }
+
+  #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
+
+  # credits
+  foreach my $credit ( $self->_items_credits ) {
+    my $total;
+    $total->{'total_item'} = encode_entities($credit->{'description'});
+    #$credittotal
+    $total->{'total_amount'} = "-$money_char". $credit->{'amount'};
+    push @{$invoice_data{'total_items'}}, $total;
+  }
+
+  # payments
+  foreach my $payment ( $self->_items_payments ) {
+    my $total = {};
+    $total->{'total_item'} = encode_entities($payment->{'description'});
+    #$paymenttotal
+    $total->{'total_amount'} = "-$money_char". $payment->{'amount'};
+    push @{$invoice_data{'total_items'}}, $total;
+  }
+
+  { 
+    my $total;
+    $total->{'total_item'} = '<b>'. $self->balance_due_msg. '</b>';
+    $total->{'total_amount'} =
+      "<b>$money_char".  sprintf('%.2f', $self->owed + $pr_total ). '</b>';
+    push @{$invoice_data{'total_items'}}, $total;
+  }
+
+  $html_template->fill_in( HASH => \%invoice_data);
+}
+
+# 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;
+}
+
 #utility methods for print_*
 
+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;
+}
+
 sub _items {
   my $self = shift;
   my @display = scalar(@_)
                 ? @_
-                : qw( _items_pkg );
+                : qw( _items_previous _items_pkg );
+                #: qw( _items_pkg );
                 #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
   my @b = ();
   foreach my $display ( @display ) {
@@ -1088,13 +1986,28 @@ sub _items_previous {
   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
   my @b = ();
   foreach ( @pr_cust_bill ) {
-    push @b, [
-      "Previous Balance, Invoice #". $_->invnum. 
-                 " (". time2str("%x",$_->_date). ")",
-      $money_char. sprintf("%10.2f",$_->owed)
-    ];
+    push @b, {
+      'description' => 'Previous Balance, Invoice #'. $_->invnum. 
+                       ' ('. time2str('%x',$_->_date). ')',
+      #'pkgpart'     => 'N/A',
+      'pkgnum'      => 'N/A',
+      'amount'      => sprintf("%.2f", $_->owed),
+    };
   }
   @b;
+
+  #{
+  #    'description'     => 'Previous Balance',
+  #    #'pkgpart'         => 'N/A',
+  #    'pkgnum'          => 'N/A',
+  #    'amount'          => sprintf("%10.2f", $pr_total ),
+  #    'ext_description' => [ map {
+  #                                 "Invoice ". $_->invnum.
+  #                                 " (". time2str("%x",$_->_date). ") ".
+  #                                 sprintf("%10.2f", $_->owed)
+  #                         } @pr_cust_bill ],
+
+  #};
 }
 
 sub _items_pkg {
@@ -1116,59 +2029,54 @@ sub _items_cust_bill_pkg {
   my @b = ();
   foreach my $cust_bill_pkg ( @$cust_bill_pkg ) {
 
-    if ( $cust_bill_pkg->pkgnum ) {
+    my $desc = $cust_bill_pkg->desc;
 
-      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->pkgnum > 0 ) {
 
       if ( $cust_bill_pkg->setup != 0 ) {
-        my @d = ();
-        @d = $cust_bill_pkg->details if $cust_bill_pkg->recur == 0;
+        my $description = $desc;
+        $description .= ' Setup' if $cust_bill_pkg->recur != 0;
+        my @d = $cust_bill_pkg->cust_pkg->h_labels_short($self->_date);
+        push @d, $cust_bill_pkg->details if $cust_bill_pkg->recur == 0;
         push @b, {
-          'description'     => "$pkg Setup",
-          'pkgpart'         => $part_pkg->pkgpart,
-          'pkgnum'          => $cust_pkg->pkgnum,
-          'amount'          => sprintf("%10.2f", $cust_bill_pkg->setup),
-          'ext_description' => [ ( map { $_->[0]. ": ". $_->[1] }
-                                         $cust_pkg->labels        ),
-                                 @d,
-                               ],
+          description     => $description,
+          #pkgpart         => $part_pkg->pkgpart,
+          pkgnum          => $cust_bill_pkg->pkgnum,
+          amount          => sprintf("%.2f", $cust_bill_pkg->setup),
+          ext_description => \@d,
         };
       }
 
       if ( $cust_bill_pkg->recur != 0 ) {
         push @b, {
-          'description'     => "$pkg (" .
+          description     => "$desc (" .
                                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' => [ ( map { $_->[0]. ": ". $_->[1] }
-                                       $cust_pkg->labels          ),
-                                 $cust_bill_pkg->details,
-                               ],
+          #pkgpart         => $part_pkg->pkgpart,
+          pkgnum          => $cust_bill_pkg->pkgnum,
+          amount          => sprintf("%.2f", $cust_bill_pkg->recur),
+          ext_description =>
+            [ $cust_bill_pkg->cust_pkg->h_labels_short( $cust_bill_pkg->edate,
+                                                        $cust_bill_pkg->sdate),
+              $cust_bill_pkg->details,
+            ],
         };
       }
 
     } else { #pkgnum tax or one-shot line item (??)
 
-      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),
+          'description' => $desc,
+          'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
         };
       }
       if ( $cust_bill_pkg->recur != 0 ) {
         push @b, {
-          'description' => "$itemdesc (".
+          'description' => "$desc (".
                            time2str("%x", $cust_bill_pkg->sdate). ' - '.
                            time2str("%x", $cust_bill_pkg->edate). ')',
-          'amount'      => sprintf("%10.2f", $cust_bill_pkg->recur),
+          'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
         };
       }
 
@@ -1189,14 +2097,18 @@ sub _items_credits {
 
     #something more elaborate if $_->amount ne $_->cust_credit->credited ?
 
-    my $reason = substr($_->cust_credit->reason,0,32);
-    $reason .= '...' if length($reason) < length($_->cust_credit->reason);
+    my $reason = $_->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,[
-      "Credit #". $_->crednum. " (". time2str("%x",$_->cust_credit->_date) .")".
-        $reason,
-      $money_char. sprintf("%10.2f",$_->amount)
-    ];
+    push @b, {
+      #'description' => 'Credit ref\#'. $_->crednum.
+      #                 " (". time2str("%x",$_->cust_credit->_date) .")".
+      #                 $reason,
+      'description' => 'Credit applied '.
+                       time2str("%x",$_->cust_credit->_date). $reason,
+      'amount'      => sprintf("%.2f",$_->amount),
+    };
   }
   #foreach ( @cr_cust_credit ) {
   #  push @buf,[
@@ -1218,10 +2130,11 @@ sub _items_payments {
 
     #something more elaborate if $_->amount ne ->cust_pay->paid ?
 
-    push @b,[
-      "Payment received ". time2str("%x",$_->cust_pay->_date ),
-      $money_char. sprintf("%10.2f",$_->amount )
-    ];
+    push @b, {
+      'description' => "Payment received ".
+                       time2str("%x",$_->cust_pay->_date ),
+      'amount'      => sprintf("%.2f", $_->amount )
+    };
   }
 
   @b;
@@ -1230,6 +2143,122 @@ sub _items_payments {
 
 =back
 
+=head1 SUBROUTINES
+
+=over 4
+
+=item reprint
+
+=cut
+
+sub process_reprint {
+  process_re_X('print', @_);
+}
+
+=item reemail
+
+=cut
+
+sub process_reemail {
+  process_re_X('email', @_);
+}
+
+=item refax
+
+=cut
+
+sub process_refax {
+  process_re_X('fax', @_);
+}
+
+use Storable qw(thaw);
+use Data::Dumper;
+use MIME::Base64;
+sub process_re_X {
+  my( $method, $job ) = ( shift, shift );
+
+  my $param = thaw(decode_base64(shift));
+  warn Dumper($param) if $DEBUG;
+
+  re_X(
+    $method,
+    $job,
+    %$param,
+  );
+
+}
+
+sub re_X {
+  my($method, $job, %param ) = @_;
+#              [ 'begin', 'end', 'agentnum', 'open', 'days', 'newest_percust' ],
+
+  #some false laziness w/search/cust_bill.html
+  my $distinct = '';
+  my $orderby = 'ORDER BY cust_bill._date';
+
+  my @where;
+
+  if ( $param{'begin'} =~ /^(\d+)$/ ) {
+    push @where, "cust_bill._date >= $1";
+  }
+  if ( $param{'end'} =~ /^(\d+)$/ ) {
+    push @where, "cust_bill._date < $1";
+  }
+  if ( $param{'agentnum'} =~ /^(\d+)$/ ) {
+    push @where, "cust_main.agentnum = $1";
+  }
+
+  my $owed =
+    "charged - ( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
+                 WHERE cust_bill_pay.invnum = cust_bill.invnum )
+             - ( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
+                 WHERE cust_credit_bill.invnum = cust_bill.invnum )";
+
+  push @where, "0 != $owed"
+    if $param{'open'};
+
+  push @where, "cust_bill._date < ". (time-86400*$param{'days'})
+    if $param{'days'};
+
+  my $extra_sql = scalar(@where) ? 'WHERE '. join(' AND ', @where) : '';
+
+  my $addl_from = 'left join cust_main using ( custnum )';
+
+  if ( $param{'newest_percust'} ) {
+    $distinct = 'DISTINCT ON ( cust_bill.custnum )';
+    $orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
+    #$count_query = "SELECT COUNT(DISTINCT cust_bill.custnum), 'N/A', 'N/A'";
+  }
+     
+  my @cust_bill = qsearch( 'cust_bill',
+                           {},
+                           "$distinct cust_bill.*",
+                           $extra_sql,
+                           '',
+                           $addl_from
+                         );
+
+  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 BUGS
 
 The delete method.
@@ -1237,9 +2266,6 @@ 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 ($=).
 
-missing print_ps for a nice postscript copy (maybe HylaFAX-cover-page-style
-or something similar so the look can be completely customized?)
-
 =head1 SEE ALSO
 
 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,