fix race condition where ->apply_payments_and_credits could double-apply in rare...
[freeside.git] / FS / FS / cust_main.pm
index 252a5ca..7238e97 100644 (file)
@@ -24,7 +24,7 @@ use Locale::Country;
 use Data::Dumper;
 use FS::UID qw( getotaker dbh );
 use FS::Record qw( qsearchs qsearch dbdef );
-use FS::Misc qw( send_email );
+use FS::Misc qw( send_email generate_ps do_print );
 use FS::Msgcat qw(gettext);
 use FS::cust_pkg;
 use FS::cust_svc;
@@ -1815,22 +1815,23 @@ sub agent {
 Generates invoices (see L<FS::cust_bill>) for this customer.  Usually used in
 conjunction with the collect method.
 
-Options are passed as name-value pairs.
+If there is an error, returns the error, otherwise returns false.
 
-Currently available options are:
+Options are passed as name-value pairs.  Currently available options are:
 
-resetup - if set true, re-charges setup fees.
+=over 4
+
+=item resetup - if set true, re-charges setup fees.
 
-time - bills the customer as if it were that time.  Specified as a UNIX
-timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and
-L<Date::Parse> for conversion functions.  For example:
+=item time - bills the customer as if it were that time.  Specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.  For example:
 
  use Date::Parse;
  ...
  $cust_main->bill( 'time' => str2time('April 20th, 2001') );
 
+=item invoice_time - used in conjunction with the I<time> option, this option specifies the date of for the generated invoices.  Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
 
-If there is an error, returns the error, otherwise returns false.
+=back
 
 =cut
 
@@ -1863,7 +1864,7 @@ sub bill {
   # no line items] and we're inside a transaciton so nothing else will see it)
   my $cust_bill = new FS::cust_bill ( {
     'custnum' => $self->custnum,
-    '_date'   => $time,
+    '_date'   => ( $options{'invoice_time'} || $time ),
     #'charged' => $charged,
     'charged' => 0,
   } );
@@ -2566,10 +2567,11 @@ sub realtime_bop {
   $content{invoice_number} = $options{'invnum'}
     if exists($options{'invnum'}) && length($options{'invnum'});
 
+  my $paydate = '';
   if ( $method eq 'CC' ) { 
 
     $content{card_number} = $payinfo;
-    my $paydate = exists($options{'paydate'})
+    $paydate = exists($options{'paydate'})
                     ? $options{'paydate'}
                     : $self->paydate;
     $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
@@ -2612,12 +2614,20 @@ sub realtime_bop {
     ( $content{account_number}, $content{routing_code} ) =
       split('@', $payinfo);
     $content{bank_name} = $o_payname;
-    $content{bank_state} = $self->getfield('paystate');
-    $content{account_type} = uc($self->getfield('paytype')) || 'CHECKING';
+    $content{bank_state} = exists($options{'paystate'})
+                             ? $options{'paystate'}
+                             : $self->getfield('paystate');
+    $content{account_type} = exists($options{'paytype'})
+                               ? uc($options{'paytype'}) || 'CHECKING'
+                               : uc($self->getfield('paytype')) || 'CHECKING';
     $content{account_name} = $payname;
     $content{customer_org} = $self->company ? 'B' : 'I';
-    $content{state_id}       = $self->getfield('stateid');
-    $content{state_id_state} = $self->getfield('stateid_state');
+    $content{state_id}       = exists($options{'stateid'})
+                                 ? $options{'stateid'}
+                                 : $self->getfield('stateid');
+    $content{state_id_state} = exists($options{'stateid_state'})
+                                 ? $options{'stateid_state'}
+                                 : $self->getfield('stateid_state');
     $content{customer_ssn} = exists($options{'ss'})
                                ? $options{'ss'}
                                : $self->ss;
@@ -2753,8 +2763,12 @@ sub realtime_bop {
        'payby'    => $method2payby{$method},
        'payinfo'  => $payinfo,
        'paybatch' => $paybatch,
+       'paydate'  => $paydate,
     } );
+    $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
+
     my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
+
     if ( $error ) {
       $cust_pay->invnum(''); #try again with no specific invnum
       my $error2 = $cust_pay->insert( $options{'manual'} ?
@@ -2888,7 +2902,7 @@ L<http://420.am/business-onlinepayment> for supported gateways.
 
 Available methods are: I<CC>, I<ECHECK> and I<LEC>
 
-Available options are: I<amount>, I<reason>, I<paynum>
+Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
 
 Most gateways require a reference to an original payment transaction to refund,
 so you probably need to specify a I<paynum>.
@@ -2897,6 +2911,9 @@ I<amount> defaults to the original amount of the payment if not specified.
 
 I<reason> specifies a reason for the refund.
 
+I<paydate> specifies the expiration date for a credit card overriding the
+value from the customer record or the payment record. Specified as yyyy-mm-dd
+
 Implementation note: If I<amount> is unspecified or equal to the amount of the
 orignal payment, first an attempt is made to "void" the transaction via
 the gateway (to cancel a not-yet settled transaction) and then if that fails,
@@ -3097,17 +3114,24 @@ sub realtime_refund_bop {
 
     if ( $cust_pay ) {
       $content{card_number} = $payinfo = $cust_pay->payinfo;
-      #$self->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
-      #$content{expiration} = "$2/$1";
+      (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
+        =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
+        ($content{expiration} = "$2/$1");  # where available
     } else {
       $content{card_number} = $payinfo = $self->payinfo;
-      $self->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+      (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
+        =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
       $content{expiration} = "$2/$1";
     }
 
   } elsif ( $method eq 'ECHECK' ) {
-    ( $content{account_number}, $content{routing_code} ) =
-      split('@', $payinfo = $self->payinfo);
+
+    if ( $cust_pay ) {
+      $payinfo = $cust_pay->payinfo;
+    } else {
+      $payinfo = $self->payinfo;
+    } 
+    ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
     $content{bank_name} = $self->payname;
     $content{account_type} = 'CHECKING';
     $content{account_name} = $payname;
@@ -3354,15 +3378,37 @@ Applies unapplied payments and credits.
 In most cases, this new method should be used in place of sequential
 apply_payments and apply_credits methods.
 
+If there is an error, returns the error, otherwise returns false.
+
 =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
+
   foreach my $cust_bill ( $self->open_cust_bill ) {
-    $cust_bill->apply_payments_and_credits;
+    my $error = $cust_bill->apply_payments_and_credits;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error applying: $error";
+    }
   }
 
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  ''; #no error
+
 }
 
 =item apply_credits OPTION => VALUE ...
@@ -3373,13 +3419,31 @@ chronological order if the I<order> option is set to B<newest>) and returns the
 value of any remaining unapplied credits available for refund (see
 L<FS::cust_refund>).
 
+Dies if there is an error.
+
 =cut
 
 sub apply_credits {
   my $self = shift;
   my %opt = @_;
 
-  return 0 unless $self->total_credited;
+  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
+
+  unless ( $self->total_credited ) {
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return 0;
+  }
 
   my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
       qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
@@ -3408,13 +3472,20 @@ sub apply_credits {
       'amount'  => $amount,
     } );
     my $error = $cust_credit_bill->insert;
-    die $error if $error;
+    if ( $error ) {
+      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+      die $error;
+    }
     
     redo if ($cust_bill->owed > 0);
 
   }
 
-  return $self->total_credited;
+  my $total_credited = $self->total_credited;
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return $total_credited;
 }
 
 =item apply_payments
@@ -3424,11 +3495,26 @@ to outstanding invoice balances in chronological order.
 
  #and returns the value of any remaining unapplied payments.
 
+Dies if there is an error.
+
 =cut
 
 sub apply_payments {
   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
+
   #return 0 unless
 
   my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
@@ -3458,13 +3544,20 @@ sub apply_payments {
       'amount' => $amount,
     } );
     my $error = $cust_bill_pay->insert;
-    die $error if $error;
+    if ( $error ) {
+      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+      die $error;
+    }
 
     redo if ( $cust_bill->owed > 0);
 
   }
 
-  return $self->total_unapplied_payments;
+  my $total_unapplied_payments = $self->total_unapplied_payments;
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return $total_unapplied_payments;
 }
 
 =item total_credited
@@ -4542,7 +4635,7 @@ use vars qw(@fuzzyfields);
 @fuzzyfields = ( 'last', 'first', 'company' );
 
 sub check_and_rebuild_fuzzyfiles {
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
   rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
 }
 
@@ -4554,7 +4647,7 @@ sub rebuild_fuzzyfiles {
 
   use Fcntl qw(:flock);
 
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
   mkdir $dir, 0700 unless -d $dir;
 
   foreach my $fuzzy ( @fuzzyfields ) {
@@ -4592,7 +4685,7 @@ sub rebuild_fuzzyfiles {
 
 sub all_X {
   my( $self, $field ) = @_;
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
   open(CACHE,"<$dir/cust_main.$field")
     or die "can't open $dir/cust_main.$field: $!";
   my @array = map { chomp; $_; } <CACHE>;
@@ -4611,7 +4704,7 @@ sub append_fuzzyfiles {
 
   use Fcntl qw(:flock);
 
-  my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
 
   foreach my $field (qw( first last company )) {
     my $value = shift;
@@ -4717,7 +4810,7 @@ sub batch_import {
     my %svc_acct = ();
     foreach my $field ( @fields ) {
 
-      if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|expire|cancel)$/ ) {
+      if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) {
 
         #$cust_pkg{$1} = str2time( shift @$columns );
         if ( $1 eq 'pkgpart' ) {
@@ -4806,8 +4899,12 @@ sub batch_import {
         return "can't bill customer for $line: $error";
       }
   
-      $cust_main->apply_payments_and_credits;
-  
+      $error = $cust_main->apply_payments_and_credits;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "can't bill customer for $line: $error";
+      }
+
       $error = $cust_main->collect();
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
@@ -4998,6 +5095,166 @@ sub notify {
 
 }
 
+=item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
+
+Generates a templated notification to the customer (see L<Text::Template>).
+
+OPTIONS is a hash and may include
+
+I<extra_fields> - a hashref of name/value pairs which will be substituted
+   into the template.  These values may override values mentioned below
+   and those from the customer record.
+
+The following variables are available in the template instead of or in addition
+to the fields of the customer record.
+
+I<$payby> - a description of the method of payment for the customer
+            # would be nice to use FS::payby::shortname
+I<$payinfo> - the masked account information used to collect for this customer
+I<$expdate> - the expiration of the customer payment method in seconds from epoch
+I<$returnaddress> - the return address defaults to invoice_latexreturnaddress
+
+=cut
+
+sub generate_letter {
+  my ($self, $template, %options) = @_;
+
+  return unless $conf->exists($template);
+
+  my $letter_template = new Text::Template
+                        ( TYPE       => 'ARRAY',
+                          SOURCE     => [ map "$_\n", $conf->config($template)],
+                          DELIMITERS => [ '[@--', '--@]' ],
+                        )
+    or die "can't create new Text::Template object: Text::Template::ERROR";
+
+  $letter_template->compile()
+    or die "can't compile template: Text::Template::ERROR";
+
+  my %letter_data = map { $_ => $self->$_ } $self->fields;
+  $letter_data{payinfo} = $self->mask_payinfo;
+
+  my $paydate = $self->paydate;
+  my $payby = $self->payby;
+  my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
+  my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
+
+  #credit cards expire at the end of the month/year of their exp date
+  if ($payby eq 'CARD' || $payby eq 'DCRD') {
+    $letter_data{payby} = 'credit card';
+    ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
+    $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
+    $expire_time--;
+  }elsif ($payby eq 'COMP') {
+    $letter_data{payby} = 'complimentary account';
+  }else{
+    $letter_data{payby} = 'current method';
+  }
+  $letter_data{expdate} = $expire_time;
+
+  for (keys %{$options{extra_fields}}){
+    $letter_data{$_} = $options{extra_fields}->{$_};
+  }
+
+  unless(exists($letter_data{returnaddress})){
+    my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress',
+                                                  $self->_agent_template)
+                     );
+
+    $letter_data{returnaddress} = length($retadd) ? $retadd : '~';
+  }
+
+  $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
+
+  my $dir = $FS::UID::conf_dir."cache.". $FS::UID::datasrc;
+  my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
+                           DIR      => $dir,
+                           SUFFIX   => '.tex',
+                           UNLINK   => 0,
+                         ) or die "can't open temp file: $!\n";
+
+  $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
+  close $fh;
+  $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
+  return $1;
+}
+
+=item print_ps TEMPLATE 
+
+Returns an postscript letter filled in from TEMPLATE, as a scalar.
+
+=cut
+
+sub print_ps {
+  my $self = shift;
+  my $file = $self->generate_letter(@_);
+  FS::Misc::generate_ps($file);
+}
+
+=item print TEMPLATE
+
+Prints the filled in template.
+
+TEMPLATE is the name of a L<Text::Template> to fill in and print.
+
+=cut
+
+sub queueable_print {
+  my %opt = @_;
+
+  my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
+    or die "invalid customer number: " . $opt{custvnum};
+
+  my $error = $self->print( $opt{template} );
+  die $error if $error;
+}
+
+sub print {
+  my ($self, $template) = (shift, shift);
+  do_print [ $self->print_ps($template) ];
+}
+
+sub agent_template {
+  my $self = shift;
+  $self->_agent_plandata('agent_templatename');
+}
+
+sub agent_invoice_from {
+  my $self = shift;
+  $self->_agent_plandata('agent_invoice_from');
+}
+
+sub _agent_plandata {
+  my( $self, $option ) = @_;
+
+  my $part_bill_event = qsearchs( 'part_bill_event',
+    {
+      'payby'     => $self->payby,
+      'plan'      => 'send_agent',
+      'plandata'  => { 'op'    => '~',
+                       'value' => "(^|\n)agentnum ".
+                                   '([0-9]*, )*'.
+                                  $self->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 '';
+  }
+
+}
+
 =back
 
 =head1 BUGS