Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / pay_batch.pm
index 9bec2c7..4f223e1 100644 (file)
@@ -5,10 +5,13 @@ use vars qw( @ISA $DEBUG %import_info %export_info $conf );
 use Time::Local;
 use Text::CSV_XS;
 use FS::Record qw( dbh qsearch qsearchs );
-use FS::cust_pay;
 use FS::Conf;
+use FS::cust_pay;
+use FS::agent;
 use Date::Parse qw(str2time);
 use Business::CreditCard qw(cardtype);
+use Scalar::Util 'blessed';
+use IO::Scalar;
 
 @ISA = qw(FS::Record);
 
@@ -40,6 +43,8 @@ from FS::Record.  The following fields are currently supported:
 
 =item batchnum - primary key
 
+=item agentnum - optional agent number for agent batches
+
 =item payby - CARD or CHEK
 
 =item status - O (Open), I (In-transit), or R (Resolved)
@@ -112,12 +117,33 @@ sub check {
     $self->ut_numbern('batchnum')
     || $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
     || $self->ut_enum('status', [ 'O', 'I', 'R' ])
+    || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
   ;
   return $error if $error;
 
   $self->SUPER::check;
 }
 
+=item agent
+
+Returns the L<FS::agent> object for this batch.
+
+=cut
+
+sub agent {
+  qsearchs('agent', { 'agentnum' => $_[0]->agentnum });
+}
+
+=item cust_pay_batch
+
+Returns all L<FS::cust_pay_batch> objects for this batch.
+
+=cut
+
+sub cust_pay_batch {
+  qsearch('cust_pay_batch', { 'batchnum' => $_[0]->batchnum });
+}
+
 =item rebalance
 
 =cut
@@ -184,7 +210,10 @@ Options are:
 
 I<filehandle> - open filehandle of results file.
 
-I<format> - "csv-td_canada_trust-merchant_pc_batch", "csv-chase_canada-E-xactBatch", "ach-spiritone", or "PAP"
+I<format> - an L<FS::pay_batch> module
+
+I<gateway> - an L<FS::payment_gateway> object for a batch gateway.  This 
+takes precedence over I<format>.
 
 =cut
 
@@ -193,13 +222,18 @@ sub import_results {
 
   my $param = ref($_[0]) ? shift : { @_ };
   my $fh = $param->{'filehandle'};
+  my $job = $param->{'job'};
+  $job->update_statustext(0) if $job;
+
+  my $gateway = $param->{'gateway'};
+  if ( $gateway ) {
+    return $self->import_from_gateway($gateway, 'file' => $fh, 'job' => $job);
+  }
+
   my $format = $param->{'format'};
   my $info = $import_info{$format}
     or die "unknown format $format";
 
-  my $job = $param->{'job'};
-  $job->update_statustext(0) if $job;
-
   my $conf = new FS::Conf;
 
   my $filetype            = $info->{'filetype'};      # CSV, fixed, variable
@@ -231,24 +265,15 @@ sub import_results {
 
   my $reself = $self->select_for_update;
 
-  unless ( $reself->status eq 'I' ) {
+  if ( $reself->status ne 'I' 
+      and !$conf->exists('batch-manual_approval') ) {
     $dbh->rollback if $oldAutoCommit;
     return "batchnum ". $self->batchnum. "no longer in transit";
   }
 
-  my $error = $self->set_status('R');
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
   my $total = 0;
   my $line;
 
-  # Order of operations has been changed here.
-  # We now slurp everything into @all_values, then 
-  # process one line at a time.
-
   if ($filetype eq 'XML') {
     eval "use XML::Simple";
     die $@ if $@;
@@ -354,14 +379,14 @@ sub import_results {
 
     &{$hook}(\%hash, $cust_pay_batch->hashref);
 
-    my $new_cust_pay_batch = new FS::cust_pay_batch { 
-      $cust_pay_batch->hash, 
-      %hash 
-    };
+    my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
 
     my $error = '';
     if ( &{$approved_condition}(\%hash) ) {
 
+      foreach ('paid', '_date', 'payinfo') {
+        $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
+      }
       $error = $new_cust_pay_batch->approve($hash{'paybatch'} || $self->batchnum);
       $total += $hash{'paid'};
 
@@ -388,15 +413,22 @@ sub import_results {
 
   } # foreach (@all_values)
 
+  my $close = 1;
   if ( defined($close_condition) ) {
     # Allow the module to decide whether to close the batch.
     # $close_condition can also die() to abort the whole import.
-    my $close = eval { $close_condition->($self) };
+    $close = eval { $close_condition->($self) };
     if ( $@ ) {
       $dbh->rollback;
       die $@;
     }
-    $self->set_status('I') if !$close;
+  }
+  if ( $close ) {
+    my $error = $self->set_status('R');
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -415,6 +447,13 @@ sub process_import_results {
   my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
   my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
 
+  my $gatewaynum = delete $param->{'gatewaynum'};
+  if ( $gatewaynum ) {
+    $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
+      or die "gatewaynum '$gatewaynum' not found\n";
+    delete $param->{'format'}; # to avoid confusion
+  }
+
   my $file = $param->{'uploaded_files'} or die "no files provided\n";
   $file =~ s/^(\w+):([\.\w]+)$/$2/;
   my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc;
@@ -427,39 +466,258 @@ sub process_import_results {
   die $error if $error;
 }
 
-# Formerly httemplate/misc/download-batch.cgi
-sub export_batch {
-  my $self = shift;
-  my $conf = new FS::Conf;
-  my $format = shift || $conf->config('batch-default_format')
-               or die "No batch format configured\n";
-  my $info = $export_info{$format} or die "Format not found: '$format'\n";
-  &{$info->{'init'}}($conf) if exists($info->{'init'});
+=item import_from_gateway GATEWAY [ OPTIONS ]
 
-  my $curuser = $FS::CurrentUser::CurrentUser;
+Import results from a L<FS::payment_gateway>, using Business::BatchPayment,
+and apply them.  GATEWAY must use the Business::BatchPayment namespace.
+
+This is a class method, since results can be applied to any batch.  
+The 'batch-reconsider' option determines whether an already-approved 
+or declined payment can have its status changed by a later import.
+
+OPTIONS may include:
+
+- file: a file name or handle to use as a data source.
+- job: an L<FS::queue> object to update with progress messages.
+
+=cut
+
+sub import_from_gateway {
+  my $class = shift;
+  my $gateway = shift;
+  my %opt = @_;
+  my $conf = FS::Conf->new;
+
+  # unavoidable duplication with import_batch, for now
+  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;  
+  my $dbh = dbh;
+
+  my $job = delete($opt{'job'});
+  $job->update_statustext(0) if $job;
+
+  my $total = 0;
+  return "import_from_gateway requires a payment_gateway"
+    unless eval { $gateway->isa('FS::payment_gateway') };
+
+  my %proc_opt = (
+    'input' => $opt{'file'}, # will do nothing if it's empty
+    # any other constructor options go here
+  );
+
+  my $processor = $gateway->batch_processor(%proc_opt);
+
+  my @batches = $processor->receive;
+  my $error;
+  my $num = 0;
+
+  # whether to allow items to change status
+  my $reconsider = $conf->exists('batch-reconsider');
+
+  # mutex all affected batches
+  my %pay_batch_for_update;
+
+  BATCH: foreach my $batch (@batches) {
+    ITEM: foreach my $item ($batch->elements) {
+      # cust_pay_batch.paybatchnum should be in the 'tid' attribute
+      my $paybatchnum = $item->tid;
+      my $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
+      if (!$cust_pay_batch) {
+        # XXX for one-way batch protocol this needs to create new payments
+        $error = "unknown paybatchnum $paybatchnum";
+        last ITEM;
+      }
+
+      my $batchnum = $cust_pay_batch->batchnum;
+      if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
+        warn "batch ID ".$batch->batch_id.
+              " does not match batchnum ".$cust_pay_batch->batchnum."\n";
+      }
+
+      # lock the batch and check its status
+      my $pay_batch = FS::pay_batch->by_key($batchnum);
+      $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
+      if ( $pay_batch->status ne 'I' and !$reconsider ) {
+        $error = "batch $batchnum no longer in transit";
+        last ITEM;
+      }
+
+      if ( $cust_pay_batch->status ) {
+        my $new_status = $item->approved ? 'approved' : 'declined';
+        if ( lc( $cust_pay_batch->status ) eq $new_status ) {
+          # already imported with this status, so don't touch
+          next ITEM;
+        }
+        elsif ( !$reconsider ) {
+          # then we're not allowed to change its status, so bail out
+          $error = "paybatchnum ".$item->tid.
+            " already resolved with status '". $cust_pay_batch->status . "'";
+          last ITEM;
+        }
+      }
+
+      # create a new cust_pay_batch with whatever information we got back
+      my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
+      my $new_payinfo;
+      # update payinfo, if needed
+      if ( $item->assigned_token ) {
+        $new_payinfo = $item->assigned_token;
+      } elsif ( $cust_pay_batch->payby eq 'CARD' ) {
+        $new_payinfo = $item->card_number if $item->card_number;
+      } else { #$cust_pay_batch->payby eq 'CHEK'
+        $new_payinfo = $item->account_number . '@' . $item->routing_code
+          if $item->account_number;
+      }
+      $new_cust_pay_batch->payinfo($new_payinfo) if $new_payinfo;
+
+      # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
+      # paid, if the batch says it's different from the amount requested
+      if ( defined $item->amount ) {
+        $new_cust_pay_batch->paid($item->amount);
+      } else {
+        $new_cust_pay_batch->paid($cust_pay_batch->amount);
+      }
+
+      # set payment date to when it was processed
+      $new_cust_pay_batch->_date($item->payment_date->epoch)
+        if $item->payment_date;
+
+      # approval status
+      if ( $item->approved ) {
+        # follow Billing_Realtime format for paybatch
+        my $paybatch = $gateway->gatewaynum .
+          '-' .
+          $gateway->gateway_module .
+          ':' .
+          $item->authorization .
+          ':' .
+          $item->order_number;
+
+        $error = $new_cust_pay_batch->approve($paybatch);
+        $total += $new_cust_pay_batch->paid;
+      }
+      else {
+        $error = $new_cust_pay_batch->decline($item->error_message);
+      }
+      last ITEM if $error;
+      $num++;
+      $job->update_statustext(int(100 * $num/( $batch->count + 1 ) ),
+        'Importing batch items')
+        if $job;
+    } #foreach $item
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+  } #foreach $batch (input batch, not pay_batch)
+
+  # Auto-resolve
+  foreach my $pay_batch (values %pay_batch_for_update) {
+    $error = $pay_batch->try_to_resolve;
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $dbh->commit if $oldAutoCommit;
+  return;
+}
+
+=item try_to_resolve
+
+Resolve this batch if possible.  A batch can be resolved if all of its
+entries have a status.  If the system options 'batch-auto_resolve_days'
+and 'batch-auto_resolve_status' are set, and the batch's download date is
+at least (batch-auto_resolve_days) before the current time, then it can
+be auto-resolved; entries with no status will be approved or declined 
+according to the batch-auto_resolve_status setting.
+
+=cut
+
+sub try_to_resolve {
+  my $self = shift;
+  my $conf = FS::Conf->new;;
+
+  return if $self->status ne 'I';
+
+  my @unresolved = qsearch('cust_pay_batch',
+    {
+      batchnum => $self->batchnum,
+      status   => ''
+    }
+  );
+
+  if ( @unresolved ) {
+    my $days = $conf->config('batch-auto_resolve_days') || '';
+    # either 'approve' or 'decline'
+    my $action = $conf->config('batch-auto_resolve_status') || '';
+    return unless 
+      length($days) and 
+      length($action) and
+      time > ($self->download + 86400 * $days)
+      ;
+
+    my $error;
+    foreach my $cpb (@unresolved) {
+      if ( $action eq 'approve' ) {
+        # approve it for the full amount
+        $cpb->set('paid', $cpb->amount) unless ($cpb->paid || 0) > 0;
+        $error = $cpb->approve($self->batchnum);
+      }
+      elsif ( $action eq 'decline' ) {
+        $error = $cpb->decline('No response from processor');
+      }
+      return $error if $error;
+    }
+  }
+
+  $self->set_status('R');
+}
+
+=item prepare_for_export
+
+Prepare the batch to be exported.  This will:
+- Set the status to "in transit".
+- If batch-increment_expiration is set and this is a credit card batch,
+  increment expiration dates that are in the past.
+- If this is the first download for this batch, adjust payment amounts to 
+  not be greater than the customer's current balance.  If the customer's 
+  balance is zero, the entry will be removed.
+
+Use this within a transaction.
+
+=cut
+
+sub prepare_for_export {
+  my $self = shift;
+  my $conf = FS::Conf->new;
+  my $curuser = $FS::CurrentUser::CurrentUser;
 
   my $first_download;
   my $status = $self->status;
   if ($status eq 'O') {
     $first_download = 1;
     my $error = $self->set_status('I');
-    die "error updating pay_batch status: $error\n" if $error;
+    return "error updating pay_batch status: $error\n" if $error;
   } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
     $first_download = 0;
   } else {
     die "No pending batch.\n";
   }
 
-  my $batch = '';
-  my $batchtotal = 0;
-  my $batchcount = 0;
-
-  my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
-                      qsearch('cust_pay_batch', { batchnum => $self->batchnum } );
+  my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum } 
+                       $self->cust_pay_batch;
   
   # handle batch-increment_expiration option
   if ( $self->payby eq 'CARD' ) {
@@ -471,54 +729,98 @@ sub export_batch {
         $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
         $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
       }
-      $_->setfield('expmmyy', sprintf('%02u%02u', $mon+1, $year % 100));
+      my $error = $_->replace;
+      return $error if $error;
     }
   }
 
-  my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
+  if ($first_download) { #remove or reduce entries if customer's balance changed
 
-  my $h = $info->{'header'};
-  if(ref($h) eq 'CODE') {
-    $batch .= &$h($self, \@cust_pay_batch) . $delim;
-  }
-  else {
-    $batch .= $h . $delim;
-  }
-  foreach my $cust_pay_batch (@cust_pay_batch) {
+    foreach my $cust_pay_batch (@cust_pay_batch) {
 
-    if ($first_download) {
       my $balance = $cust_pay_batch->cust_main->balance;
       if ($balance <= 0) { # then don't charge this customer
         my $error = $cust_pay_batch->delete;
-        if ( $error ) {
-          $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-          die $error;
-        }
-        next;
+        return $error if $error;
       } elsif ($balance < $cust_pay_batch->amount) {
         # reduce the charge to the remaining balance
         $cust_pay_batch->amount($balance);
         my $error = $cust_pay_batch->replace;
-        if ( $error ) {
-          $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-          die $error;
-        }
+        return $error if $error;
       }
       # else $balance >= $cust_pay_batch->amount
     }
+  } #if $first_download
+
+  '';
+}
+
+=item export_batch [ format => FORMAT | gateway => GATEWAY ]
+
+Export batch for processing.  FORMAT is the name of an L<FS::pay_batch> 
+module, in which case the configuration options are in 'batchconfig-FORMAT'.
+
+Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
+L<Business::BatchPayment> module.
 
+=cut
+
+sub export_batch {
+  my $self = shift;
+  my %opt = @_;
+
+  my $conf = new FS::Conf;
+  my $batch;
+
+  my $gateway = $opt{'gateway'};
+  if ( $gateway ) {
+    # welcome to the future
+    my $fh = IO::Scalar->new(\$batch);
+    $self->export_to_gateway($gateway, 'file' => $fh);
+    return $batch;
+  }
+
+  my $format = $opt{'format'} || $conf->config('batch-default_format')
+    or die "No batch format configured\n";
+
+  my $info = $export_info{$format} or die "Format not found: '$format'\n";
+
+  &{$info->{'init'}}($conf) if exists($info->{'init'});
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;  
+
+  my $error = $self->prepare_for_export;
+
+  die $error if $error;
+  my $batchtotal = 0;
+  my $batchcount = 0;
+
+  my @cust_pay_batch = $self->cust_pay_batch;
+
+  my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
+
+  my $h = $info->{'header'};
+  if (ref($h) eq 'CODE') {
+    $batch .= &$h($self, \@cust_pay_batch). $delim;
+  } else {
+    $batch .= $h. $delim;
+  }
+
+  foreach my $cust_pay_batch (@cust_pay_batch) {
     $batchcount++;
     $batchtotal += $cust_pay_batch->amount;
-    $batch .= &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal) . $delim;
-
+    $batch .=
+    &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
+    $delim;
   }
 
   my $f = $info->{'footer'};
-  if(ref($f) eq 'CODE') {
-    $batch .= &$f($self, $batchcount, $batchtotal) . $delim;
-  }
-  else {
-    $batch .= $f . $delim;
+  if (ref($f) eq 'CODE') {
+    $batch .= &$f($self, $batchcount, $batchtotal). $delim;
+  } else {
+    $batch .= $f. $delim;
   }
 
   if ($info->{'autopost'}) {
@@ -533,11 +835,49 @@ sub export_batch {
   return $batch;
 }
 
+=item export_to_gateway GATEWAY OPTIONS
+
+Given L<FS::payment_gateway> GATEWAY, export the items in this batch to 
+that gateway via Business::BatchPayment. OPTIONS may include:
+
+- file: override the default transport and write to this file (name or handle)
+
+=cut
+
+sub export_to_gateway {
+
+  my ($self, $gateway, %opt) = @_;
+  
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;  
+
+  my $error = $self->prepare_for_export;
+  die $error if $error;
+
+  my %proc_opt = (
+    'output' => $opt{'file'}, # will do nothing if it's empty
+    # any other constructor options go here
+  );
+  my $processor = $gateway->batch_processor(%proc_opt);
+
+  my @items = map { $_->request_item } $self->cust_pay_batch;
+  my $batch = Business::BatchPayment->create(Batch =>
+    batch_id  => $self->batchnum,
+    items     => \@items
+  );
+  $processor->submit($batch);
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+}
+
 sub manual_approve {
   my $self = shift;
   my $date = time;
   my %opt = @_;
   my $paybatch = $opt{'paybatch'} || $self->batchnum;
+  my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
   my $conf = FS::Conf->new;
   return 'manual batch approval disabled' 
     if ( ! $conf->exists('batch-manual_approval') );
@@ -562,8 +902,9 @@ sub manual_approve {
   ) {
     my $new_cust_pay_batch = new FS::cust_pay_batch { 
       $cust_pay_batch->hash,
-      'paid'  => $cust_pay_batch->amount,
-      '_date' => $date,
+      'paid'    => $cust_pay_batch->amount,
+      '_date'   => $date,
+      'usernum' => $usernum,
     };
     my $error = $new_cust_pay_batch->approve($paybatch);
     if ( $error ) {
@@ -572,13 +913,66 @@ sub manual_approve {
     }
     $payments++;
   }
-  return 'no unresolved payments in batch' if $payments == 0;
   $self->set_status('R');
-  
   $dbh->commit;
   return;
 }
 
+sub _upgrade_data {
+  # Set up configuration for gateways that have a Business::BatchPayment
+  # module.
+  
+  eval "use Class::MOP;";
+  if ( $@ ) {
+    warn "Moose/Class::MOP not available.\n$@\nSkipping pay_batch upgrade.\n";
+    return;
+  }
+  my $conf = FS::Conf->new;
+  for my $format (keys %export_info) {
+    my $mod = "FS::pay_batch::$format";
+    if ( $mod->can('_upgrade_gateway') 
+        and length( $conf->config("batchconfig-$format") ) ) {
+
+      local $@;
+      my ($module, %gw_options) = $mod->_upgrade_gateway;
+      my $gateway = FS::payment_gateway->new({
+          gateway_namespace => 'Business::BatchPayment',
+          gateway_module    => $module,
+      });
+      my $error = $gateway->insert(%gw_options);
+      if ( $error ) {
+        warn "Failed to migrate '$format' to a Business::BatchPayment::$module gateway:\n$error\n";
+        next;
+      }
+
+      # test whether it loads
+      my $processor = eval { $gateway->batch_processor };
+      if ( !$processor ) {
+        warn "Couldn't load Business::BatchPayment module for '$format'.\n";
+        # if not, remove it so it doesn't hang around and break things
+        $gateway->delete;
+      }
+      else {
+        # remove the batchconfig-*
+        warn "Created Business::BatchPayment gateway '".$gateway->label.
+             "' for '$format' batch processing.\n";
+        $conf->delete("batchconfig-$format");
+
+        # and if appropriate, make it the system default
+        for my $payby (qw(CARD CHEK)) {
+          if ( $conf->config("batch-fixed_format-$payby") eq $format ) {
+            warn "Setting as default for $payby.\n";
+            $conf->set("batch-gateway-$payby", $gateway->gatewaynum);
+            $conf->delete("batch-fixed_format-$payby");
+          }
+        }
+      } # if $processor
+    } #if can('_upgrade_gateway') and batchconfig-$format
+  } #for $format
+
+  '';
+}
+
 =back
 
 =head1 BUGS