Business::BatchPayment interface, #17373
[freeside.git] / FS / FS / pay_batch.pm
index 6a27554..4f223e1 100644 (file)
@@ -5,8 +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);
 
@@ -38,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)
@@ -110,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
@@ -182,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
 
@@ -191,13 +222,24 @@ 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 $filetype            = $info->{'filetype'};      # CSV or fixed
+  my $conf = new FS::Conf;
+
+  my $filetype            = $info->{'filetype'};      # CSV, fixed, variable
   my @fields              = @{ $info->{'fields'}};
   my $formatre            = $info->{'formatre'};      # for fixed
+  my $parse               = $info->{'parse'};         # for variable
   my @all_values;
   my $begin_condition     = $info->{'begin_condition'};
   my $end_condition       = $info->{'end_condition'};
@@ -206,6 +248,7 @@ sub import_results {
   my $hook                = $info->{'hook'};
   my $approved_condition  = $info->{'approved'};
   my $declined_condition  = $info->{'declined'};
+  my $close_condition     = $info->{'close_condition'};
 
   my $csv = new Text::CSV_XS;
 
@@ -222,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 $@;
@@ -279,14 +313,29 @@ sub import_results {
         };
         push @values, $line;
         push @all_values, \@values;
-      }else{
+      }
+      elsif ($filetype eq 'variable') {
+        my @values = ( eval { $parse->($self, $line) } );
+        if( $@ ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $@;
+        };
+        push @values, $line;
+        push @all_values, \@values;
+      }
+      else {
         $dbh->rollback if $oldAutoCommit;
         return "Unknown file type $filetype";
       }
     }
   }
 
+  my $num = 0;
   foreach (@all_values) {
+    if($job) {
+      $num++;
+      $job->update_statustext(int(100 * $num/scalar(@all_values)));
+    }
     my @values = @$_;
 
     my %hash;
@@ -328,153 +377,450 @@ sub import_results {
     my $custnum = $cust_pay_batch->custnum,
     my $payby = $cust_pay_batch->payby,
 
-    my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
-
     &{$hook}(\%hash, $cust_pay_batch->hashref);
 
+    my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
+
+    my $error = '';
     if ( &{$approved_condition}(\%hash) ) {
 
-      $new_cust_pay_batch->status('Approved');
+      foreach ('paid', '_date', 'payinfo') {
+        $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
+      }
+      $error = $new_cust_pay_batch->approve($hash{'paybatch'} || $self->batchnum);
+      $total += $hash{'paid'};
 
     } elsif ( &{$declined_condition}(\%hash) ) {
 
-      $new_cust_pay_batch->status('Declined');
+      $error = $new_cust_pay_batch->decline;
 
     }
 
-    my $error = $new_cust_pay_batch->replace($cust_pay_batch);
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
+      return $error;
     }
 
-    if ( $new_cust_pay_batch->status =~ /Approved/i ) {
-
-      my $cust_pay = new FS::cust_pay ( {
-        'custnum'  => $custnum,
-       'payby'    => $payby,
-        'paybatch' => $self->batchnum,
-        map { $_ => $hash{$_} } (qw( paid _date payinfo )),
-      } );
-      $error = $cust_pay->insert;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
+    # purge CVV when the batch is processed
+    if ( $payby =~ /^(CARD|DCRD)$/ ) {
+      my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
+      if ( ! grep { $_ eq cardtype($payinfo) }
+          $conf->config('cvv-save') ) {
+        $new_cust_pay_batch->cust_main->remove_cvv;
       }
-      $total += $hash{'paid'};
-  
-      $cust_pay->cust_main->apply_payments;
 
-    } elsif ( $new_cust_pay_batch->status =~ /Declined/i ) {
+    }
 
-      #false laziness w/cust_main::collect
+  } # foreach (@all_values)
 
-      my $due_cust_event = $new_cust_pay_batch->cust_main->due_cust_event(
-        #'check_freq' => '1d', #?
-        'eventtable' => 'cust_pay_batch',
-        'objects'    => [ $new_cust_pay_batch ],
-      );
-      unless( ref($due_cust_event) ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $due_cust_event;
+  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.
+    $close = eval { $close_condition->($self) };
+    if ( $@ ) {
+      $dbh->rollback;
+      die $@;
+    }
+  }
+  if ( $close ) {
+    my $error = $self->set_status('R');
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+use MIME::Base64;
+use Storable 'thaw';
+use Data::Dumper;
+sub process_import_results {
+  my $job = shift;
+  my $param = thaw(decode_base64(shift));
+  $param->{'job'} = $job;
+  warn Dumper($param) if $DEBUG;
+  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;
+  open( $param->{'filehandle'}, 
+        '<',
+        "$dir/$file" )
+      or die "unable to open '$file'.\n";
+  my $error = $batch->import_results($param);
+  unlink $file;
+  die $error if $error;
+}
+
+=item import_from_gateway GATEWAY [ OPTIONS ]
+
+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 $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;
       }
 
-      foreach my $cust_event ( @$due_cust_event ) {
-        
-        #XXX lock event
-    
-        #re-eval event conditions (a previous event could have changed things)
-        next unless $cust_event->test_conditions;
+      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;
+        }
+      }
 
-       if ( my $error = $cust_event->do_event() ) {
-         # gah, even with transactions.
-         #$dbh->commit if $oldAutoCommit; #well.
-         $dbh->rollback if $oldAutoCommit;
-          return $error;
-       }
+      # 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 or die $dbh->errstr if $oldAutoCommit;
-  '';
 
+  $dbh->commit if $oldAutoCommit;
+  return;
 }
 
-# Formerly httemplate/misc/download-batch.cgi
-sub export_batch {
+=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 = 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'});
+  my $conf = FS::Conf->new;;
 
-  my $curuser = $FS::CurrentUser::CurrentUser;
+  return if $self->status ne 'I';
 
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;  
+  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 } 
+                       $self->cust_pay_batch;
+  
+  # handle batch-increment_expiration option
+  if ( $self->payby eq 'CARD' ) {
+    my ($cmon, $cyear) = (localtime(time))[4,5];
+    foreach (@cust_pay_batch) {
+      my $etime = str2time($_->exp) or next;
+      my ($day, $mon, $year) = (localtime($etime))[3,4,5];
+      if( $conf->exists('batch-increment_expiration') ) {
+        $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
+        $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
+      }
+      my $error = $_->replace;
+      return $error if $error;
+    }
+  }
 
-  my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
-                      qsearch('cust_pay_batch', { batchnum => $self->batchnum } );
+  if ($first_download) { #remove or reduce entries if customer's balance changed
+
+    foreach my $cust_pay_batch (@cust_pay_batch) {
 
-  my $h = $info->{'header'};
-  if(ref($h) eq 'CODE') {
-    $batch .= &$h($self, \@cust_pay_batch) . "\n";
-  }
-  else {
-    $batch .= $h . "\n";
-  }
-  foreach my $cust_pay_batch (@cust_pay_batch) {
-    if($first_download) {
       my $balance = $cust_pay_batch->cust_main->balance;
-      my $error = '';
-      if($balance <= 0) { # then don't charge this customer
-        $error = $cust_pay_batch->delete;
-        undef $cust_pay_batch;
-      }
-      elsif($balance < $cust_pay_batch->amount) { # then reduce the charge to the remaining balance
+      if ($balance <= 0) { # then don't charge this customer
+        my $error = $cust_pay_batch->delete;
+        return $error if $error;
+      } elsif ($balance < $cust_pay_batch->amount) {
+        # reduce the charge to the remaining balance
         $cust_pay_batch->amount($balance);
-        $error = $cust_pay_batch->replace;
+        my $error = $cust_pay_batch->replace;
+        return $error if $error;
       }
       # else $balance >= $cust_pay_batch->amount
-      if($error) {
-        $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-        die $error;
-      }
-    }
-    if($cust_pay_batch) { # that is, it wasn't deleted
-      $batchcount++;
-      $batchtotal += $cust_pay_batch->amount;
-      $batch .= &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal) . "\n";
     }
+  } #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 $f = $info->{'footer'};
-  if(ref($f) eq 'CODE') {
-    $batch .= &$f($self, $batchcount, $batchtotal) . "\n";
+
+  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;
   }
-  else {
-    $batch .= $f . "\n";
+
+  foreach my $cust_pay_batch (@cust_pay_batch) {
+    $batchcount++;
+    $batchtotal += $cust_pay_batch->amount;
+    $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 ($info->{'autopost'}) {
@@ -489,6 +835,144 @@ 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') );
+  return 'batch already resolved' if $self->status eq 'R';
+  return 'batch not yet submitted' if $self->status eq 'O';
+
+  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 $payments = 0;
+  foreach my $cust_pay_batch ( 
+    qsearch('cust_pay_batch', { batchnum => $self->batchnum,
+        status   => '' })
+  ) {
+    my $new_cust_pay_batch = new FS::cust_pay_batch { 
+      $cust_pay_batch->hash,
+      'paid'    => $cust_pay_batch->amount,
+      '_date'   => $date,
+      'usernum' => $usernum,
+    };
+    my $error = $new_cust_pay_batch->approve($paybatch);
+    if ( $error ) {
+      $dbh->rollback;
+      return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
+    }
+    $payments++;
+  }
+  $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