delete invoices, RT#4048
[freeside.git] / FS / FS / cust_bill.pm
index c1724af..e6d0b0d 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,8 +12,8 @@ use String::ShellQuote;
 use HTML::Entities;
 use Locale::Country;
 use FS::UID qw( datasrc );
-use FS::Misc qw( send_email send_fax );
-use FS::Record qw( qsearch qsearchs );
+use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print );
+use FS::Record qw( qsearch qsearchs dbh );
 use FS::cust_main_Mixin;
 use FS::cust_main;
 use FS::cust_bill_pkg;
@@ -21,15 +21,19 @@ use FS::cust_credit;
 use FS::cust_pay;
 use FS::cust_pkg;
 use FS::cust_credit_bill;
+use FS::pay_batch;
 use FS::cust_pay_batch;
 use FS::cust_bill_event;
 use FS::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 { 
@@ -121,15 +125,63 @@ returns the error, otherwise returns false.
 
 =item delete
 
-Currently unimplemented.  I don't remove invoices because there would then be
-no record you ever posted this invoice (which is bad, no?)
+This method now works but you probably shouldn't use it.  Instead, apply a
+credit against the invoice.
+
+Using this method to delete invoices outright is really, really bad.  There
+would be no record you ever posted this invoice, and there are no check to
+make sure charged = 0 or that there are no associated cust_bill_pkg records.
+
+Really, don't use it.
 
 =cut
 
 sub delete {
   my $self = shift;
   return "Can't delete closed invoice" if $self->closed =~ /^Y/i;
-  $self->SUPER::delete(@_);
+
+  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;
+
+  foreach my $table (qw(
+    cust_bill_event
+    cust_credit_bill
+    cust_bill_pay
+    cust_bill_pay
+    cust_credit_bill
+    cust_pay_batch
+    cust_bill_pay_batch
+    cust_bill_pkg
+  )) {
+
+    foreach my $linked ( $self->$table() ) {
+      my $error = $linked->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
+  }
+
+  my $error = $self->SUPER::delete(@_);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
 }
 
 =item replace OLD_RECORD
@@ -142,14 +194,20 @@ collect method of a customer object (see L<FS::cust_main>).
 
 =cut
 
-sub replace {
+#replace can be inherited from Record.pm
+
+# replace_check is now the preferred way to #implement replace data checks
+# (so $object->replace() works without an argument)
+
+sub replace_check {
   my( $new, $old ) = ( shift, shift );
   return "Can't change custnum!" unless $old->custnum == $new->custnum;
   #return "Can't change _date!" unless $old->_date eq $new->_date;
   return "Can't change _date!" unless $old->_date == $new->_date;
-  return "Can't change charged!" unless $old->charged == $new->charged;
+  return "Can't change charged!" unless $old->charged == $new->charged
+                                     || $old->charged == 0;
 
-  $new->SUPER::replace($old);
+  '';
 }
 
 =item check
@@ -212,6 +270,47 @@ 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
@@ -236,6 +335,25 @@ sub cust_main {
   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
 }
 
+=item cust_suspend_if_balance_over AMOUNT
+
+Suspends the customer associated with this invoice if the total amount owed on
+this invoice and all older invoices is greater than the specified amount.
+
+Returns a list: an empty list on success or a list of errors.
+
+=cut
+
+sub cust_suspend_if_balance_over {
+  my( $self, $amount ) = ( shift, shift );
+  my $cust_main = $self->cust_main;
+  if ( $cust_main->total_owed_date($self->_date) < $amount ) {
+    return ();
+  } else {
+    $cust_main->suspend(@_);
+  }
+}
+
 =item cust_credit
 
 Depreciated.  See the cust_credited method.
@@ -277,6 +395,16 @@ sub cust_pay {
   #;
 }
 
+sub cust_pay_batch {
+  my $self = shift;
+  qsearch('cust_pay_batch', { 'invnum' => $self->invnum } );
+}
+
+sub cust_bill_pay_batch {
+  my $self = shift;
+  qsearch('cust_bill_pay_batch', { 'invnum' => $self->invnum } );
+}
+
 =item cust_bill_pay
 
 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice.
@@ -291,6 +419,8 @@ sub cust_bill_pay {
 
 =item cust_credited
 
+=item cust_credit_bill
+
 Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice.
 
 =cut
@@ -302,6 +432,10 @@ sub cust_credited {
   ;
 }
 
+sub cust_credit_bill {
+  shift->cust_credited(@_);
+}
+
 =item tax
 
 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
@@ -335,6 +469,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
 
@@ -370,7 +601,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)$/ }
@@ -421,16 +652,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";
     }
@@ -559,7 +791,7 @@ sub mimebuild_pdf {
     'Encoding'    => 'base64',
     'Data'        => [ $self->print_pdf(@_) ],
     'Disposition' => 'attachment',
-    'Filename'    => 'invoice.pdf',
+    'Filename'    => 'invoice-'. $self->invnum. '.pdf',
   );
 }
 
@@ -576,8 +808,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 : '';
@@ -591,15 +841,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
 
   '';
@@ -616,6 +873,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 : '';
@@ -630,10 +903,13 @@ sub email {
   #better to notify this person than silence
   @invoicing_list = ($invoice_from) unless @invoicing_list;
 
+  my $subject = $self->email_subject($template);
+
   my $error = send_email(
     $self->generate_email(
       'from'       => $invoice_from,
       'to'         => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
+      'subject'    => $subject,
       'template'   => $template,
     )
   );
@@ -642,6 +918,23 @@ sub email {
 
 }
 
+sub email_subject {
+  my $self = shift;
+
+  #my $template = scalar(@_) ? shift : '';
+  #per-template?
+
+  my $subject = $conf->config('invoice_subject') || 'Invoice';
+
+  my $cust_main = $self->cust_main;
+  my $name = $cust_main->name;
+  my $name_short = $cust_main->name_short;
+  my $invoice_number = $self->invnum;
+  my $invoice_date = $self->_date_pretty;
+
+  eval qq("$subject");
+}
+
 =item lpr_data [ TEMPLATENAME ]
 
 Returns the postscript or plaintext for this invoice as an arrayref.
@@ -665,22 +958,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.
 
@@ -688,7 +974,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 : '';
 
@@ -705,6 +991,46 @@ sub fax {
 
 }
 
+=item ftp_invoice [ TEMPLATENAME ] 
+
+Sends this invoice data via FTP.
+
+TEMPLATENAME is unused?
+
+=cut
+
+sub ftp_invoice {
+  my $self = shift;
+  my $template = scalar(@_) ? shift : '';
+
+  $self->send_csv(
+    'protocol'   => 'ftp',
+    'server'     => $conf->config('cust_bill-ftpserver'),
+    'username'   => $conf->config('cust_bill-ftpusername'),
+    'password'   => $conf->config('cust_bill-ftppassword'),
+    'dir'        => $conf->config('cust_bill-ftpdir'),
+    'format'     => $conf->config('cust_bill-ftpformat'),
+  );
+}
+
+=item spool_invoice [ TEMPLATENAME ] 
+
+Spools this invoice data (see L<FS::spool_csv>)
+
+TEMPLATENAME is unused?
+
+=cut
+
+sub spool_invoice {
+  my $self = shift;
+  my $template = scalar(@_) ? shift : '';
+
+  $self->spool_csv(
+    'format'       => $conf->config('cust_bill-spoolformat'),
+    'agent_spools' => $conf->exists('cust_bill-spoolagent'),
+  );
+}
+
 =item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
 
 Like B<send>, but only sends the invoice if it is the newest open invoice for
@@ -739,16 +1065,9 @@ server
 username
 password
 dir
-format - 'default' or 'billco'
 
-#???
-If I<format> is not specified or "default", the file will be named
-"N-YYYYMMDDHHMMSS.csv" where N is the invoice number and YYMMDDHHMMSS is a
-timestamp.
-
-#???
-If I<format> is "billco", two files will be created and uploaded.  They will be named "N-YYYYMMDDHHMMSS-header.csv" and "N-YYYYMMDDHHMMSS-detail.csv" where N
-is the invoice number and YYMMDDHHMMSS is a timestamp(???).
+The file will be named "N-YYYYMMDDHHMMSS.csv" where N is the invoice number
+and YYMMDDHHMMSS is a timestamp.
 
 See L</print_csv> for a description of the output format.
 
@@ -763,27 +1082,13 @@ sub send_csv {
   mkdir $spooldir, 0700 unless -d $spooldir;
 
   my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
-  my $file = "$spooldir/$tracctnum";
-  if ( lc($opt{'format'}) eq 'billco' ) {
-    $file .= '-header.csv';
-  } else {
-    #$file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time);
-    $file .= '.csv';
-  }
+  my $file = "$spooldir/$tracctnum.csv";
   
   my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
 
   open(CSV, ">$file") or die "can't open $file: $!";
   print CSV $header;
 
-  my $oldfile = '';
-  if ( lc($opt{'format'}) eq 'billco' ) {
-    close CSV;
-    $oldfile = $file;
-    $file = "$spooldir/$tracctnum-detail.csv";
-    open(CSV,">$file") or die "can't open $file: $!";
-  }
-
   print CSV $detail;
 
   close CSV;
@@ -804,14 +1109,10 @@ sub send_csv {
 
   $net->cwd($opt{dir}) or die "can't cwd to $opt{dir}";
 
-  if ( $oldfile) {
-    $net->put($oldfile) or die "can't put $oldfile: $!";
-  }
   $net->put($file) or die "can't put $file: $!";
 
   $net->quit;
 
-  unlink $oldfile if $oldfile;
   unlink $file;
 
 }
@@ -828,6 +1129,10 @@ Options are:
 
 =item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the customer has the corresponding invoice destinations set (see L<FS::cust_main_invoice>).
 
+=item agent_spools - if set to a true value, will spool to per-agent files rather than a single global file
+
+=item balanceover - if set, only spools the invoice if the total amount owed on this invoice and all older invoices is greater than the specified amount.
+
 =back
 
 =cut
@@ -835,25 +1140,30 @@ Options are:
 sub spool_csv {
   my($self, %opt) = @_;
 
+  my $cust_main = $self->cust_main;
+
   if ( $opt{'dest'} ) {
-    my %invoicing_list = map { /^(POST|FAX)$/ or $1 = 'EMAIL'; $1 => 1 }
-                             $self->cust_main->invoicing_list;
-    return unless $invoicing_list{$opt{'dest'}};
+    my %invoicing_list = map { /^(POST|FAX)$/ or 'EMAIL' =~ /^(.*)$/; $1 => 1 }
+                             $cust_main->invoicing_list;
+    return 'N/A' unless $invoicing_list{$opt{'dest'}}
+                     || ! keys %invoicing_list;
   }
 
-  #create file(s)
+  if ( $opt{'balanceover'} ) {
+    return 'N/A'
+      if $cust_main->total_owed_date($self->_date) < $opt{'balanceover'};
+  }
 
   my $spooldir = "/usr/local/etc/freeside/export.". datasrc. "/cust_bill";
   mkdir $spooldir, 0700 unless -d $spooldir;
 
   my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
-  my $file = "$spooldir/spool";
-  if ( lc($opt{'format'}) eq 'billco' ) {
-    $file .= '-header.csv';
-  } else {
-    #$file = $spooldir. '/'. $self->invnum. time2str('-%Y%m%d%H%M%S.csv', time);
-    $file .= '.csv';
-  }
+
+  my $file =
+    "$spooldir/".
+    ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
+    ( lc($opt{'format'}) eq 'billco' ? '-header' : '' ) .
+    '.csv';
   
   my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
 
@@ -863,14 +1173,15 @@ sub spool_csv {
 
   print CSV $header;
 
-  my $oldfile = '';
   if ( lc($opt{'format'}) eq 'billco' ) {
 
     flock(CSV, LOCK_UN);
     close CSV;
 
-    $oldfile = $file;
-    $file = "$spooldir/spool-detail.csv";
+    $file =
+      "$spooldir/".
+      ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
+      '-detail.csv';
 
     open(CSV,">>$file") or die "can't open $file: $!";
     flock(CSV, LOCK_EX);
@@ -882,6 +1193,8 @@ sub spool_csv {
   flock(CSV, LOCK_UN);
   close CSV;
 
+  return '';
+
 }
 
 =item print_csv OPTION => VALUE, ...
@@ -1021,11 +1334,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->balance_due_date;
 
     my( $previous_balance, @unused ) = $self->previous; #previous balance
 
@@ -1254,78 +1563,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 $cust_pay_batch = new FS::cust_pay_batch ( {
-    'invnum'   => $self->getfield('invnum'),
-    'custnum'  => $cust_main->getfield('custnum'),
-    'last'     => $cust_main->getfield('last'),
-    'first'    => $cust_main->getfield('first'),
-    'address1' => $cust_main->getfield('address1'),
-    'address2' => $cust_main->getfield('address2'),
-    'city'     => $cust_main->getfield('city'),
-    'state'    => $cust_main->getfield('state'),
-    'zip'      => $cust_main->getfield('zip'),
-    'country'  => $cust_main->getfield('country'),
-    'cardnum'  => $cust_main->payinfo,
-    'exp'      => $cust_main->getfield('paydate'),
-    'payname'  => $cust_main->getfield('payname'),
-    'amount'   => $self->owed,
-  } );
-  my $error = $cust_pay_batch->insert;
-  die $error if $error;
-
-  '';
+  $options{invnum} = $self->invnum;
+  
+  $cust_main->batch_card(%options);
 }
 
 sub _agent_template {
   my $self = shift;
-  $self->_agent_plandata('agent_templatename');
+  $self->cust_main->agent_template;
 }
 
 sub _agent_invoice_from {
   my $self = shift;
-  $self->_agent_plandata('agent_invoice_from');
-}
-
-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 ] ]
@@ -1360,18 +1622,20 @@ sub print_text {
   @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,['',''];
+  unless ($conf->exists('disable_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
@@ -1396,8 +1660,12 @@ sub print_text {
 
       if ( $cust_bill_pkg->recur != 0 ) {
         push @buf, [
-          "$desc (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
-                      time2str("%x", $cust_bill_pkg->edate) . ")",
+          $desc .
+            ( $conf->exists('disable_line_item_date_ranges')
+              ? ''
+              : " (" . time2str("%x", $cust_bill_pkg->sdate) . " - " .
+                       time2str("%x", $cust_bill_pkg->edate) . ")"
+            ),
           $money_char. sprintf("%10.2f", $cust_bill_pkg->recur)
         ];
         push @buf,
@@ -1426,53 +1694,57 @@ sub print_text {
   }
 
   push @buf,['','-----------'];
-  push @buf,['Total New Charges',
+  push @buf,[ ( $conf->exists('disable_previous_balance')
+                ? 'Total Charges'
+                : 'Total New Charges'),
              $money_char. sprintf("%10.2f",$self->charged) ];
   push @buf,['',''];
 
-  push @buf,['','-----------'];
-  push @buf,['Total Charges',
-             $money_char. sprintf("%10.2f",$self->charged + $pr_total) ];
-  push @buf,['',''];
+  unless ($conf->exists('disable_previous_balance')) {
+    push @buf,['','-----------'];
+    push @buf,['Total Charges',
+               $money_char. sprintf("%10.2f",$self->charged + $pr_total) ];
+    push @buf,['',''];
 
-  #credits
-  foreach ( $self->cust_credited ) {
+    #credits
+    foreach ( $self->cust_credited ) {
 
-    #something more elaborate if $_->amount ne $_->cust_credit->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)
-    ];
-  }
-  #foreach ( @cr_cust_credit ) {
-  #  push @buf,[
-  #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
-  #    $money_char. sprintf("%10.2f",$_->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)
+      ];
+    }
+    #foreach ( @cr_cust_credit ) {
+    #  push @buf,[
+    #    "Credit #". $_->crednum. " (" . time2str("%x",$_->_date) .")",
+    #    $money_char. sprintf("%10.2f",$_->credited)
+    #  ];
+    #}
 
-  #get & print payments
-  foreach ( $self->cust_bill_pay ) {
+    #get & print payments
+    foreach ( $self->cust_bill_pay ) {
 
-    #something more elaborate if $_->amount ne ->cust_pay->paid ?
+      #something more elaborate if $_->amount ne ->cust_pay->paid ?
 
-    push @buf,[
-      "Payment received ". time2str("%x",$_->cust_pay->_date ),
-      $money_char. sprintf("%10.2f",$_->amount )
-    ];
-  }
+      push @buf,[
+        "Payment received ". time2str("%x",$_->cust_pay->_date ),
+        $money_char. sprintf("%10.2f",$_->amount )
+      ];
+    }
 
-  #balance due
-  my $balance_due_msg = $self->balance_due_msg;
+    #balance due
+    my $balance_due_msg = $self->balance_due_msg;
 
-  push @buf,['','-----------'];
-  push @buf,[$balance_due_msg, $money_char. 
-    sprintf("%10.2f", $balance_due ) ];
+    push @buf,['','-----------'];
+    push @buf,[$balance_due_msg, $money_char. 
+      sprintf("%10.2f", $balance_due ) ];
+  }
 
   #create the template
   $template ||= $self->_agent_template;
@@ -1497,12 +1769,14 @@ sub print_text {
 
   #setup template variables
   package FS::cust_bill::_template; #!
-  use vars qw( $invnum $date $page $total_pages @address $overdue @buf $agent );
+  use vars qw( $custnum $invnum $date $agent @address $overdue
+               $page $total_pages @buf );
 
+  $custnum = $self->custnum;
   $invnum = $self->invnum;
   $date = $self->_date;
-  $page = 1;
   $agent = $self->cust_main->agent->agent;
+  $page = 1;
 
   if ( $FS::cust_bill::invoice_lines ) {
     $total_pages =
@@ -1635,17 +1909,21 @@ sub print_latex {
   }
 
   my %invoice_data = (
+    'custnum'      => $self->custnum,
     'invnum'       => $self->invnum,
     'date'         => time2str('%b %o, %Y', $self->_date),
     'today'        => time2str('%b %o, %Y', $today),
     'agent'        => _latex_escape($cust_main->agent->agent),
+    'agent_custid' => _latex_escape($cust_main->agent_custid),
     '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),
+    #'quantity'     => 1,
     'zip'          => _latex_escape($cust_main->zip),
+    'fax'          => _latex_escape($cust_main->fax),
     'footer'       => join("\n", $conf->config_orbase('invoice_latexfooter', $template) ),
     'smallfooter'  => join("\n", $conf->config_orbase('invoice_latexsmallfooter', $template) ),
     'returnaddress' => $returnaddress,
@@ -1653,9 +1931,23 @@ sub print_latex {
     '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",
+    'current_charges'  => sprintf('%.2f', $self->charged ),
+    'previous_balance' => sprintf("%.2f", $pr_total),
+    'balance'      => sprintf("%.2f", $balance_due),
+    'duedate'      => $self->balance_due_date,
+    'ship_enable'  => $conf->exists('invoice-ship_address'),
+    'unitprices'   => $conf->exists('invoice-unitprice'),
   );
 
   my $countrydefault = $conf->config('countrydefault') || 'US';
+  my $prefix = $cust_main->has_ship_address ? 'ship_' : '';
+  foreach ( qw( contact company address1 address2 city state zip country fax) ){
+    my $method = $prefix.$_;
+    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
+  }
+  $invoice_data{'ship_country'} = ''
+    if ( $invoice_data{'ship_country'} eq $countrydefault );
+
   if ( $cust_main->country eq $countrydefault ) {
     $invoice_data{'country'} = '';
   } else {
@@ -1671,6 +1963,28 @@ sub print_latex {
   warn "invoice notes: ". $invoice_data{'notes'}. "\n"
     if $DEBUG;
 
+  #do variable substitution in coupon
+  foreach my $include (qw( coupon )) {
+
+    my @inc_src = $conf->config_orbase("invoice_latex$include", $template);
+
+    my $inc_tt = new Text::Template (
+      TYPE       => 'ARRAY',
+      SOURCE     => [ map "$_\n", @inc_src ],
+      DELIMITERS => [ '[@--', '--@]' ],
+    ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
+
+    unless ( $inc_tt->compile() ) {
+      my $error = "Can't compile $include 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+$//
+  }
+
   $invoice_data{'footer'} =~ s/\n+$//;
   $invoice_data{'smallfooter'} =~ s/\n+$//;
   $invoice_data{'notes'} =~ s/\n+$//;
@@ -1694,7 +2008,7 @@ sub print_latex {
                 !~ /^%%EndDetail\s*$/                            ) {
           push @line_item, $line_item_line;
         }
-        foreach my $line_item ( $self->_items ) {
+        foreach my $line_item ( $self->_items ) { #( 'format'=>'latex' ) ) {
         #foreach my $line_item ( $self->_items_pkg ) {
           $invoice_data{'ref'} = $line_item->{'pkgnum'};
           $invoice_data{'description'} =
@@ -1706,7 +2020,9 @@ sub print_latex {
                     map _latex_escape($_), @{$line_item->{'ext_description'}}
                   );
           }
-          $invoice_data{'amount'} = $line_item->{'amount'};
+          $invoice_data{'amount'}       = $line_item->{'amount'};
+          $invoice_data{'unit_amount'}  = $line_item->{'unit_amount'};
+          $invoice_data{'quantity'}     = $line_item->{'quantity'};
           $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;
@@ -1800,7 +2116,8 @@ sub print_latex {
     $invoice_data{'detail_items'} = \@detail_items;
     $invoice_data{'total_items'} = \@total_items;
   
-    foreach my $line_item ( $self->_items ) {
+    my %options = ( 'format' => 'latex', 'escape_function' => \&_latex_escape );
+    foreach my $line_item ( ($conf->exists('disable_previous_balance') ? qw() : $self->_items_previous(%options)), $self->_items_pkg(%options) ) {
       my $detail = {
         ext_description => [],
       };
@@ -1808,11 +2125,10 @@ sub print_latex {
       $detail->{'quantity'} = 1;
       $detail->{'description'} = _latex_escape($line_item->{'description'});
       if ( exists $line_item->{'ext_description'} ) {
-        @{$detail->{'ext_description'}} = map {
-          _latex_escape($_);
-        } @{$line_item->{'ext_description'}};
+        @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
       }
       $detail->{'amount'} = $line_item->{'amount'};
+      $detail->{'unit_amount'} = $line_item->{'unit_amount'};
       $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
   
       push @detail_items, $detail;
@@ -1829,47 +2145,63 @@ sub print_latex {
     }
   
     if ( $taxtotal ) {
+      $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
       my $total = {};
       $total->{'total_item'} = 'Sub-total';
       $total->{'total_amount'} =
         '\dollar '. sprintf('%.2f', $self->charged - $taxtotal );
       unshift @total_items, $total;
+    }else{
+      $invoice_data{'taxtotal'} = '0.00';
     }
   
     {
       my $total = {};
       $total->{'total_item'} = '\textbf{Total}';
       $total->{'total_amount'} =
-        '\textbf{\dollar '. sprintf('%.2f', $self->charged + $pr_total ). '}';
+        '\textbf{\dollar '.
+        sprintf( '%.2f',
+                 $self->charged + ( $conf->exists('disable_previous_balance')
+                                    ? 0
+                                    : $pr_total
+                                  )
+               ).
+      '}';
       push @total_items, $total;
     }
   
-    #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
+    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'});
-      #$credittotal
-      $total->{'total_amount'} = '-\dollar '. $credit->{'amount'};
-      push @total_items, $total;
-    }
+      # credits
+      my $credittotal = 0;
+      foreach my $credit ( $self->_items_credits ) {
+        my $total;
+        $total->{'total_item'} = _latex_escape($credit->{'description'});
+        $credittotal += $credit->{'amount'};
+        $total->{'total_amount'} = '-\dollar '. $credit->{'amount'};
+        push @total_items, $total;
+      }
+      $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
   
-    # 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;
-    }
+      # payments
+      my $paymenttotal = 0;
+      foreach my $payment ( $self->_items_payments ) {
+        my $total = {};
+        $total->{'total_item'} = _latex_escape($payment->{'description'});
+        $paymenttotal += $payment->{'amount'};
+        $total->{'total_amount'} = '-\dollar '. $payment->{'amount'};
+        push @total_items, $total;
+      }
+      $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
   
-    { 
-      my $total;
-      $total->{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
-      $total->{'total_amount'} =
-        '\textbf{\dollar '. sprintf('%.2f', $self->owed + $pr_total ). '}';
-      push @total_items, $total;
+      { 
+        my $total;
+        $total->{'total_item'} = '\textbf{'. $self->balance_due_msg. '}';
+        $total->{'total_amount'} =
+          '\textbf{\dollar '. sprintf('%.2f', $self->owed + $pr_total ). '}';
+        push @total_items, $total;
+      }
     }
 
   } else {
@@ -1911,34 +2243,9 @@ 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 $ps = generate_ps($file);
+  
+  $ps;
 }
 
 =item print_pdf [ TIME [ , TEMPLATE ] ]
@@ -1956,43 +2263,9 @@ 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 $pdf = generate_pdf($file);
+  
+  $pdf;
 }
 
 =item print_html [ TIME [ , TEMPLATE [ , CID ] ] ]
@@ -2035,10 +2308,12 @@ sub print_html {
     or die 'While compiling ' . $templatefile . ': ' . $Text::Template::ERROR;
 
   my %invoice_data = (
+    'custnum'      => $self->custnum,
     'invnum'       => $self->invnum,
     'date'         => time2str('%b&nbsp;%o,&nbsp;%Y', $self->_date),
     'today'        => time2str('%b %o, %Y', $today),
     'agent'        => encode_entities($cust_main->agent->agent),
+    'agent_custid' => encode_entities($cust_main->agent_custid),
     'payname'      => encode_entities($cust_main->payname),
     'company'      => encode_entities($cust_main->company),
     'address1'     => encode_entities($cust_main->address1),
@@ -2046,25 +2321,35 @@ sub print_html {
     'city'         => encode_entities($cust_main->city),
     'state'        => encode_entities($cust_main->state),
     'zip'          => encode_entities($cust_main->zip),
+    'fax'          => encode_entities($cust_main->fax),
     'terms'        => $conf->config('invoice_default_terms')
                       || 'Payable upon receipt',
     'cid'          => $cid,
     'template'     => $template,
+    'ship_enable'  => $conf->exists('invoice-ship_address'),
+    'unitprices'   => $conf->exists('invoice-unitprice'),
 #    'conf_dir'     => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
   );
 
+  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_$_"} = encode_entities($cust_main->$method);
+  }
+
   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) );
+      join("\n", $conf->config_orbase('invoice_htmlreturnaddress', $template) );
   } else {
     $invoice_data{'returnaddress'} =
       join("\n", map { 
                        s/~/&nbsp;/g;
                        s/\\\\\*?\s*$/<BR>/;
                        s/\\hyphenation\{[\w\s\-]+\}//;
+                       s/\\([&])/$1/g;
                        $_;
                      }
                      $conf->config_orbase( 'invoice_latexreturnaddress',
@@ -2090,12 +2375,16 @@ sub print_html {
   } 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>/;
+                       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;
                        $_;
                      } 
                      $conf->config_orbase('invoice_latexnotes', $template)
@@ -2129,16 +2418,15 @@ sub print_html {
 
   my $money_char = $conf->config('money_char') || '$';
 
-  foreach my $line_item ( $self->_items ) {
+  my %options = ( 'format' => 'html', 'escape_function' => \&encode_entities );
+  foreach my $line_item ( ($conf->exists('disable_previous_balance') ? qw() : $self->_items_previous(%options)), $self->_items_pkg(%options) ) {
     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->{'ext_description'}} = @{$line_item->{'ext_description'}};
     }
     $detail->{'amount'} = $money_char. $line_item->{'amount'};
     $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
@@ -2169,36 +2457,45 @@ sub print_html {
     my $total = {};
     $total->{'total_item'} = '<b>Total</b>';
     $total->{'total_amount'} =
-      "<b>$money_char".  sprintf('%.2f', $self->charged + $pr_total ). '</b>';
+      "<b>$money_char".
+      sprintf( '%.2f',
+               $self->charged + ( $conf->exists('disable_previous_balance')
+                                  ? 0
+                                  : $pr_total
+                                )
+             ).
+      '</b>';
     push @{$invoice_data{'total_items'}}, $total;
   }
 
-  #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
+  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'} = encode_entities($credit->{'description'});
-    #$credittotal
-    $total->{'total_amount'} = "-$money_char". $credit->{'amount'};
-    push @{$invoice_data{'total_items'}}, $total;
-  }
+    # 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;
-  }
+    # 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;
+    { 
+      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);
@@ -2234,13 +2531,49 @@ sub balance_due_msg {
   $msg;
 }
 
+sub balance_due_date {
+  my $self = shift;
+  my $duedate = '';
+  if (    $conf->exists('invoice_default_terms') 
+       && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
+    $duedate = time2str("%m/%d/%Y", $self->_date + ($1*86400) );
+  }
+  $duedate;
+}
+
+=item invnum_date_pretty
+
+Returns a string with the invoice number and date, for example:
+"Invoice #54 (3/20/2008)"
+
+=cut
+
+sub invnum_date_pretty {
+  my $self = shift;
+  'Invoice #'. $self->invnum. ' ('. $self->_date_pretty. ')';
+}
+
+=item _date_pretty
+
+Returns a string with the date, for example: "3/20/2008"
+
+=cut
+
+sub _date_pretty {
+  my $self = shift;
+  time2str('%x', $self->_date);
+}
+
 sub _items {
   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(@_);
@@ -2293,42 +2626,81 @@ sub _items_tax {
 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;
+    $desc = substr($desc, 0, 50). '...'
+      if $format eq 'latex' && length($desc) > 50;
+
+    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 = ();
+        push @d, map &{$escape_function}($_),
+                     $cust_pkg->h_labels_short($self->_date)
+          unless $cust_pkg->part_pkg->hide_svc_detail;
+
+        push @d, $cust_bill_pkg->details(%details_opt)
+          if $cust_bill_pkg->recur == 0;
+
         push @b, {
           description     => $description,
           #pkgpart         => $part_pkg->pkgpart,
           pkgnum          => $cust_bill_pkg->pkgnum,
           amount          => sprintf("%.2f", $cust_bill_pkg->setup),
+          unit_amount     => sprintf("%.2f", $cust_bill_pkg->unitsetup),
+          quantity        => $cust_bill_pkg->quantity,
           ext_description => \@d,
         };
       }
 
       if ( $cust_bill_pkg->recur != 0 ) {
+
+        my $description = $desc;
+        unless ( $conf->exists('disable_line_item_date_ranges') ) {
+          $description .= " (" . time2str("%x", $cust_bill_pkg->sdate).
+                          " - ". time2str("%x", $cust_bill_pkg->edate). ")";
+        }
+
+        my @d = ();
+
+        #at least until cust_bill_pkg has "past" ranges in addition to
+        #the "future" sdate/edate ones... see #3032
+        push @d, map &{$escape_function}($_),
+                     $cust_pkg->h_labels_short($self->_date)
+                                               #$cust_bill_pkg->edate,
+                                               #$cust_bill_pkg->sdate),
+          unless $cust_pkg->part_pkg->hide_svc_detail
+              || $cust_bill_pkg->itemdesc;
+
+        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 (??)
@@ -2409,13 +2781,14 @@ sub _items_payments {
 
 }
 
+
 =back
 
 =head1 SUBROUTINES
 
 =over 4
 
-=item reprint
+=item process_reprint
 
 =cut
 
@@ -2423,7 +2796,7 @@ sub process_reprint {
   process_re_X('print', @_);
 }
 
-=item reemail
+=item process_reemail
 
 =cut
 
@@ -2431,7 +2804,7 @@ sub process_reemail {
   process_re_X('email', @_);
 }
 
-=item refax
+=item process_refax
 
 =cut
 
@@ -2439,11 +2812,28 @@ sub process_refax {
   process_re_X('fax', @_);
 }
 
+=item process_reftp
+
+=cut
+
+sub process_reftp {
+  process_re_X('ftp', @_);
+}
+
+=item respool
+
+=cut
+
+sub process_respool {
+  process_re_X('spool', @_);
+}
+
 use Storable qw(thaw);
 use Data::Dumper;
 use MIME::Base64;
 sub process_re_X {
   my( $method, $job ) = ( shift, shift );
+  warn "$me process_re_X $method for job $job\n" if $DEBUG;
 
   my $param = thaw(decode_base64(shift));
   warn Dumper($param) if $DEBUG;
@@ -2458,53 +2848,33 @@ 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 );
+  }
 
   #some false laziness w/search/cust_bill.html
   my $distinct = '';
   my $orderby = 'ORDER BY cust_bill._date';
 
-  my @where;
-
-  if ( $param{'begin'} =~ /^(\d+)$/ ) {
-    push @where, "cust_bill._date >= $1";
-  }
-  if ( $param{'end'} =~ /^(\d+)$/ ) {
-    push @where, "cust_bill._date < $1";
-  }
-  if ( $param{'agentnum'} =~ /^(\d+)$/ ) {
-    push @where, "cust_main.agentnum = $1";
-  }
-
-  my $owed =
-    "charged - ( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
-                 WHERE cust_bill_pay.invnum = cust_bill.invnum )
-             - ( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
-                 WHERE cust_credit_bill.invnum = cust_bill.invnum )";
+  my $extra_sql = ' WHERE '. FS::cust_bill->search_sql(\%param);
 
-  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 @cust_bill = qsearch( {
+    #'select'    => "cust_bill.*",
+    'table'     => 'cust_bill',
+    'addl_from' => $addl_from,
+    'hashref'   => {},
+    'extra_sql' => $extra_sql,
+    'order_by'  => $orderby,
+    'debug' => 1,
+  } );
 
-  my $addl_from = 'left join cust_main using ( custnum )';
+  $method .= '_invoice' unless $method eq 'email' || $method eq 'print';
 
-  if ( $param{'newest_percust'} ) {
-    $distinct = 'DISTINCT ON ( cust_bill.custnum )';
-    $orderby = 'ORDER BY cust_bill.custnum ASC, cust_bill._date DESC';
-    #$count_query = "SELECT COUNT(DISTINCT cust_bill.custnum), 'N/A', 'N/A'";
-  }
-     
-  my @cust_bill = qsearch( 'cust_bill',
-                           {},
-                           "$distinct cust_bill.*",
-                           $extra_sql,
-                           '',
-                           $addl_from
-                         );
+  warn " $me re_X $method: ". scalar(@cust_bill). " invoices found\n"
+    if $DEBUG;
 
   my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
   foreach my $cust_bill ( @cust_bill ) {
@@ -2527,13 +2897,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>,