dangling cust_credit_refund not allowed
[freeside.git] / FS / FS / cust_bill.pm
index a93d175..1317448 100644 (file)
@@ -1,9 +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 List::Util qw(min max);
 use IPC::Run3;
 use Date::Format;
 use Text::Template 1.20;
@@ -26,11 +27,14 @@ 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 { 
@@ -225,6 +229,20 @@ 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.
@@ -291,7 +309,7 @@ sub cust_suspend_if_balance_over {
   if ( $cust_main->total_owed_date($self->_date) < $amount ) {
     return ();
   } else {
-    $cust_main->suspend;
+    $cust_main->suspend(@_);
   }
 }
 
@@ -394,6 +412,83 @@ sub owed {
   $balance;
 }
 
+=item apply_payments_and_credits
+
+=cut
+
+sub apply_payments_and_credits {
+  my $self = shift;
+
+  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;
+    die $error if $error;
+
+  }
+
+}
 
 =item generate_email PARAMHASH
 
@@ -480,16 +575,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";
     }
@@ -637,6 +733,21 @@ INVOICE_FROM, if specified, overrides the default email invoice From: address.
 
 =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 : '';
@@ -675,6 +786,21 @@ 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 {
   my $self = shift;
   my $template = scalar(@_) ? shift : '';
@@ -1300,29 +1426,44 @@ 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 $amount = sprintf("%.2f", $cust_main->balance - $cust_main->in_transit_payments);
   return '' unless $amount > 0;
   
+  if ($options{'realtime'}) {
+    return $cust_main->realtime_bop( FS::payby->payby2bop($cust_main->payby),
+                                     $amount,
+                                     %options,
+                                   );
+  }
+
   my $oldAutoCommit = $FS::UID::AutoCommit;
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $pay_batch = qsearchs('pay_batch', {'status' => 'O'});
+  $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
+    or return "Cannot lock pay_batch: " . $dbh->errstr;
+
+  my %pay_batch = (
+    'status' => 'O',
+    'payby'  => FS::payby->payby2payment($cust_main->payby),
+  );
+
+  my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
 
   unless ( $pay_batch ) {
-    $pay_batch = new FS::pay_batch;
-    $pay_batch->setfield('status' => 'O');
+    $pay_batch = new FS::pay_batch \%pay_batch;
     my $error = $pay_batch->insert;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -1331,26 +1472,29 @@ sub batch_card {
   }
 
   my $old_cust_pay_batch = qsearchs('cust_pay_batch', {
-      'batchnum' => $pay_batch->getfield('batchnum'),
-      'custnum'  => $cust_main->getfield('custnum'),
+      'batchnum' => $pay_batch->batchnum,
+      'custnum'  => $cust_main->custnum,
   } );
 
   my $cust_pay_batch = new FS::cust_pay_batch ( {
-    'batchnum' => $pay_batch->getfield('batchnum'),
+    'batchnum' => $pay_batch->batchnum,
     'invnum'   => $self->getfield('invnum'),       # is there a better value?
-    'custnum'  => $cust_main->getfield('custnum'),
+                                                   # this field should be
+                                                  # removed...
+                                                  # cust_bill_pay_batch now
+    'custnum'  => $cust_main->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'),
+    'address1' => $cust_main->address1,
+    'address2' => $cust_main->address2,
+    'city'     => $cust_main->city,
+    'state'    => $cust_main->state,
+    'zip'      => $cust_main->zip,
+    'country'  => $cust_main->country,
     'payby'    => $cust_main->payby,
     'payinfo'  => $cust_main->payinfo,
-    'exp'      => $cust_main->getfield('paydate'),
-    'payname'  => $cust_main->getfield('payname'),
+    'exp'      => $cust_main->paydate,
+    'payname'  => $cust_main->payname,
     'amount'   => $amount,                          # consolidating
   } );
   
@@ -1369,6 +1513,30 @@ sub batch_card {
     die $error;
   }
 
+  my $unapplied = $cust_main->total_credited + $cust_main->total_unapplied_payments + $cust_main->in_transit_payments;
+  foreach my $cust_bill ($cust_main->open_cust_bill) {
+    #$dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    my $cust_bill_pay_batch = new FS::cust_bill_pay_batch {
+      'invnum' => $cust_bill->invnum,
+      'paybatchnum' => $cust_pay_batch->paybatchnum,
+      'amount' => $cust_bill->owed,
+      '_date' => time,
+    };
+    if ($unapplied >= $cust_bill_pay_batch->amount){
+      $unapplied -= $cust_bill_pay_batch->amount;
+      next;
+    }else{
+      $cust_bill_pay_batch->amount(sprintf ( "%.2f", 
+                                   $cust_bill_pay_batch->amount - $unapplied ));
+      $unapplied = 0;
+    }
+    $error = $cust_bill_pay_batch->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      die $error;
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 }
@@ -1583,12 +1751,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 =
@@ -1657,7 +1827,8 @@ sub print_text {
 =item print_latex [ TIME [ , TEMPLATE ] ]
 
 Internal method - returns a filename of a filled-in LaTeX template for this
-invoice (Note: add ".tex" to get the actual filename).
+invoice (Note: add ".tex" to get the actual filename), and a filename of
+an associated logo (with the .eps extension included).
 
 See print_ps and print_pdf for methods that return PostScript and PDF output.
 
@@ -1721,6 +1892,7 @@ 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),
@@ -1738,6 +1910,7 @@ sub print_latex {
     'quantity'     => 1,
     'terms'        => $conf->config('invoice_default_terms') || 'Payable upon receipt',
     #'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",
   );
 
@@ -1963,6 +2136,22 @@ sub print_latex {
   }
 
   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";
+
+  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;
+  $invoice_data{'logo_file'} = $lh->filename;
+
   my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX',
                            DIR      => $dir,
                            SUFFIX   => '.tex',
@@ -1978,7 +2167,7 @@ sub print_latex {
   close $fh;
 
   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
-  return $1;
+  return ($1, $invoice_data{'logo_file'});
 
 }
 
@@ -1996,7 +2185,7 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 sub print_ps {
   my $self = shift;
 
-  my $file = $self->print_latex(@_);
+  my ($file, $lfile) = $self->print_latex(@_);
 
   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
   chdir($dir);
@@ -2015,6 +2204,7 @@ sub print_ps {
     or die "can't open $file.ps: $! (error in LaTeX template?)\n";
 
   unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex");
+  unlink("$lfile");
 
   my $ps = '';
   while (<POSTSCRIPT>) {
@@ -2041,7 +2231,7 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 sub print_pdf {
   my $self = shift;
 
-  my $file = $self->print_latex(@_);
+  my ($file, $lfile) = $self->print_latex(@_);
 
   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
   chdir($dir);
@@ -2069,6 +2259,7 @@ sub print_pdf {
     or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
 
   unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex");
+  unlink("$lfile");
 
   my $pdf = '';
   while (<PDF>) {
@@ -2121,6 +2312,7 @@ 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),
@@ -2182,6 +2374,7 @@ sub print_html {
                        s/\\item /  <li>/;
                        s/\\end\{enumerate\}/<\/ol>/;
                        s/\\textbf\{(.*)\}/<b>$1<\/b>/;
+                       s/\\\\\*/ /;
                        $_;
                      } 
                      $conf->config_orbase('invoice_latexnotes', $template)