package categories (meta package classes) and grouping invoices by them
[freeside.git] / FS / FS / cust_bill.pm
index def84f9..4618958 100644 (file)
@@ -1,10 +1,10 @@
 package FS::cust_bill;
 
 use strict;
-use vars qw( @ISA $DEBUG $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 IPC::Run3;
+use List::Util qw(min max);
 use Date::Format;
 use Text::Template 1.20;
 use File::Temp 0.14;
@@ -12,7 +12,7 @@ use String::ShellQuote;
 use HTML::Entities;
 use Locale::Country;
 use FS::UID qw( datasrc );
-use FS::Misc qw( send_email send_fax );
+use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print );
 use FS::Record qw( qsearch qsearchs dbh );
 use FS::cust_main_Mixin;
 use FS::cust_main;
@@ -24,13 +24,17 @@ 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::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 { 
@@ -225,10 +229,50 @@ sub cust_bill_pkg {
   qsearch( 'cust_bill_pkg', { 'invnum' => $self->invnum } );
 }
 
+=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
 
@@ -237,6 +281,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
 
@@ -264,7 +356,7 @@ sub cust_suspend_if_balance_over {
   if ( $cust_main->total_owed_date($self->_date) < $amount ) {
     return ();
   } else {
-    $cust_main->suspend;
+    $cust_main->suspend(@_);
   }
 }
 
@@ -367,6 +459,103 @@ 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
 
@@ -402,7 +591,7 @@ sub generate_email {
     'subject'   => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
   );
 
-  if (ref($args{'to'} eq 'ARRAY')) {
+  if (ref($args{'to'}) eq 'ARRAY') {
     $return{'to'} = $args{'to'};
   } else {
     $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
@@ -453,16 +642,17 @@ sub generate_email {
       'Disposition' => 'inline',
     );
 
-    $args{'from'} =~ /\@([\w\.\-]+)/ or $1 = 'example.com';
-    my $content_id = join('.', rand()*(2**32), $$, time). "\@$1";
+    $args{'from'} =~ /\@([\w\.\-]+)/;
+    my $from = $1 || 'example.com';
+    my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
 
     my $path = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
     my $file;
-    if ( defined($args{'_template'}) && length($args{'_template'})
-         && -e "$path/logo_". $args{'_template'}. ".png"
+    if ( defined($args{'template'}) && length($args{'template'})
+         && -e "$path/logo_". $args{'template'}. ".png"
        )
     {
-      $file = "$path/logo_". $args{'_template'}. ".png";
+      $file = "$path/logo_". $args{'template'}. ".png";
     } else {
       $file = "$path/logo.png";
     }
@@ -608,8 +798,26 @@ single agent) or an arrayref of agentnums.
 
 INVOICE_FROM, if specified, overrides the default email invoice From: address.
 
+AMOUNT, if specified, only sends the invoice if the total amount owed on this
+invoice and all older invoices is greater than the specified amount.
+
 =cut
 
+sub queueable_send {
+  my %opt = @_;
+
+  my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
+    or die "invalid invoice number: " . $opt{invnum};
+
+  my @args = ( $opt{template}, $opt{agentnum} );
+  push @args, $opt{invoice_from}
+    if exists($opt{invoice_from}) && $opt{invoice_from};
+
+  my $error = $self->send( @args );
+  die $error if $error;
+
+}
+
 sub send {
   my $self = shift;
   my $template = scalar(@_) ? shift : '';
@@ -623,15 +831,22 @@ sub send {
       ? shift
       : ( $self->_agent_invoice_from || $conf->config('invoice_from') );
 
+  my $balance_over = ( scalar(@_) && $_[0] !~ /^\s*$/ ) ? shift : 0;
+
+  return ''
+    unless $self->cust_main->total_owed_date($self->_date) > $balance_over;
+
   my @invoicing_list = $self->cust_main->invoicing_list;
 
+  #$self->email_invoice($template, $invoice_from)
   $self->email($template, $invoice_from)
     if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list;
 
+  #$self->print_invoice($template)
   $self->print($template)
     if grep { $_ eq 'POST' } @invoicing_list; #postal
 
-  $self->fax($template)
+  $self->fax_invoice($template)
     if grep { $_ eq 'FAX' } @invoicing_list; #fax
 
   '';
@@ -648,6 +863,22 @@ INVOICE_FROM, if specified, overrides the default email invoice From: address.
 
 =cut
 
+sub queueable_email {
+  my %opt = @_;
+
+  my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
+    or die "invalid invoice number: " . $opt{invnum};
+
+  my @args = ( $opt{template} );
+  push @args, $opt{invoice_from}
+    if exists($opt{invoice_from}) && $opt{invoice_from};
+
+  my $error = $self->email( @args );
+  die $error if $error;
+
+}
+
+#sub email_invoice {
 sub email {
   my $self = shift;
   my $template = scalar(@_) ? shift : '';
@@ -697,22 +928,15 @@ TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
 
 =cut
 
+#sub print_invoice {
 sub print {
   my $self = shift;
   my $template = scalar(@_) ? shift : '';
 
-  my $lpr = $conf->config('lpr');
-
-  my $outerr = '';
-  run3 $lpr, $self->lpr_data($template), \$outerr, \$outerr;
-  if ( $? ) {
-    $outerr = ": $outerr" if length($outerr);
-    die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
-  }
-
+  do_print $self->lpr_data($template);
 }
 
-=item fax [ TEMPLATENAME ] 
+=item fax_invoice [ TEMPLATENAME ] 
 
 Faxes this invoice.
 
@@ -720,7 +944,7 @@ TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
 
 =cut
 
-sub fax {
+sub fax_invoice {
   my $self = shift;
   my $template = scalar(@_) ? shift : '';
 
@@ -1040,11 +1264,7 @@ sub print_csv {
     my $taxtotal = 0;
     $taxtotal += $_->{'amount'} foreach $self->_items_tax;
 
-    my $duedate = '';
-    if (    $conf->exists('invoice_default_terms') 
-         && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
-      $duedate = time2str("%m/%d/%Y", $self->_date + ($1*86400) );
-    }
+    my $duedate = $self->due_date2str('%m/%d/%Y'); #date_format?
 
     my( $previous_balance, @unused ) = $self->previous; #previous balance
 
@@ -1139,7 +1359,7 @@ sub print_csv {
       if ( $cust_bill_pkg->pkgnum ) {
       
         ($pkg, $setup, $recur, $sdate, $edate) = (
-          $cust_bill_pkg->cust_pkg->part_pkg->pkg,
+          $cust_bill_pkg->part_pkg->pkg,
           ( $cust_bill_pkg->setup != 0
             ? sprintf("%.2f", $cust_bill_pkg->setup )
             : '' ),
@@ -1260,7 +1480,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");
@@ -1273,99 +1493,31 @@ 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 $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  my $pay_batch = qsearchs('pay_batch', {'status' => 'O'});
-
-  unless ( $pay_batch ) {
-    $pay_batch = new FS::pay_batch;
-    $pay_batch->setfield('status' => 'O');
-    my $error = $pay_batch->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      die "error creating new batch: $error\n";
-    }
-  }
-
-  my $cust_pay_batch = new FS::cust_pay_batch ( {
-    'batchnum' => $pay_batch->getfield('batchnum'),
-    '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'),
-    'payinfo'  => $cust_main->payinfo,
-    'exp'      => $cust_main->getfield('paydate'),
-    'payname'  => $cust_main->getfield('payname'),
-    'amount'   => $self->owed,
-  } );
-  my $error = $cust_pay_batch->insert;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    die $error;
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
+  $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');
-}
-
-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 '';
-  }
-
+  $self->cust_main->agent_invoice_from;
 }
 
 =item print_text [ TIME [ , TEMPLATE ] ]
@@ -1379,561 +1531,774 @@ 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 = $self->cust_main;
-  $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
-    unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/;
-
-  my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
-#  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
-  #my $balance_due = $self->owed + $pr_total - $cr_total;
-  my $balance_due = $self->owed + $pr_total;
-
-  #my @collect = ();
-  #my($description,$amount);
-  @buf = ();
-
-  #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,['',''];
-  }
-
-  #new charges
-  foreach my $cust_bill_pkg (
-    ( grep {   $_->pkgnum } $self->cust_bill_pkg ),  #packages first
-    ( grep { ! $_->pkgnum } $self->cust_bill_pkg ),  #then taxes
-  ) {
-
-    my $desc = $cust_bill_pkg->desc;
-
-    if ( $cust_bill_pkg->pkgnum > 0 ) {
-
-      if ( $cust_bill_pkg->setup != 0 ) {
-        my $description = $desc;
-        $description .= ' Setup' if $cust_bill_pkg->recur != 0;
-        push @buf, [ $description,
-                     $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
-        push @buf,
-          map { [ "  ". $_->[0]. ": ". $_->[1], '' ] }
-              $cust_bill_pkg->cust_pkg->h_labels($self->_date);
-      }
-
-      if ( $cust_bill_pkg->recur != 0 ) {
-        push @buf, [
-          "$desc (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
-                      time2str("%x", $cust_bill_pkg->edate) . ")",
-          $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
-        ];
-        push @buf,
-          map { [ "  ". $_->[0]. ": ". $_->[1], '' ] }
-              $cust_bill_pkg->cust_pkg->h_labels( $cust_bill_pkg->edate,
-                                                  $cust_bill_pkg->sdate );
-      }
-
-      push @buf, map { [ "  $_", '' ] } $cust_bill_pkg->details;
-
-    } else { #pkgnum tax or one-shot line item
-
-      if ( $cust_bill_pkg->setup != 0 ) {
-        push @buf, [ $desc,
-                     $money_char. sprintf("%10.2f", $cust_bill_pkg->setup) ];
-      }
-      if ( $cust_bill_pkg->recur != 0 ) {
-        push @buf, [ "$desc (". time2str("%x", $cust_bill_pkg->sdate). " - "
-                              . time2str("%x", $cust_bill_pkg->edate). ")",
-                     $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
-                   ];
-      }
-
-    }
-
-  }
 
-  push @buf,['','-----------'];
-  push @buf,['Total New Charges',
-             $money_char. sprintf("%10.2f",$self->charged) ];
-  push @buf,['',''];
+  my %params = ( 'format' => 'template' );
+  $params{'time'} = $today if $today;
+  $params{'template'} = $template if $template;
 
-  push @buf,['','-----------'];
-  push @buf,['Total Charges',
-             $money_char. sprintf("%10.2f",$self->charged + $pr_total) ];
-  push @buf,['',''];
+  $self->print_generic( %params );
+}
 
-  #credits
-  foreach ( $self->cust_credited ) {
+=item print_latex [ TIME [ , TEMPLATE ] ]
 
-    #something more elaborate if $_->amount ne $_->cust_credit->credited ?
+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).
 
-    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)
-  #  ];
-  #}
+See print_ps and print_pdf for methods that return PostScript and PDF output.
 
-  #get & print payments
-  foreach ( $self->cust_bill_pay ) {
+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.
 
-    #something more elaborate if $_->amount ne ->cust_pay->paid ?
+=cut
 
-    push @buf,[
-      "Payment received ". time2str("%x",$_->cust_pay->_date ),
-      $money_char. sprintf("%10.2f",$_->amount )
-    ];
-  }
+sub print_latex {
 
-  #balance due
-  my $balance_due_msg = $self->balance_due_msg;
+  my( $self, $today, $template ) = @_;
 
-  push @buf,['','-----------'];
-  push @buf,[$balance_due_msg, $money_char. 
-    sprintf("%10.2f", $balance_due ) ];
+  my %params = ( 'format' => 'latex' );
+  $params{'time'} = $today if $today;
+  $params{'template'} = $template if $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;
-  }
-  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;
-  } else {
-    $total_pages = 1;
-  }
 
-  #format address (variable for the template)
-  my $l = 0;
-  @address = ( '', '', '', '', '', '' );
-  package FS::cust_bill; #!
-  $FS::cust_bill::_template::address[$l++] =
-    $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
-    if $cust_main->company;
-  $FS::cust_bill::_template::address[$l++] = $cust_main->address1;
-  $FS::cust_bill::_template::address[$l++] = $cust_main->address2
-    if $cust_main->address2;
-  $FS::cust_bill::_template::address[$l++] =
-    $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
+  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 $countrydefault = $conf->config('countrydefault') || 'US';
-  $FS::cust_bill::_template::address[$l++] = code2country($cust_main->country)
-    unless $cust_main->country eq $countrydefault;
-
-       #  #overdue? (variable for the template)
-       #  $FS::cust_bill::_template::overdue = ( 
-       #    $balance_due > 0
-       #    && $today > $self->_date 
-       ##    && $self->printed > 1
-       #    && $self->printed > 0
-       #  );
-
-  #and subroutine for the template
-  sub FS::cust_bill::_template::invoice_lines {
-    my $lines = shift || scalar(@buf);
-    map { 
-      scalar(@buf) ? shift @buf : [ '', '' ];
-    }
-    ( 1 .. $lines );
+  if ($template && $conf->exists("logo_${template}.eps")) {
+    print $lh $conf->config_binary("logo_${template}.eps")
+      or die "can't write temp file: $!\n";
+  }else{
+    print $lh $conf->config_binary('logo.eps')
+      or die "can't write temp file: $!\n";
   }
+  close $lh;
+  $params{'logo_file'} = $lh->filename;
 
-  #and fill it in
-  $FS::cust_bill::_template::page = 1;
-  my $lines;
-  my @collect;
-  while (@buf) {
-    push @collect, split("\n",
-      $invoice_template->fill_in( PACKAGE => 'FS::cust_bill::_template' )
-    );
-    $FS::cust_bill::_template::page++;
-  }
+  my @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;
 
-  map "$_\n", @collect;
+  $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
+  return ($1, $params{'logo_file'});
 
 }
 
-=item print_latex [ TIME [ , TEMPLATE ] ]
+=item print_generic OPTIONS_HASH
 
-Internal method - returns a filename of a filled-in LaTeX template for this
-invoice (Note: add ".tex" to get the actual filename).
+Internal method - returns a filled-in template for this invoice as a scalar.
 
 See print_ps and print_pdf for methods that return PostScript and PDF output.
 
-TIME an optional value used to control the printing of overdue messages.  The
+Non optional options include 
+  format - latex, html, template
+
+Optional options include
+
+template - a value used as a suffix for a configuration template
+
+time - a value used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
+cid - 
+
 =cut
 
-#still some false laziness w/print_text and print_html (and send_csv) (mostly print_text should use _items stuff though)
-sub print_latex {
+sub print_generic {
 
-  my( $self, $today, $template ) = @_;
-  $today ||= time;
-  warn "FS::cust_bill::print_latex called on $self with suffix $template\n"
+  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;
 
+  my $format = $params{format};
+  die "Unknown format: $format"
+    unless $format =~ /^(latex|html|template)$/;
+
   my $cust_main = $self->cust_main;
   $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
     unless $cust_main->payname && $cust_main->payby !~ /^(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 %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
+                     'html'     => [ '<%=', '%>' ],
+                     'template' => [ '{', '}' ],
+                   );
 
   #create the template
-  $template ||= $self->_agent_template;
-  my $templatefile = 'invoice_latex';
-  my $suffix = length($template) ? "_$template" : '';
-  $templatefile .= $suffix;
+  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 file $templatefile";
+    or die "cannot load config data $templatefile";
 
-  my($format, $text_template);
-  if ( grep { /^%%Detail/ } @invoice_template ) {
+  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";
-    $format = 'old';
-  } else {
-    $format = 'Text::Template';
-    $text_template = new Text::Template(
-      TYPE => 'ARRAY',
-      SOURCE => \@invoice_template,
-      DELIMITERS => [ '[@--', '--@]' ],
-    );
+    $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 'While compiling ' . $templatefile . ': ' . $Text::Template::ERROR;
-  }
+  $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\-]+}//;
+                       $_;
+                     }  @_
+                   },
+                 '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 ( length($conf->config_orbase('invoice_latexreturnaddress', $template)) ) {
+  if (
+         defined( $conf->config_orbase( "invoice_${format}returnaddress",
+                                        $template
+                                      )
+                )
+       && length( $conf->config_orbase( "invoice_${format}returnaddress",
+                                        $template
+                                      )
+                )
+  ) {
+
     $returnaddress = join("\n",
-      $conf->config_orbase('invoice_latexreturnaddress', $template)
+      $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') ) {
+
+    my $convert_map = $convert_maps{$format}{'returnaddress'};
+    $returnaddress = join( "\n", &$convert_map(
+                                   map { s/( {2,})/'~' x length($1)/eg;
+                                         s/$/\\\\\*/;
+                                         $_
+                                       }
+                                     ( $conf->config('company_name'),
+                                       $conf->config('company_address'),
+                                     )
+                                 )
+                     );
+
   } else {
-    $returnaddress = '~';
+
+    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 = (
-    '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",
+    'company_name'    => scalar( $conf->config('company_name') ),
+    'company_address' => join("\n", $conf->config('company_address') ). "\n",
+    'custnum'         => $self->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),
+    'returnaddress'   => $returnaddress,
+    #'quantity'        => 1,
+    'terms'           => $self->terms,
+    '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,
+    'ship_enable'     => $conf->exists('invoice-ship_address'),
+    'unitprices'      => $conf->exists('invoice-unitprice'),
   );
 
+  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);
+  }
+  
+  $invoice_data{'cid'} = $params{'cid'}
+    if $params{'cid'};
+
   my $countrydefault = $conf->config('countrydefault') || 'US';
   if ( $cust_main->country eq $countrydefault ) {
     $invoice_data{'country'} = '';
   } else {
-    $invoice_data{'country'} = _latex_escape(code2country($cust_main->country));
+    $invoice_data{'country'} = &$escape_function(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;
+  my @address = ();
+  $invoice_data{'address'} = \@address;
+  push @address,
+    $cust_main->payname.
+      ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
+        ? " (P.O. #". $cust_main->payinfo. ")"
+        : ''
+      )
+  ;
+  push @address, $cust_main->company
+    if $cust_main->company;
+  push @address, $cust_main->address1;
+  push @address, $cust_main->address2
+    if $cust_main->address2;
+  push @address,
+    $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
+  push @address, $invoice_data{'country'}
+    if $invoice_data{'country'};
+  push @address, ''
+    while (scalar(@address) < 5);
 
-  $invoice_data{'footer'} =~ s/\n+$//;
-  $invoice_data{'smallfooter'} =~ s/\n+$//;
-  $invoice_data{'notes'} =~ s/\n+$//;
+  $invoice_data{'logo_file'} = $params{'logo_file'}
+    if $params{'logo_file'};
+
+  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{'balance'} = $balance_due;
+
+  #do variable substitution in notes, footer, smallfooter
+  foreach my $include (qw( notes footer smallfooter coupon )) {
+
+    my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
+    my @inc_src;
+
+    if ( $conf->exists($inc_file) && length( $conf->config($inc_file) ) ) {
+
+      @inc_src = $conf->config($inc_file);
+
+    } else {
+
+      $inc_file = $conf->key_orbase("invoice_latex$include", $template);
+
+      my $convert_map = $convert_maps{$format}{$include};
+
+      @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
+                       s/--\@\]/$delimiters{$format}[1]/g;
+                       $_;
+                     } 
+                 &$convert_map( $conf->config($inc_file) );
+
+    }
+
+    my $inc_tt = new Text::Template (
+      TYPE       => 'ARRAY',
+      SOURCE     => [ map "$_\n", @inc_src ],
+      DELIMITERS => $delimiters{$format},
+    ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
+
+    unless ( $inc_tt->compile() ) {
+      my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
+      warn $error. "Template:\n". join('', map "$_\n", @inc_src);
+      die $error;
+    }
+
+    $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
+
+    $invoice_data{$include} =~ s/\n+$//
+      if ($format eq 'latex');
+  }
 
   $invoice_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;
-        }
+      ? &$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 ',
+                            '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;
   
-      } 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;
-        }
+  my $previous_section = { 'description' => 'Previous Charges',
+                           'subtotal'    => $other_money_char.
+                                            sprintf('%.2f', $pr_total),
+                         };
 
-        if ( $taxtotal ) {
-          $invoice_data{'total_item'} = 'Sub-total';
-          $invoice_data{'total_amount'} =
-            '\dollar '. sprintf('%.2f', $self->charged - $taxtotal );
-          unshift @total_fill,
-            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-                @total_item;
-        }
-  
-        $invoice_data{'total_item'} = '\textbf{Total}';
-        $invoice_data{'total_amount'} =
-          '\textbf{\dollar '. sprintf('%.2f', $self->charged + $pr_total ). '}';
-        push @total_fill,
-          map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-              @total_item;
-  
-        #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
-  
-        # credits
-        foreach my $credit ( $self->_items_credits ) {
-          $invoice_data{'total_item'} = _latex_escape($credit->{'description'});
-          #$credittotal
-          $invoice_data{'total_amount'} = '-\dollar '. $credit->{'amount'};
-          push @total_fill, 
-            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-                @total_item;
-        }
-  
-        # payments
-        foreach my $payment ( $self->_items_payments ) {
-          $invoice_data{'total_item'} = _latex_escape($payment->{'description'});
-          #$paymenttotal
-          $invoice_data{'total_amount'} = '-\dollar '. $payment->{'amount'};
-          push @total_fill, 
-            map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-                @total_item;
-        }
-  
-        $invoice_data{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
-        $invoice_data{'total_amount'} =
-          '\textbf{\dollar '. sprintf('%.2f', $self->owed + $pr_total ). '}';
-        push @total_fill,
-          map { my $b=$_; $b =~ s/\$(\w+)/$invoice_data{$1}/eg; $b }
-              @total_item;
-  
-        push @filled_in, @total_fill;
+  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 $multisection = $conf->exists('invoice_sections', $cust_main->agentnum);
+  if ( $multisection ) {
+    push @sections, $self->_items_sections;
+  }else{
+    push @sections, { 'description' => '', 'subtotal' => '' };
+  }
+
+  foreach my $line_item ( $conf->exists('disable_previous_balance') 
+                            ? ()
+                            : $self->_items_previous
+                        )
+  {
+    my $detail = {
+      ext_description => [],
+    };
+    $detail->{'ref'} = $line_item->{'pkgnum'};
+    $detail->{'quantity'} = 1;
+    $detail->{'section'} = $previous_section;
+    $detail->{'description'} = &$escape_function($line_item->{'description'});
+    if ( exists $line_item->{'ext_description'} ) {
+      @{$detail->{'ext_description'}} = map {
+        &$escape_function($_);
+      } @{$line_item->{'ext_description'}};
+    }
+    $detail->{'amount'} = ( $old_latex ? '' : $money_char).
+                          $line_item->{'amount'};
+    $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
   
-      } 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 @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, ['',''];
+  }
 
-    sub nounder {
-      my $var = $1;
-      $var =~ s/_/\-/g;
-      $var;
-    }
+  foreach my $section (@sections) {
 
-  } elsif ( $format eq 'Text::Template' ) {
+    $section->{'subtotal'} = $other_money_char.
+                             sprintf('%.2f', $section->{'subtotal'})
+      if $multisection;
 
-    my @detail_items = ();
-    my @total_items = ();
+    if ( $section->{'description'} ) {
+      push @buf, ( [ &$escape_function($section->{'description'}), '' ],
+                   [ '', '' ],
+                 );
+    }
 
-    $invoice_data{'detail_items'} = \@detail_items;
-    $invoice_data{'total_items'} = \@total_items;
-  
-    foreach my $line_item ( $self->_items ) {
+    my %options = ();
+    $options{'section'} = $section if $multisection;
+    $options{'format'} = $format;
+    $options{'escape_function'} = $escape_function;
+
+    foreach my $line_item ( $self->_items_pkg(%options) ) {
       my $detail = {
         ext_description => [],
       };
       $detail->{'ref'} = $line_item->{'pkgnum'};
-      $detail->{'quantity'} = 1;
-      $detail->{'description'} = _latex_escape($line_item->{'description'});
+      $detail->{'quantity'} = $line_item->{'quantity'};
+      $detail->{'section'} = $section;
+      $detail->{'description'} = &$escape_function($line_item->{'description'});
       if ( exists $line_item->{'ext_description'} ) {
-        @{$detail->{'ext_description'}} = map {
-          _latex_escape($_);
-        } @{$line_item->{'ext_description'}};
+        @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
       }
-      $detail->{'amount'} = $line_item->{'amount'};
+      $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
+                              $line_item->{'amount'};
+      $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
+                                 $line_item->{'unit_amount'};
       $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
   
       push @detail_items, $detail;
+      push @buf, ( [ $detail->{'description'},
+                     $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+                   ],
+                   map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
+                 );
+    }
+
+    if ( $section->{'description'} ) {
+      push @buf, ( ['','-----------'],
+                   [ $section->{'description'}. ' sub-total',
+                      $money_char. sprintf("%10.2f", $section->{'subtotal'})
+                   ],
+                   [ '', '' ],
+                   [ '', '' ],
+                 );
     }
   
+  }
   
-    my $taxtotal = 0;
-    foreach my $tax ( $self->_items_tax ) {
-      my $total = {};
-      $total->{'total_item'} = _latex_escape($tax->{'description'});
-      $taxtotal += $tax->{'amount'};
-      $total->{'total_amount'} = '\dollar '. $tax->{'amount'};
+  if ( $multisection && !$conf->exists('disable_previous_balance') ) {
+    unshift @sections, $previous_section if $pr_total;
+  }
+
+  foreach my $tax ( $self->_items_tax ) {
+    my $total = {};
+    $total->{'total_item'} = &$escape_function($tax->{'description'});
+    $taxtotal += $tax->{'amount'};
+    $total->{'total_amount'} = $other_money_char. $tax->{'amount'};
+    if ( $multisection ) {
+      my $money = $old_latex ? '' : $money_char;
+      push @detail_items, {
+        ext_description => [],
+        ref          => '',
+        quantity     => '',
+        description  => &$escape_function($tax->{'description'}),
+        amount       => $money. $tax->{'amount'},
+        product_code => '',
+        section      => $tax_section,
+      };
+    }else{
       push @total_items, $total;
     }
+    push @buf,[ $total->{'total_item'},
+                $money_char. sprintf("%10.2f", $total->{'total_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;
     }
+  }
   
-    {
-      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 '.
+                                      $total->{'total_amount'};
+    }else{
       push @total_items, $total;
     }
+    push @buf,['','-----------'];
+    push @buf,['Total Charges',
+               $money_char.
+               sprintf( '%10.2f', $self->charged +
+                                    ( $conf->exists('disable_previous_balance')
+                                        ? 0
+                                        : $pr_total
+                                    )
+                      )
+              ];
+    push @buf,['',''];
+  }
   
+  unless ( $conf->exists('disable_previous_balance') ) {
     #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
   
     # credits
     foreach my $credit ( $self->_items_credits ) {
       my $total;
-      $total->{'total_item'} = _latex_escape($credit->{'description'});
+      $total->{'total_item'} = &$escape_function($credit->{'description'});
       #$credittotal
-      $total->{'total_amount'} = '-\dollar '. $credit->{'amount'};
-      push @total_items, $total;
+      $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;
+      }
     }
   
+    # credits (again)
+    foreach ( $self->cust_credited ) {
+  
+      #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);
+      $reason = " ($reason) " if $reason;
+      push @buf,[
+        "Credit #". $_->crednum. " (". time2str("%x",$_->cust_credit->_date) .")".        $reason,
+        $money_char. sprintf("%10.2f",$_->amount)
+      ];
+    }
+
     # payments
     foreach my $payment ( $self->_items_payments ) {
       my $total = {};
-      $total->{'total_item'} = _latex_escape($payment->{'description'});
+      $total->{'total_item'} = &$escape_function($payment->{'description'});
       #$paymenttotal
-      $total->{'total_amount'} = '-\dollar '. $payment->{'amount'};
-      push @total_items, $total;
+      $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'}),
+                 ];
     }
   
+    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";
   }
 
-  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 ] ]
@@ -1950,35 +2315,11 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 sub print_ps {
   my $self = shift;
 
-  my $file = $self->print_latex(@_);
-
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
-  chdir($dir);
-
-  my $sfile = shell_quote $file;
-
-  system("pslatex $sfile.tex >/dev/null 2>&1") == 0
-    or die "pslatex $file.tex failed; see $file.log for details?\n";
-  system("pslatex $sfile.tex >/dev/null 2>&1") == 0
-    or die "pslatex $file.tex failed; see $file.log for details?\n";
-
-  system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0
-    or die "dvips failed";
-
-  open(POSTSCRIPT, "<$file.ps")
-    or die "can't open $file.ps: $! (error in LaTeX template?)\n";
-
-  unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
-
-  my $ps = '';
-  while (<POSTSCRIPT>) {
-    $ps .= $_;
-  }
-
-  close POSTSCRIPT;
-
-  return $ps;
+  my ($file, $lfile) = $self->print_latex(@_);
+  my $ps = generate_ps($file);
+  unlink($lfile);
 
+  $ps;
 }
 
 =item print_pdf [ TIME [ , TEMPLATE ] ]
@@ -1995,44 +2336,11 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 sub print_pdf {
   my $self = shift;
 
-  my $file = $self->print_latex(@_);
-
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
-  chdir($dir);
-
-  #system('pdflatex', "$file.tex");
-  #system('pdflatex', "$file.tex");
-  #! LaTeX Error: Unknown graphics extension: .eps.
-
-  my $sfile = shell_quote $file;
-
-  system("pslatex $sfile.tex >/dev/null 2>&1") == 0
-    or die "pslatex $file.tex failed; see $file.log for details?\n";
-  system("pslatex $sfile.tex >/dev/null 2>&1") == 0
-    or die "pslatex $file.tex failed; see $file.log for details?\n";
-
-  #system('dvipdf', "$file.dvi", "$file.pdf" );
-  system(
-    "dvips -q -t letter -f $sfile.dvi ".
-    "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
-    "     -c save pop -"
-  ) == 0
-    or die "dvips | gs failed: $!";
-
-  open(PDF, "<$file.pdf")
-    or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
-
-  unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
-
-  my $pdf = '';
-  while (<PDF>) {
-    $pdf .= $_;
-  }
-
-  close PDF;
-
-  return $pdf;
+  my ($file, $lfile) = $self->print_latex(@_);
+  my $pdf = generate_pdf($file);
+  unlink($lfile);
 
+  $pdf;
 }
 
 =item print_html [ TIME [ , TEMPLATE [ , CID ] ] ]
@@ -2049,238 +2357,181 @@ when emailing the invoice as part of a multipart/related MIME email.
 
 =cut
 
-#some falze laziness w/print_text and print_latex (and send_csv)
 sub print_html {
   my( $self, $today, $template, $cid ) = @_;
-  $today ||= time;
 
-  my $cust_main = $self->cust_main;
-  $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
-    unless $cust_main->payname && $cust_main->payby !~ /^(CHEK|DCHK)$/;
+  my %params = ( 'format' => 'html' );
+  $params{'time'} = $today if $today;
+  $params{'template'} = $template if $template;
+  $params{'cid'} = $cid if $cid;
 
-  $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 => [ '<%=', '%>' ],
-  );
+  $self->print_generic( %params );
+}
 
-  $html_template->compile()
-    or die 'While compiling ' . $templatefile . ': ' . $Text::Template::ERROR;
+# 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 ..."). 
 
-  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",
-  );
+sub _latex_escape {
+  my $value = shift;
+  $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
+  $value =~ s/([<>])/\$$1\$/g;
+  $value;
+}
 
-  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
-                                         )
-          );
-  }
+#utility methods for print_*
 
-  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));
-  }
+sub _translate_old_latex_format {
+  warn "_translate_old_latex_format called\n"
+    if $DEBUG; 
 
-  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)
-          );
-  }
+  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, '}',
+                      '--@]';
 
-#  #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)
-#    );
+    } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
 
-  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)
-           );
-  }
+      push @template, '[@--',
+                      '  foreach my $_tr_line (@total_items) {';
 
-  $invoice_data{'po_line'} =
-    (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
-      ? encode_entities("Purchase Order #". $cust_main->payinfo)
-      : '';
+      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 $money_char = $conf->config('money_char') || '$';
+      push @template, '}',
+                      '--@]';
 
-  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'}};
+    } else {
+      $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
+      push @template, $line;  
     }
-    $detail->{'amount'} = $money_char. $line_item->{'amount'};
-    $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
+  
+  }
 
-    push @{$invoice_data{'detail_items'}}, $detail;
+  if ($DEBUG) {
+    warn "$_\n" foreach @template;
   }
 
+  (@template);
+}
 
-  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;
-  }
+sub terms {
+  my $self = shift;
 
-  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;
-  }
+  #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;
 
-  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;
+  #use configured default or default default
+  $conf->config('invoice_default_terms') || 'Payable upon receipt';
+}
+
+sub due_date {
+  my $self = shift;
+  my $duedate = '';
+  if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
+    $duedate = $self->_date() + ( $1 * 86400 );
   }
+  $duedate;
+}
 
-  #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
+sub due_date2str {
+  my $self = shift;
+  $self->due_date ? time2str(shift, $self->due_date) : '';
+}
 
-  # 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;
+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;
+}
 
-  # 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;
-  }
+=item invnum_date_pretty
 
-  { 
-    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;
-  }
+Returns a string with the invoice number and date, for example:
+"Invoice #54 (3/20/2008)"
 
-  $html_template->fill_in( HASH => \%invoice_data);
+=cut
+
+sub invnum_date_pretty {
+  my $self = shift;
+  'Invoice #'. $self->invnum. ' ('. time2str('%x', $self->_date). ')';
 }
 
-# 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 _items_sections {
+  my $self = shift;
 
-sub _latex_escape {
-  my $value = shift;
-  $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
-  $value =~ s/([<>])/\$$1\$/g;
-  $value;
-}
+  my %s = ();
+  foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
 
-#utility methods for print_*
+    if ( $cust_bill_pkg->pkgnum > 0 ) {
+
+      my $desc = $cust_bill_pkg->part_pkg->categoryname;
+
+      $s{$desc} += $cust_bill_pkg->setup
+        if ( $cust_bill_pkg->setup != 0 );
+
+      $s{$desc} += $cust_bill_pkg->recur
+        if ( $cust_bill_pkg->recur != 0 );
+
+    }
 
-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;
+
+  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(@_);
@@ -2320,55 +2571,101 @@ sub _items_previous {
 
 sub _items_pkg {
   my $self = shift;
-  my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
-  $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
+  my %options = @_;
+  my $section = delete $options{'section'};
+  my @cust_bill_pkg =
+    grep { $_->pkgnum &&
+           ( defined($section)
+               ? $_->part_pkg->categoryname eq $section->{'description'}
+               : 1
+           )
+         } $self->cust_bill_pkg;
+  $self->_items_cust_bill_pkg(\@cust_bill_pkg, %options);
+}
+
+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 @b = ();
   foreach my $cust_bill_pkg ( @$cust_bill_pkg ) {
 
+    my $cust_pkg = $cust_bill_pkg->cust_pkg;
+
     my $desc = $cust_bill_pkg->desc;
 
+    my %details_opt = ( 'format'          => $format,
+                        'escape_function' => $escape_function,
+                      );
+
     if ( $cust_bill_pkg->pkgnum > 0 ) {
 
       if ( $cust_bill_pkg->setup != 0 ) {
+
         my $description = $desc;
         $description .= ' Setup' if $cust_bill_pkg->recur != 0;
-        my @d = $cust_bill_pkg->cust_pkg->h_labels_short($self->_date);
-        push @d, $cust_bill_pkg->details if $cust_bill_pkg->recur == 0;
+
+        my @d = map &{$escape_function}($_),
+                       $cust_pkg->h_labels_short($self->_date);
+        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 ) {
+
+        my $description = $desc;
+        unless ( $conf->exists('disable_line_item_date_ranges') ) {
+          $desc .= " (" . time2str("%x", $cust_bill_pkg->sdate).
+                   " - ". time2str("%x", $cust_bill_pkg->edate). ")";
+        }
+
+        #at least until cust_bill_pkg has "past" ranges in addition to
+        #the "future" sdate/edate ones... see #3032
+        my @d = map &{$escape_function}($_),
+                    $cust_pkg->h_labels_short($self->_date);
+                                              #$cust_bill_pkg->edate,
+                                              #$cust_bill_pkg->sdate),
+        push @d, $cust_bill_pkg->details(%details_opt);
+
         push @b, {
-          description     => "$desc (" .
-                               time2str('%x', $cust_bill_pkg->sdate). ' - '.
-                               time2str('%x', $cust_bill_pkg->edate). ')',
+          description     => $description,
           #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,
-            ],
+          unit_amount     => sprintf("%.2f", $cust_bill_pkg->unitrecur),
+          quantity        => $cust_bill_pkg->quantity,
+          ext_description => \@d,
         };
+
       }
 
     } else { #pkgnum tax or one-shot line item (??)
@@ -2485,7 +2782,7 @@ use Data::Dumper;
 use MIME::Base64;
 sub process_re_X {
   my( $method, $job ) = ( shift, shift );
-  warn "process_re_X $method for job $job\n" if $DEBUG;
+  warn "$me process_re_X $method for job $job\n" if $DEBUG;
 
   my $param = thaw(decode_base64(shift));
   warn Dumper($param) if $DEBUG;
@@ -2500,7 +2797,6 @@ sub process_re_X {
 
 sub re_X {
   my($method, $job, %param ) = @_;
-#              [ 'begin', 'end', 'agentnum', 'open', 'days', 'newest_percust' ],
   if ( $DEBUG ) {
     warn "re_X $method for job $job with param:\n".
          join( '', map { "  $_ => ". $param{$_}. "\n" } keys %param );
@@ -2510,47 +2806,22 @@ sub re_X {
   my $distinct = '';
   my $orderby = 'ORDER BY cust_bill._date';
 
-  my @where;
-
-  if ( $param{'begin'} =~ /^(\d+)$/ ) {
-    push @where, "cust_bill._date >= $1";
-  }
-  if ( $param{'end'} =~ /^(\d+)$/ ) {
-    push @where, "cust_bill._date < $1";
-  }
-  if ( $param{'agentnum'} =~ /^(\d+)$/ ) {
-    push @where, "cust_main.agentnum = $1";
-  }
-
-  my $owed =
-    "charged - ( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
-                 WHERE cust_bill_pay.invnum = cust_bill.invnum )
-             - ( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
-                 WHERE cust_credit_bill.invnum = cust_bill.invnum )";
-
-  push @where, "0 != $owed"
-    if $param{'open'};
-
-  push @where, "cust_bill._date < ". (time-86400*$param{'days'})
-    if $param{'days'};
-
-  my $extra_sql = scalar(@where) ? 'WHERE '. join(' AND ', @where) : '';
-
-  my $addl_from = 'left join cust_main using ( custnum )';
+  my $extra_sql = ' WHERE '. FS::cust_bill->search_sql(\%param);
 
-  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 $addl_from = 'LEFT JOIN cust_main USING ( custnum )';
      
-  my @cust_bill = qsearch( 'cust_bill',
-                           {},
-                           "$distinct cust_bill.*",
-                           $extra_sql,
-                           '',
-                           $addl_from
-                         );
+  my @cust_bill = qsearch( {
+    #'select'    => "cust_bill.*",
+    'table'     => 'cust_bill',
+    'addl_from' => $addl_from,
+    'hashref'   => {},
+    'extra_sql' => $extra_sql,
+    'order_by'  => $orderby,
+    'debug' => 1,
+  } );
+
+  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 ) {
@@ -2573,13 +2844,175 @@ sub re_X {
 
 =back
 
+=head1 CLASS METHODS
+
+=over 4
+
+=item owed_sql
+
+Returns an SQL fragment to retreive the amount owed (charged minus credited and paid).
+
+=cut
+
+sub owed_sql {
+  my $class = shift;
+  'charged - '. $class->paid_sql. ' - '. $class->credited_sql;
+}
+
+=item net_sql
+
+Returns an SQL fragment to retreive the net amount (charged minus credited).
+
+=cut
+
+sub net_sql {
+  my $class = shift;
+  'charged - '. $class->credited_sql;
+}
+
+=item paid_sql
+
+Returns an SQL fragment to retreive the amount paid against this invoice.
+
+=cut
+
+sub paid_sql {
+  #my $class = shift;
+  "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
+       WHERE cust_bill.invnum = cust_bill_pay.invnum   )";
+}
+
+=item credited_sql
+
+Returns an SQL fragment to retreive the amount credited against this invoice.
+
+=cut
+
+sub credited_sql {
+  #my $class = shift;
+  "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
+       WHERE cust_bill.invnum = cust_credit_bill.invnum   )";
+}
+
+=item search_sql HASHREF
+
+Class method which returns an SQL WHERE fragment to search for parameters
+specified in HASHREF.  Valid parameters are
+
+=over 4
+
+=item begin
+
+Epoch date (UNIX timestamp) setting a lower bound for _date values
+
+=item end
+
+Epoch date (UNIX timestamp) setting an upper bound for _date values
+
+=item invnum_min
+
+=item invnum_max
+
+=item agentnum
+
+=item owed
+
+=item net
+
+=item days
+
+=item newest_percust
+
+=back
+
+Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
+
+=cut
+
+sub search_sql {
+  my($class, $param) = @_;
+  if ( $DEBUG ) {
+    warn "$me search_sql called with params: \n".
+         join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
+  }
+
+  my @search = ();
+
+  if ( $param->{'begin'} =~ /^(\d+)$/ ) {
+    push @search, "cust_bill._date >= $1";
+  }
+  if ( $param->{'end'} =~ /^(\d+)$/ ) {
+    push @search, "cust_bill._date < $1";
+  }
+  if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) {
+    push @search, "cust_bill.invnum >= $1";
+  }
+  if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) {
+    push @search, "cust_bill.invnum <= $1";
+  }
+  if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
+    push @search, "cust_main.agentnum = $1";
+  }
+
+  push @search, '0 != '. FS::cust_bill->owed_sql
+    if $param->{'open'};
+
+  push @search, '0 != '. FS::cust_bill->net_sql
+    if $param->{'net'};
+
+  push @search, "cust_bill._date < ". (time-86400*$param->{'days'})
+    if $param->{'days'};
+
+  if ( $param->{'newest_percust'} ) {
+
+    #$distinct = 'DISTINCT ON ( cust_bill.custnum )';
+    #$orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
+
+    my @newest_where = map { my $x = $_;
+                             $x =~ s/\bcust_bill\./newest_cust_bill./g;
+                             $x;
+                           }
+                           grep ! /^cust_main./, @search;
+    my $newest_where = scalar(@newest_where)
+                         ? ' AND '. join(' AND ', @newest_where)
+                        : '';
+
+
+    push @search, "cust_bill._date = (
+      SELECT(MAX(newest_cust_bill._date)) FROM cust_bill AS newest_cust_bill
+        WHERE newest_cust_bill.custnum = cust_bill.custnum
+          $newest_where
+    )";
+
+  }
+
+  my $curuser = $FS::CurrentUser::CurrentUser;
+  if ( $curuser->username eq 'fs_queue'
+       && $param->{'CurrentUser'} =~ /^(\w+)$/ ) {
+    my $username = $1;
+    my $newuser = qsearchs('access_user', {
+      'username' => $username,
+      'disabled' => '',
+    } );
+    if ( $newuser ) {
+      $curuser = $newuser;
+    } else {
+      warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n";
+    }
+  }
+
+  push @search, $curuser->agentnums_sql;
+
+  join(' AND ', @search );
+
+}
+
+=back
+
 =head1 BUGS
 
 The delete method.
 
-print_text formatting (and some logic :/) is in source, but needs to be
-slurped in from a file.  Also number of lines ($=).
-
 =head1 SEE ALSO
 
 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill_pay>, L<FS::cust_pay>,