fix picking up alternate invoice_latexnotes_* files, and expand country codes on...
[freeside.git] / FS / FS / cust_bill.pm
index 93c179a..fe5a653 100644 (file)
@@ -1,15 +1,17 @@
 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 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::Record qw( qsearch qsearchs );
-use FS::Misc qw( send_email );
+use FS::Misc qw( send_email send_fax );
 use FS::cust_main;
 use FS::cust_bill_pkg;
 use FS::cust_credit;
@@ -21,6 +23,8 @@ use FS::cust_bill_event;
 
 @ISA = qw( FS::Record );
 
+$DEBUG = 1;
+
 #ask FS::UID to run this stuff for us later
 FS::UID->install_callback( sub { 
   $conf = new FS::Conf;
@@ -318,6 +322,80 @@ sub owed {
   $balance;
 }
 
+
+=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 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 $email_text;
+  if ($conf->exists('invoice_email_pdf')
+      and scalar($conf->config('invoice_email_pdf_note'))) {
+
+    #warn "[FS::cust_bill::send] using 'invoice_email_pdf_note'";
+    $email_text = [ map { $_ . "\n" } $conf->config('invoice_email_pdf_note') ];
+  } 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'}) ];
+    }
+  }
+
+  my @invoicing_list;
+  if (ref($args{'to'} eq 'ARRAY')) {
+    @invoicing_list = @{$args{'to'}};
+  } else {
+    @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
+  }
+
+  return (
+    'from'      => $args{'from'},
+    'to'        => [ @invoicing_list ],
+    'subject'   => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
+    'body'      => $email_text,
+    'mimeparts' => $mimeparts,
+  );
+
+
+}
+
 =item send [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
 
 Sends this invoice to the destinations configured for this customer: send
@@ -344,32 +422,51 @@ sub send {
   my @print_text = $self->print_text('', $template);
   my @invoicing_list = $self->cust_main->invoicing_list;
 
-  if ( grep { $_ ne 'POST' } @invoicing_list or !@invoicing_list  ) { #email
+  if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list  ) {
+    #email
 
     #better to notify this person than silence
     @invoicing_list = ($invoice_from) unless @invoicing_list;
 
     my $error = send_email(
-      'from'    => $invoice_from,
-      'to'      => [ grep { $_ ne 'POST' } @invoicing_list ],
-      'subject' => 'Invoice',
-      'body'    => \@print_text,
+      $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;
 
   }
 
-  if ( grep { $_ eq 'POST' } @invoicing_list ) { #postal
-    @print_text = $self->print_ps('', $template)
-      if $conf->config('invoice_latex');
-    my $lpr = $conf->config('lpr');
-    open(LPR, "|$lpr")
-      or die "Can't open pipe to $lpr: $!\n";
-    print LPR @print_text;
-    close LPR
-      or die $! ? "Error closing $lpr: $!\n"
-                : "Exit status $? from $lpr\n";
+  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;
+    }
+
+    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 ( 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;
+    }
+
   }
 
   '';
@@ -681,7 +778,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,
@@ -935,8 +1032,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 = ( 
@@ -989,8 +1088,9 @@ 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 !~ /^(CHEK|DCHK)$/;
@@ -1000,21 +1100,46 @@ sub print_latex {
   #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
   $template ||= $self->_agent_template;
   my $templatefile = 'invoice_latex';
   my $suffix = length($template) ? "_$template" : '';
   $templatefile .= $suffix;
-  my @invoice_template = $conf->config($templatefile)
+  my @invoice_template = map "$_\n", $conf->config($templatefile)
     or die "cannot load config file $templatefile";
 
+  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 => [ '[@--', '--@]' ],
+    );
+
+    $text_template->compile()
+      or die 'While compiling ' . $templatefile . ': ' . $Text::Template::ERROR;
+  }
+
+  my $returnaddress;
+  if ( $conf->exists('invoice_latexreturnaddress')
+       && length($conf->exists('invoice_latexreturnaddress'))
+     )
+  {
+    $returnaddress = join("\n", $conf->config('invoice_latexreturnaddress') );
+  } else {
+    $returnaddress = '~';
+  }
+
   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),
@@ -1023,23 +1148,30 @@ sub print_latex {
     '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' => $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));
+  }
 
   #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)
+        $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+$//;
@@ -1050,110 +1182,200 @@ sub print_latex {
       ? _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'} = _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'}} );
+  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'} = _latex_escape($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;
-      }
-
-      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->charged - $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;
       }
+  
+    }
 
-      $invoice_data{'total_item'} = '\textbf{Total}';
-      $invoice_data{'total_amount'} =
-        '\textbf{\dollar '. sprintf('%.2f', $self->charged + $pr_total ). '}';
-      push @total_fill,
-        map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-            @total_item;
-
-      #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
-
-      # credits
-      foreach my $credit ( $self->_items_credits ) {
-        $invoice_data{'total_item'} = _latex_escape($credit->{'description'});
-        #$credittotal
-        $invoice_data{'total_amount'} = '-\dollar '. $credit->{'amount'};
-        push @total_fill, 
-          map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-              @total_item;
-      }
+    sub nounder {
+      my $var = $1;
+      $var =~ s/_/\-/g;
+      $var;
+    }
 
-      # 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;
+  } 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'}};
       }
-
-      $invoice_data{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
-      $invoice_data{'total_amount'} =
+      $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_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 @total_items, $total;
     }
 
-  }
-
-  sub nounder {
-    my $var = $1;
-    $var =~ s/_/\-/g;
-    $var;
+  } else {
+    die "guru meditation #54";
   }
 
   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
@@ -1162,7 +1384,13 @@ sub print_latex {
                            SUFFIX   => '.tex',
                            UNLINK   => 0,
                          ) or die "can't open temp file: $!\n";
-  print $fh join("\n", @filled_in ), "\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;
 
   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
@@ -1192,12 +1420,12 @@ sub print_ps {
   my $sfile = shell_quote $file;
 
   system("pslatex $sfile.tex >/dev/null 2>&1") == 0
-    or die "pslatex $file.tex failed: $!";
+    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: $!";
+    or die "pslatex $file.tex failed; see $file.log for details?\n";
 
   system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
-    or die "dvips failed: $!";
+    or die "dvips failed";
 
   open(POSTSCRIPT, "<$file.ps")
     or die "can't open $file.ps: $! (error in LaTeX template?)\n";
@@ -1241,9 +1469,9 @@ sub print_pdf {
   my $sfile = shell_quote $file;
 
   system("pslatex $sfile.tex >/dev/null 2>&1") == 0
-    or die "pslatex $file.tex failed: $!";
+    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: $!";
+    or die "pslatex $file.tex failed; see $file.log for details?\n";
 
   #system('dvipdf', "$file.dvi", "$file.pdf" );
   system(
@@ -1269,6 +1497,239 @@ sub print_pdf {
 
 }
 
+=item print_html [ TIME [ , TEMPLATE ] ]
+
+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.
+
+=cut
+
+#sub print_html {
+#  my $self = shift;
+#
+#  my $file = $self->print_latex(@_);
+#
+#  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+#  chdir($dir);
+#
+#  my $sfile = shell_quote $file;
+#
+#  system("htlatex $sfile.tex") == 0
+#    or die "hlatex $file.tex failed; is hlatex installed, or see $file.log for details?\n";
+#  #system("ltoh $sfile.tex") == 0
+#  #  or die "ltoh $file.tex failed; is hlatex installed, or see $file.log for details?\n";
+#
+#  open(HTML, "<$file.html")
+#    or die "can't open $file.html: $! (error in LaTeX template?)\n";
+#
+#  #unlink("$file.dvi", "$file.log", "$file.aux", "$file.html", "$file.tex");
+#
+#  my $html = '';
+#  while (<HTML>) {
+#
+#    s/<link\s+rel="stylesheet"\s+type="text\/css"\s+href="invoice\.(\d+)\.(\w+)\.css">/<link rel="stylesheet" type="text\/css" href="cust_bill.html?$1.$2.css">/;
+##    s/<link\s+//;
+#    $html .= $_;
+#  }
+#
+#  close HTML;
+#
+#  return $html;
+#
+#}
+#
+##inefficient proof-of-concept for now
+#sub print_html_css {
+#  my $self = shift;
+#
+#  my $file = $self->print_latex(@_);
+#
+#  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+#  chdir($dir);
+#
+#  my $sfile = shell_quote $file;
+#
+#  system("htlatex $sfile.tex") == 0
+#    or die "hlatex $file.tex failed; is hlatex installed, or see $file.log for details?\n";
+#  #system("ltoh $sfile.tex") == 0
+#  #  or die "ltoh $file.tex failed; is hlatex installed, or see $file.log for details?\n";
+#
+#  open(CSS, "<$file.css")
+#    or die "can't open $file.html: $! (error in LaTeX template?)\n";
+#
+#  unlink("$file.dvi", "$file.log", "$file.aux", "$file.html", "$file.tex");
+#
+#  my $css = '';
+#  while (<CSS>) {
+#    $css .= $_;
+#  }
+#
+#  close CSS;
+#
+#  return $css;
+#
+#}
+
+sub print_html {
+  my( $self, $today, $template ) = @_;
+  $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 $returnaddress = $conf->exists('invoice_htmlreturnaddress')
+    ? join("\n", $conf->config('invoice_htmlreturnaddress') )
+    : join("\n", map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; }
+                     $conf->config('invoice_latexreturnaddress')
+          );
+  warn $conf->config('invoice_latexreturnaddress');
+  warn $returnaddress;
+
+  my %invoice_data = (
+    'invnum'       => $self->invnum,
+    'date'         => time2str('%b&nbsp;%o,&nbsp;%Y', $self->_date),
+    '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),
+#    'footer'       => join("\n", $conf->config('invoice_latexfooter') ),
+#    'smallfooter'  => join("\n", $conf->config('invoice_latexsmallfooter') ),
+    'returnaddress' => $returnaddress,
+    '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';
+  if ( $cust_main->country eq $countrydefault ) {
+    $invoice_data{'country'} = '';
+  } else {
+    $invoice_data{'country'} =
+      encode_entities(code2country($cust_main->country));
+  }
+
+  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 )
+      ? 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
@@ -1281,6 +1742,7 @@ sub print_pdf {
 sub _latex_escape {
   my $value = shift;
   $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
+  $value =~ s/([<>])/\$$1\$/g;
   $value;
 }
 
@@ -1323,7 +1785,7 @@ sub _items_previous {
                        ' ('. time2str('%x',$_->_date). ')',
       #'pkgpart'     => 'N/A',
       'pkgnum'      => 'N/A',
-      'amount'      => sprintf("%10.2f", $_->owed),
+      'amount'      => sprintf("%.2f", $_->owed),
     };
   }
   @b;
@@ -1376,7 +1838,7 @@ sub _items_cust_bill_pkg {
           description     => $description,
           #pkgpart         => $part_pkg->pkgpart,
           pkgnum          => $cust_pkg->pkgnum,
-          amount          => sprintf("%10.2f", $cust_bill_pkg->setup),
+          amount          => sprintf("%.2f", $cust_bill_pkg->setup),
           ext_description => \@d,
         };
       }
@@ -1388,7 +1850,7 @@ sub _items_cust_bill_pkg {
                                time2str('%x', $cust_bill_pkg->edate). ')',
           #pkgpart         => $part_pkg->pkgpart,
           pkgnum          => $cust_pkg->pkgnum,
-          amount          => sprintf("%10.2f", $cust_bill_pkg->recur),
+          amount          => sprintf("%.2f", $cust_bill_pkg->recur),
           ext_description => [ $cust_pkg->h_labels_short($cust_bill_pkg->edate,
                                                          $cust_bill_pkg->sdate),
                                $cust_bill_pkg->details,
@@ -1404,7 +1866,7 @@ sub _items_cust_bill_pkg {
       if ( $cust_bill_pkg->setup != 0 ) {
         push @b, {
           'description' => $itemdesc,
-          'amount'      => sprintf("%10.2f", $cust_bill_pkg->setup),
+          'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
         };
       }
       if ( $cust_bill_pkg->recur != 0 ) {
@@ -1412,7 +1874,7 @@ sub _items_cust_bill_pkg {
           'description' => "$itemdesc (".
                            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),
         };
       }
 
@@ -1443,7 +1905,7 @@ sub _items_credits {
       #                 $reason,
       'description' => 'Credit applied '.
                        time2str("%x",$_->cust_credit->_date). $reason,
-      'amount'      => sprintf("%10.2f",$_->amount),
+      'amount'      => sprintf("%.2f",$_->amount),
     };
   }
   #foreach ( @cr_cust_credit ) {
@@ -1469,7 +1931,7 @@ sub _items_payments {
     push @b, {
       'description' => "Payment received ".
                        time2str("%x",$_->cust_pay->_date ),
-      'amount'      => sprintf("%10.2f", $_->amount )
+      'amount'      => sprintf("%.2f", $_->amount )
     };
   }