RT# 83044 - fixed so open empty batches not created on upgrade
[freeside.git] / FS / FS / pay_batch.pm
index 5a06ba6..c57c554 100644 (file)
@@ -1,14 +1,20 @@
 package FS::pay_batch;
 package FS::pay_batch;
+use base qw( FS::Record );
 
 use strict;
 
 use strict;
-use vars qw( @ISA );
+use vars qw( $DEBUG %import_info %export_info $conf );
+use Scalar::Util qw(blessed);
+use IO::Scalar;
+use List::Util qw(sum);
 use Time::Local;
 use Text::CSV_XS;
 use Time::Local;
 use Text::CSV_XS;
+use Date::Parse qw(str2time);
+use Business::CreditCard qw( 0.35 cardtype );
 use FS::Record qw( dbh qsearch qsearchs );
 use FS::Record qw( dbh qsearch qsearchs );
+use FS::Conf;
 use FS::cust_pay;
 use FS::cust_pay;
-use FS::part_bill_event qw(due_events);
-
-@ISA = qw(FS::Record);
+use FS::Log;
+use Try::Tiny;
 
 =head1 NAME
 
 
 =head1 NAME
 
@@ -31,21 +37,31 @@ FS::pay_batch - Object methods for pay_batch records
 
 =head1 DESCRIPTION
 
 
 =head1 DESCRIPTION
 
-An FS::pay_batch object represents an example.  FS::pay_batch inherits from
-FS::Record.  The following fields are currently supported:
+An FS::pay_batch object represents an payment batch.  FS::pay_batch inherits
+from FS::Record.  The following fields are currently supported:
 
 =over 4
 
 =item batchnum - primary key
 
 
 =over 4
 
 =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)
 
 =item payby - CARD or CHEK
 
 =item status - O (Open), I (In-transit), or R (Resolved)
 
-=item download - 
+=item download - time when the batch was first downloaded
+
+=item upload - time when the batch was first uploaded
+
+=item title - unique batch identifier
+
+=item processor_id -
 
 
-=item upload - 
+=item type - batch type payents (DEBIT), or refunds (CREDIT)
 
 
+For incoming batches, the combination of 'title', 'payby', and 'agentnum'
+must be unique.
 
 =back
 
 
 =back
 
@@ -55,7 +71,7 @@ FS::Record.  The following fields are currently supported:
 
 =item new HASHREF
 
 
 =item new HASHREF
 
-Creates a new example.  To add the example to the database, see L<"insert">.
+Creates a new batch.  To add the batch to the database, see L<"insert">.
 
 Note that this stores the hash reference, not a distinct copy of the hash it
 points to.  You can ask the object for a copy with the I<hash> method.
 
 Note that this stores the hash reference, not a distinct copy of the hash it
 points to.  You can ask the object for a copy with the I<hash> method.
@@ -94,7 +110,7 @@ returns the error, otherwise returns false.
 
 =item check
 
 
 =item check
 
-Checks all fields to make sure this is a valid example.  If there is
+Checks all fields to make sure this is a valid batch.  If there is
 an error, returns the error, otherwise returns false.  Called by the insert
 and replace methods.
 
 an error, returns the error, otherwise returns false.  Called by the insert
 and replace methods.
 
@@ -110,12 +126,34 @@ sub check {
     $self->ut_numbern('batchnum')
     || $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
     || $self->ut_enum('status', [ 'O', 'I', 'R' ])
     $self->ut_numbern('batchnum')
     || $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
     || $self->ut_enum('status', [ 'O', 'I', 'R' ])
+    || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
+    || $self->ut_alphan('title')
   ;
   return $error if $error;
 
   ;
   return $error if $error;
 
+  if ( $self->title ) {
+    my @existing = 
+      grep { !$self->batchnum or $_->batchnum != $self->batchnum } 
+      qsearch('pay_batch', {
+          payby     => $self->payby,
+          agentnum  => $self->agentnum,
+          title     => $self->title,
+      });
+    return "Batch already exists as batchnum ".$existing[0]->batchnum
+      if @existing;
+  }
+
   $self->SUPER::check;
 }
 
   $self->SUPER::check;
 }
 
+=item agent
+
+Returns the L<FS::agent> object for this batch.
+
+=item cust_pay_batch
+
+Returns all L<FS::cust_pay_batch> objects for this batch.
+
 =item rebalance
 
 =cut
 =item rebalance
 
 =cut
@@ -138,240 +176,120 @@ sub set_status {
   $self->replace();
 }
 
   $self->replace();
 }
 
-=item import results OPTION => VALUE, ...
+# further false laziness
+
+%import_info = %export_info = ();
+foreach my $INC (@INC) {
+  warn "globbing $INC/FS/pay_batch/*.pm\n" if $DEBUG;
+  foreach my $file ( glob("$INC/FS/pay_batch/*.pm")) {
+    warn "attempting to load batch format from $file\n" if $DEBUG;
+    $file =~ /\/(\w+)\.pm$/;
+    next if !$1;
+    my $mod = $1;
+    my ($import, $export, $name) = 
+      eval "use FS::pay_batch::$mod; 
+           ( \\%FS::pay_batch::$mod\::import_info,
+             \\%FS::pay_batch::$mod\::export_info,
+             \$FS::pay_batch::$mod\::name)";
+    $name ||= $mod; # in case it's not defined
+    if ($@) {
+      # in FS::cdr this is a die, not a warn.  That's probably a bug.
+      warn "error using FS::pay_batch::$mod (skipping): $@\n";
+      next;
+    }
+    if(!keys(%$import)) {
+      warn "no \%import_info found in FS::pay_batch::$mod (skipping)\n";
+    }
+    else {
+      $import_info{$name} = $import;
+    }
+    if(!keys(%$export)) {
+      warn "no \%export_info found in FS::pay_batch::$mod (skipping)\n";
+    }
+    else {
+      $export_info{$name} = $export;
+    }
+  }
+}
+
+=item import_results OPTION => VALUE, ...
 
 
-Import batch results.
+Import batch results. Can be called as an instance method, if you want to 
+automatically adjust status on a specific batch, or a class method, if you 
+don't know which batch(es) the results apply to.
 
 Options are:
 
 I<filehandle> - open filehandle of results file.
 
 
 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"
-
-=cut
-
-sub import_results {
-  my $self = shift;
-
-  my $param = ref($_[0]) ? shift : { @_ };
-  my $fh = $param->{'filehandle'};
-  my $format = $param->{'format'};
-
-  my $filetype;      # CSV, Fixed80, Fixed264
-  my @fields;
-  my $formatre;      # for Fixed.+
-  my @values;
-  my $begin_condition;
-  my $end_condition;
-  my $end_hook;
-  my $hook;
-  my $approved_condition;
-  my $declined_condition;
-
-  if ( $format eq 'csv-td_canada_trust-merchant_pc_batch' ) {
-
-    $filetype = "CSV";
-
-    @fields = (
-      'paybatchnum', # Reference#:  Invoice number of the transaction
-      'paid',        # Amount:  Amount of the transaction.  Dollars and cents
-                     #          with no decimal entered.
-      '',            # Card Type:  0 - MCrd, 1 - Visa, 2 - AMEX, 3 - Discover,
-                     #             4 - Insignia, 5 - Diners/EnRoute, 6 - JCB
-      '_date',       # Transaction Date:  Date the Transaction was processed
-      'time',        # Transaction Time:  Time the transaction was processed
-      'payinfo',     # Card Number:  Card number for the transaction
-      '',            # Expiry Date:  Expiry date of the card
-      '',            # Auth#:  Authorization number entered for force post
-                     #         transaction
-      'type',        # Transaction Type:  0 - purchase, 40 - refund,
-                     #                    20 - force post
-      'result',      # Processing Result: 3 - Approval,
-                     #                    4 - Declined/Amount over limit,
-                     #                    5 - Invalid/Expired/stolen card,
-                     #                    6 - Comm Error
-      '',            # Terminal ID: Terminal ID used to process the transaction
-    );
-
-    $end_condition = sub {
-      my $hash = shift;
-      $hash->{'type'} eq '0BC';
-    };
-
-    $end_hook = sub {
-      my( $hash, $total) = @_;
-      $total = sprintf("%.2f", $total);
-      my $batch_total = sprintf("%.2f", $hash->{'paybatchnum'} / 100 );
-      return "Our total $total does not match bank total $batch_total!"
-        if $total != $batch_total;
-      '';
-    };
-
-    $hook = sub {
-      my $hash = shift;
-      $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
-      $hash->{'_date'} = timelocal( substr($hash->{'time'},  4, 2),
-                                    substr($hash->{'time'},  2, 2),
-                                    substr($hash->{'time'},  0, 2),
-                                    substr($hash->{'_date'}, 6, 2),
-                                    substr($hash->{'_date'}, 4, 2)-1,
-                                    substr($hash->{'_date'}, 0, 4)-1900, );
-    };
-
-    $approved_condition = sub {
-      my $hash = shift;
-      $hash->{'type'} eq '0' && $hash->{'result'} == 3;
-    };
-
-    $declined_condition = sub {
-      my $hash = shift;
-      $hash->{'type'} eq '0' && (    $hash->{'result'} == 4
-                                  || $hash->{'result'} == 5 );
-    };
-
-
-  }elsif ( $format eq 'csv-chase_canada-E-xactBatch' ) {
-
-    $filetype = "CSV";
-
-    @fields = (
-      '',            # Internal(bank) id of the transaction
-      '',            # Transaction Type:  00 - purchase,      01 - preauth,
-                     #                    02 - completion,    03 - forcepost,
-                     #                    04 - refund,        05 - auth,
-                     #                    06 - purchase corr, 07 - refund corr,
-                     #                    08 - void           09 - void return
-      '',            # gateway used to process this transaction
-      'paid',        # Amount:  Amount of the transaction.  Dollars and cents
-                     #          with decimal entered.
-      'auth',        # Auth#:  Authorization number (if approved)
-      'payinfo',     # Card Number:  Card number for the transaction
-      '',            # Expiry Date:  Expiry date of the card
-      '',            # Cardholder Name
-      'bankcode',    # Bank response code (3 alphanumeric)
-      'bankmess',    # Bank response message
-      'etgcode',     # ETG response code (2 alphanumeric)
-      'etgmess',     # ETG response message
-      '',            # Returned customer number for the transaction
-      'paybatchnum', # Reference#:  paybatch number of the transaction
-      '',            # Reference#:  Invoice number of the transaction
-      'result',      # Processing Result: Approved of Declined
-    );
-
-    $end_condition = sub {
-      '';
-    };
+I<format> - an L<FS::pay_batch> module
 
 
-    $hook = sub {
-      my $hash = shift;
-      my $cpb = shift;
-      $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'}); #hmmmm
-      $hash->{'_date'} = time;  # got a better one?
-      $hash->{'payinfo'} = $cpb->{'payinfo'}
-        if( substr($hash->{'payinfo'}, -4) eq substr($cpb->{'payinfo'}, -4) );
-    };
+I<gateway> - an L<FS::payment_gateway> object for a batch gateway.  This 
+takes precedence over I<format>.
 
 
-    $approved_condition = sub {
-      my $hash = shift;
-      $hash->{'etgcode'} eq '00' && $hash->{'result'} eq "Approved";
-    };
-
-    $declined_condition = sub {
-      my $hash = shift;
-      $hash->{'etgcode'} ne '00' # internal processing error
-        || ( $hash->{'result'} eq "Declined" );
-    };
+I<no_close> - do not try to close batches
 
 
+Supported format keys (defined in the specified FS::pay_batch module) are:
 
 
-  }elsif ( $format eq 'PAP' ) {
+I<filetype> - required, can be CSV, fixed, variable, XML
 
 
-    $filetype = "Fixed264";
+I<fields> - required list of field names for each row/line
 
 
-    @fields = (
-      'recordtype',  # We are interested in the 'D' or debit records
-      'batchnum',    # Record#:  batch number we used when sending the file
-      'datacenter',  # Where in the bowels of the bank the data was processed
-      'paid',        # Amount:  Amount of the transaction.  Dollars and cents
-                     #          with no decimal entered.
-      '_date',       # Transaction Date:  Date the Transaction was processed
-      'bank',        # Routing information
-      'payinfo',     # Account number for the transaction
-      'paybatchnum', # Reference#:  Invoice number of the transaction
-    );
+I<formatre> - regular expression for fixed filetype
 
 
-    $formatre = '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$'; 
+I<parse> - required for variable filetype
 
 
-    $end_condition = sub {
-      my $hash = shift;
-      $hash->{'recordtype'} eq 'W';
-    };
+I<xmlkeys> - required for XML filetype
 
 
-    $end_hook = sub {
-      my( $hash, $total) = @_;
-      $total = sprintf("%.2f", $total);
-      my $batch_total = $hash->{'datacenter'}.$hash->{'paid'}.
-                        substr($hash->{'_date'},0,1);          # YUCK!
-      $batch_total = sprintf("%.2f", $batch_total / 100 );
-      return "Our total $total does not match bank total $batch_total!"
-        if $total != $batch_total;
-      '';
-    };
+I<xmlrow> - required for XML filetype
 
 
-    $hook = sub {
-      my $hash = shift;
-      $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
-      my $tmpdate = timelocal( 0,0,1,1,0,substr($hash->{'_date'}, 0, 3)+2000); 
-      $tmpdate += 86400*(substr($hash->{'_date'}, 3, 3)-1) ;
-      $hash->{'_date'} = $tmpdate;
-      $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'bank'};
-    };
+I<begin_condition> - sub, ignore all lines before this returns true
 
 
-    $approved_condition = sub {
-      1;
-    };
+I<end_condition> - sub, stop processing lines when this returns true
 
 
-    $declined_condition = sub {
-      0;
-    };
+I<end_hook> - sub, runs immediately after end_condition returns true
 
 
-  }elsif ( $format eq 'ach-spiritone' ) {
+I<skip_condition> - sub, skip lines when this returns true
 
 
-    $filetype = "CSV";
+I<hook> - required, sub, runs before approved/declined conditions are checked
 
 
-    @fields = (
-      '',            # Name
-      'paybatchnum', # ID:  Invoice number of the transaction
-      'aba',         # ABA Number for the transaction
-      'payinfo',     # Bank Account Number for the transaction
-      '',            # Transaction Type:  27 - debit
-      'paid',        # Amount:  Amount of the transaction.  Dollars and cents
-                     #          with decimal entered.
-      '',            # Default Transaction Type
-      '',            # Default Amount:  Dollars and cents with decimal entered.
-    );
+I<approved> - required, sub, returns true when approved
 
 
-    $end_condition = sub {
-      '';
-    };
+I<declined> - required, sub, returns true when declined
 
 
-    $hook = sub {
-      my $hash = shift;
-      $hash->{'_date'} = time;  # got a better one?
-      $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'aba'};
-    };
+I<close_condition> - sub, decide whether or not to close the batch
 
 
-    $approved_condition = sub {
-      1;
-    };
+=cut
 
 
-    $declined_condition = sub {
-      0;
-    };
+sub import_results {
+  my $self = shift;
 
 
+  my $param = ref($_[0]) ? shift : { @_ };
+  my $fh = $param->{'filehandle'};
+  my $job = $param->{'job'};
+  $job->update_statustext(0) if $job;
 
 
-  } else {
-    return "Unknown format $format";
-  }
+  my $format = $param->{'format'};
+  my $info = $import_info{$format}
+    or die "unknown format $format";
+
+  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'};
+  my $end_hook            = $info->{'end_hook'};
+  my $skip_condition      = $info->{'skip_condition'};
+  my $hook                = $info->{'hook'};
+  my $approved_condition  = $info->{'approved'};
+  my $declined_condition  = $info->{'declined'};
+  my $close_condition     = $info->{'close_condition'};
+
+  my %target_batches; # batches that had at least one payment updated
 
   my $csv = new Text::CSV_XS;
 
 
   my $csv = new Text::CSV_XS;
 
@@ -386,51 +304,106 @@ sub import_results {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $reself = $self->select_for_update;
+  if ( ref($self) ) {
+    # if called on a specific pay_batch, check the status of that batch
+    # before continuing
+    my $reself = $self->select_for_update;
 
 
-  unless ( $reself->status eq 'I' ) {
-    $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
-  }
+    if ( $reself->status ne 'I' 
+        and !$conf->exists('batch-manual_approval') ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "batchnum ". $self->batchnum. "no longer in transit";
+    }
+  } # otherwise we can't enforce this constraint. sorry.
 
   my $total = 0;
   my $line;
 
   my $total = 0;
   my $line;
-  while ( defined($line=<$fh>) ) {
-
-    next if $line =~ /^\s*$/; #skip blank lines
 
 
-    if ($filetype eq "CSV") {
-      $csv->parse($line) or do {
-        $dbh->rollback if $oldAutoCommit;
-        return "can't parse: ". $csv->error_input();
-      };
-      @values = $csv->fields();
-    }elsif ($filetype eq "Fixed80" || $filetype eq "Fixed264"){
-      @values = $line =~ /$formatre/;
-      unless (@values) {
-        $dbh->rollback if $oldAutoCommit;
-        return "can't parse: ". $line;
-      };
-    }else{
+  if ($filetype eq 'XML') {
+    eval "use XML::Simple";
+    die $@ if $@;
+    my @xmlkeys = @{ $info->{'xmlkeys'} };  # for XML
+    my $xmlrow  = $info->{'xmlrow'};        # also for XML
+
+    # Do everything differently.
+    my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
+    my $rows = $data;
+    # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
+    $rows = $rows->{$_} foreach( @$xmlrow );
+    if(!defined($rows)) {
       $dbh->rollback if $oldAutoCommit;
       $dbh->rollback if $oldAutoCommit;
-      return "Unknown file type $filetype";
+      return "can't find rows in XML file";
     }
     }
+    $rows = [ $rows ] if ref($rows) ne 'ARRAY';
+    foreach my $row (@$rows) {
+      push @all_values, [ @{$row}{@xmlkeys}, $row ];
+    }
+  }
+  else {
+    while ( defined($line=<$fh>) ) {
+
+      next if $line =~ /^\s*$/; #skip blank lines
+
+      if ($filetype eq "CSV") {
+        $csv->parse($line) or do {
+          $dbh->rollback if $oldAutoCommit;
+          return "can't parse: ". $csv->error_input();
+        };
+        push @all_values, [ $csv->fields(), $line ];
+      }elsif ($filetype eq 'fixed'){
+        my @values = ( $line =~ /$formatre/ );
+        unless (@values) {
+          $dbh->rollback if $oldAutoCommit;
+          return "can't parse: ". $line;
+        };
+        push @values, $line;
+        push @all_values, \@values;
+      }
+      elsif ($filetype eq 'variable') {
+        # no longer used
+        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;
 
     my %hash;
+    my $line = pop @values;
     foreach my $field ( @fields ) {
       my $value = shift @values;
       next unless $field;
       $hash{$field} = $value;
     }
 
     foreach my $field ( @fields ) {
       my $value = shift @values;
       next unless $field;
       $hash{$field} = $value;
     }
 
-    if ( &{$end_condition}(\%hash) ) {
-      my $error = &{$end_hook}(\%hash, $total);
+    if ( defined($begin_condition) ) {
+      if ( &{$begin_condition}(\%hash, $line) ) {
+        undef $begin_condition;
+      }
+      else {
+        next;
+      }
+    }
+
+    if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
+      my $error;
+      $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         return $error;
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         return $error;
@@ -438,70 +411,951 @@ sub import_results {
       last;
     }
 
       last;
     }
 
+    if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
+      next;
+    }
+
     my $cust_pay_batch =
       qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
     unless ( $cust_pay_batch ) {
       return "unknown paybatchnum $hash{'paybatchnum'}\n";
     }
     my $cust_pay_batch =
       qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
     unless ( $cust_pay_batch ) {
       return "unknown paybatchnum $hash{'paybatchnum'}\n";
     }
+    # remember that we've touched this batch
+    $target_batches{ $cust_pay_batch->batchnum } = 1;
+
     my $custnum = $cust_pay_batch->custnum,
     my $payby = $cust_pay_batch->payby,
 
     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);
 
     &{$hook}(\%hash, $cust_pay_batch->hashref);
 
-    if ( &{$approved_condition}(\%hash) ) {
+    my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
 
 
-      $new_cust_pay_batch->status('Approved');
+    my $error = '';
+    if ( &{$approved_condition}(\%hash) ) {
 
 
-      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";
+      foreach ('paid', '_date', 'payinfo') {
+        $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
       }
       }
+      $error = $new_cust_pay_batch->approve(%hash);
       $total += $hash{'paid'};
       $total += $hash{'paid'};
-  
-      $cust_pay->cust_main->apply_payments;
 
     } elsif ( &{$declined_condition}(\%hash) ) {
 
 
     } elsif ( &{$declined_condition}(\%hash) ) {
 
-      $new_cust_pay_batch->status('Declined');
+      $error = $new_cust_pay_batch->decline($hash{'error_message'});;
 
 
-      foreach my $part_bill_event ( due_events ( $new_cust_pay_batch,
-                                                 'DCLN',
-                                                '',
-                                                '') ) {
+    }
 
 
-        # don't run subsequent events if balance<=0
-        last if $cust_pay_batch->cust_main->balance <= 0;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
 
 
-       if (my $error = $part_bill_event->do_event($new_cust_pay_batch)) {
-         # gah, even with transactions.
-         $dbh->commit if $oldAutoCommit; #well.
-         return $error;
-       }
+    # 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;
+      }
 
 
+    }
+
+  } # foreach (@all_values)
+
+  # decide whether to close batches that had payments posted
+  if ( !$param->{no_close} ) {
+    foreach my $batchnum (keys %target_batches) {
+      my $pay_batch = FS::pay_batch->by_key($batchnum);
+      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->($pay_batch) };
+        if ( $@ ) {
+          $dbh->rollback;
+          die $@;
+        }
+      }
+      if ( $close ) {
+        my $error = $pay_batch->set_status('R');
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
       }
       }
+    } # foreach $batchnum
+  } # if (!$param->{no_close})
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+use Data::Dumper;
+sub process_import_results {
+  my $job = shift;
+  my $param = shift;
+  $param->{'job'} = $job;
+  warn Dumper($param) if $DEBUG;
+  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;
+  if ( $param->{gateway} ) {
+    $error = FS::pay_batch->import_from_gateway(%$param);
+  } else {
+    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";
+    $error = $batch->import_results($param);
+  }
+  unlink $file;
+  die $error if $error;
+}
+
+=item import_from_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:
+
+- gateway: the L<FS::payment_gateway>, required
+- filehandle: 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 %opt = @_;
+  my $gateway = $opt{'gateway'};
+  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{'filehandle'}, # will do nothing if it's empty
+    # any other constructor options go here
+  );
+
+  my @item_errors;
+  my $errors_not_fatal = $conf->config('batch-errors_not_fatal');
+  if ( $errors_not_fatal ) {
+    # construct error trap
+    $proc_opt{'on_parse_error'} = sub {
+      my ($self, $line, $error) = @_;
+      push @item_errors, "  '$line'\n$error";
+    };
+  }
+
+  my $processor = $gateway->batch_processor(%proc_opt);
+
+  my @processor_ids = map { $_->processor_id } 
+                        qsearch({
+                          'table' => 'pay_batch',
+                          'hashref' => { 'status' => 'I' },
+                          'extra_sql' => q( AND processor_id != '' AND processor_id IS NOT NULL)
+                        });
+
+  my @batches = $processor->receive(@processor_ids);
+
+  my $num = 0;
+
+  my $total_items = sum( map{$_->count} @batches);
+
+  # whether to allow items to change status
+  my $reconsider = $conf->exists('batch-reconsider');
+
+  # mutex all affected batches
+  my %pay_batch_for_update;
+
+  my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
 
 
+  BATCH: foreach my $batch (@batches) {
+
+    my %incoming_batch = (
+      'CARD' => {},
+      'CHEK' => {},
+    );
+
+    ITEM: foreach my $item ($batch->elements) {
+
+      my $cust_pay_batch; # the new batch entry (with status)
+      my $pay_batch; # the freeside batch it belongs to
+      my $payby; # CARD or CHEK
+      my $error;
+
+      my $paybatch = $gateway->gatewaynum .  '-' .  $gateway->gateway_module .
+        ':' . ($item->authorization || '') .
+        ':' . ($item->order_number || '');
+
+      if ( $batch->incoming ) {
+        # This is a one-way batch.
+        # Locate the customer, find an open batch correct for them,
+        # create a payment.  Don't bother creating a cust_pay_batch
+        # entry.
+        my $cust_main;
+        if ( defined($item->customer_id) 
+             and $item->customer_id =~ /^\d+$/ 
+             and $item->customer_id > 0 ) {
+
+          $cust_main = FS::cust_main->by_key($item->customer_id)
+                       || qsearchs('cust_main', 
+                         { 'agent_custid' => $item->customer_id }
+                       );
+          if ( !$cust_main ) {
+            push @item_errors, "Unknown customer_id ".$item->customer_id;
+            next ITEM;
+          }
+        }
+        else {
+          push @item_errors, "Illegal customer_id '".$item->customer_id."'";
+          next ITEM;
+        }
+        # it may also make sense to allow selecting the customer by 
+        # invoice_number, but no modules currently work that way
+
+        $payby = $bop2payby{ $item->payment_type };
+        my $agentnum = '';
+        $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
+
+        # create a batch if necessary
+        $pay_batch = $incoming_batch{$payby}->{$agentnum} ||= 
+          FS::pay_batch->new({
+              status    => 'R', # pre-resolve it
+              payby     => $payby,
+              agentnum  => $agentnum,
+              upload    => time,
+              title     => $batch->batch_id,
+          });
+        if ( !$pay_batch->batchnum ) {
+          $error = $pay_batch->insert;
+          die $error if $error; # can't do anything if this fails
+        }
+
+        if ( !$item->approved ) {
+          $error ||= "payment rejected - ".$item->error_message;
+        }
+        if ( !defined($item->amount) or $item->amount <= 0 ) {
+          $error ||= "no amount in item $num";
+        }
+
+        my $payinfo;
+        if ( $item->check_number ) {
+          $payby = 'BILL'; # right?
+          $payinfo = $item->check_number;
+        } elsif ( $item->assigned_token ) {
+          $payinfo = $item->assigned_token;
+        }
+        # create the payment
+        my $cust_pay = FS::cust_pay->new(
+          {
+            custnum     => $cust_main->custnum,
+            _date       => $item->payment_date->epoch,
+            paid        => sprintf('%.2f',$item->amount),
+            payby       => $payby,
+            invnum      => $item->invoice_number,
+            batchnum    => $pay_batch->batchnum,
+            payinfo     => $payinfo,
+            gatewaynum  => $gateway->gatewaynum,
+            processor   => $gateway->gateway_module,
+            auth        => $item->authorization,
+            order_number => $item->order_number,
+          }
+        );
+        $error ||= $cust_pay->insert;
+        eval { $cust_main->apply_payments };
+        $error ||= $@;
+
+        if ( $error ) {
+          push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
+        }
+
+      } else {
+        # This is a request/reply batch.
+        # Locate the request (the 'tid' attribute is the paybatchnum).
+        my $paybatchnum = $item->tid;
+        $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
+        if (!$cust_pay_batch) {
+          push @item_errors, "paybatchnum $paybatchnum not found";
+          next ITEM;
+        }
+        $payby = $cust_pay_batch->payby;
+
+        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
+        $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";
+        }
+
+        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 . "'";
+          }
+        }
+
+        if ( $error ) {        
+          push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
+          next ITEM;
+        }
+
+        my $new_payinfo;
+        # update payinfo, if needed
+        if ( $item->assigned_token ) {
+          $new_payinfo = $item->assigned_token;
+        } elsif ( $payby eq 'CARD' ) {
+          $new_payinfo = $item->card_number if $item->card_number;
+        } else { #$payby eq 'CHEK'
+          $new_payinfo = $item->account_number . '@' . $item->routing_code
+            if $item->account_number;
+        }
+        $cust_pay_batch->set('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 ) {
+          $cust_pay_batch->set('paid', $item->amount);
+        } else {
+          $cust_pay_batch->set('paid', $cust_pay_batch->amount);
+        }
+
+        # set payment date to when it was processed
+        $cust_pay_batch->_date($item->payment_date->epoch)
+          if $item->payment_date;
+
+        # approval status
+        if ( $item->approved ) {
+          # follow Billing_Realtime format for paybatch
+          $error = $cust_pay_batch->approve(
+            'gatewaynum'    => $gateway->gatewaynum,
+            'processor'     => $gateway->gateway_module,
+            'auth'          => $item->authorization,
+            'order_number'  => $item->order_number,
+          );
+          $total += $cust_pay_batch->paid;
+        }
+        else {
+          $error = $cust_pay_batch->decline($item->error_message,
+                                            $item->failure_status);
+        }
+
+        if ( $error ) {        
+          push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
+          next ITEM;
+        }
+      } # $batch->incoming
+
+      $num++;
+      $job->update_statustext(int(100 * $num/( $total_items ) ),
+        'Importing batch items')
+      if $job;
+
+    } #foreach $item
+
+  } #foreach $batch (input batch, not pay_batch)
+
+  # Format an error message
+  if ( @item_errors ) {
+    my $error_text = join("\n\n", 
+      "Errors during batch import: ".scalar(@item_errors),
+      @item_errors
+    );
+    if ( $errors_not_fatal ) {
+      my $message = "Import from gateway ".$gateway->label." errors: ".$error_text;
+      my $log = FS::Log->new('FS::pay_batch::import_from_gateway');
+      $log->error($message);
+    } else {
+      # Bail out.
+      $dbh->rollback if $oldAutoCommit;
+      die $error_text;
     }
     }
+  }
+
+  # Auto-resolve (with brute-force error handling)
+  foreach my $pay_batch (values %pay_batch_for_update) {
+    my $error = $pay_batch->try_to_resolve;
 
 
-    my $error = $new_cust_pay_batch->replace($cust_pay_batch);
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
+      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 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 and $conf->exists('batch-auto_resolve_days') ) {
+    my $days = $conf->config('batch-auto_resolve_days'); # can be zero
+    # 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;
+    }
+  } elsif ( @unresolved ) {
+    # auto resolve is not enabled, and we're not ready to resolve
+    return;
+  }
+
+  $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 (caution: all cust_pay_batch
+  entries might 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;
+  } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
+    $first_download = 0;
+  } elsif ($status eq 'R' && 
+           $curuser->access_right('Redownload resolved batches')) {
+    $first_download = 0;
+  } else {
+    die "No pending batch.\n";
+  }
+
+  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;
+    }
+  }
+
+  if ($first_download) { #remove or reduce entries if customer's balance changed
+
+    foreach my $cust_pay_batch (@cust_pay_batch) {
+
+      my $balance = $cust_pay_batch->cust_main->balance;
+      if ($balance <= 0) { # then don't charge this customer
+        my $error = $cust_pay_batch->unbatch_and_delete;
+        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;
+        return $error if $error;
+      }
+      # else $balance >= $cust_pay_batch->amount
+    }
+
+    #need to do this after unbatch_and_delete
+    my $error = $self->set_status('I');
+    return "error updating pay_batch status: $error\n" if $error;
+
+  } #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.
+
+Returns the text of the batch.  If batch contains no cust_pay_batch entries
+(or has them all removed by L</prepare_for_export>) then the batch will be 
+resolved and a blank string will be returned.  All other errors are fatal.
+
+=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, $self->agentnum) 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;
+  unless (@cust_pay_batch) {
+    # if it's empty, just resolve the batch
+    $self->set_status('R');
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return '';
+  }
+
+  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;
+  }
+
+  my $f = $info->{'footer'};
+  if (ref($f) eq 'CODE') {
+    $batch .= &$f($self, $batchcount, $batchtotal). $delim;
+  } else {
+    $batch .= $f. $delim;
+  }
 
 
+  if ($info->{'autopost'}) {
+    my $error = &{$info->{'autopost'}}($self, $batch);
+    if($error) {
+      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+      die $error;
+    }
   }
   }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  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)
+
+If batch contains no cust_pay_batch entries (or has them all removed by 
+L</prepare_for_export>) then nothing will be transported (or written to 
+the override file) and the batch will be resolved.
+
+=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;
+  unless (@items) {
+    # if it's empty, just resolve the batch
+    $self->set_status('R');
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return '';
+  }
+
+  try {
+    my $batch = Business::BatchPayment->create(Batch =>
+      batch_id  => $self->batchnum,
+      items     => \@items
+    );
+    $processor->submit($batch);
+
+    if ($batch->processor_id) {
+      $self->set('processor_id',$batch->processor_id);
+      $self->replace;
+    }
+  } catch {
+    $dbh->rollback if $oldAutoCommit;
+    die $_;
+  };
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
+}
 
 
+sub manual_approve {
+  my $self = shift;
+  my $date = time;
+  my %opt = @_;
+  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();
+    # there are no approval options here (authorization, order_number, etc.)
+    # because the transaction wasn't really approved
+    if ( $error ) {
+      $dbh->rollback;
+      return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
+    }
+    $payments++;
+  }
+  $self->set_status('R');
+  $dbh->commit;
+  return;
+}
+
+=item batch_download_formats
+
+returns a hash of batch download formats.
+
+my %download_formats = FS::pay_batch::batch_download_formats;
+
+=cut
+
+sub batch_download_formats {
+
+  my @formats = (
+    ''              =>
+        'Default batch mode',
+    'NACHA'         =>
+        '94 byte NACHA',
+    'csv-td_canada_trust-merchant_pc_batch' =>
+        'CSV file for TD Canada Trust Merchant PC Batch',
+    'csv-chase_canada-E-xactBatch' =>
+        'CSV file for Chase Canada E-xactBatch',
+    'PAP'           =>
+        '80 byte file for TD Canada Trust PAP Batch',
+    'BoM'           =>
+        'Bank of Montreal ECA batch',
+    'ach-spiritone' =>
+        'Spiritone ACH batch',
+    'paymentech'    =>
+        'XML file for Chase Paymentech',
+    'RBC'           =>
+        'Royal Bank of Canada PDS batch',
+    'td_eft1464'    =>
+        '1464 byte file for TD Commercial Banking EFT',
+    'eft_canada'    =>
+        'EFT Canada CSV batch',
+    'CIBC'          =>
+        '80 byte file for Canadian Imperial Bank of Commerce',
+    # insert new batch formats here
+  );
+
+}
+
+=item batch_download_formats
+
+returns a hash of batch download formats.
+
+my %download_formats = FS::pay_batch::batch_download_formats;
+
+=cut
+
+sub can_handle_electronic_refunds {
+
+  my $self = shift;
+  my $format = shift;
+  my $conf = new FS::Conf;
+
+  tie my %download_formats, 'Tie::IxHash', batch_download_formats;
+
+  my %paybatch_mods = (
+    'NACHA'                                 => 'nacha',
+    'csv-td_canada_trust-merchant_pc_batch' => 'td_canada_trust',
+    'csv-chase_canada-E-xactBatch'          => 'chase-canada',
+    'PAP'                                   => 'PAP',
+    'BoM'                                   => 'BoM',
+    'ach-spiritone'                         => 'ach_spiritone',
+    'paymentech'                            => 'paymentech',
+    'RBC'                                   => 'RBC',
+    'td_eft1464'                            => 'td_eft1464',
+    'eft_canada'                            => 'eft_canada',
+    'CIBC'                                  => 'CIBC',
+  );
+
+  %download_formats = ( $format => $download_formats{$format}, ) if $format;
+
+  foreach my $key (keys %download_formats) {
+    my $mod = "FS::pay_batch::".$paybatch_mods{$key};
+    if ($mod->can('can_handle_credits')) {
+      return '1' if $conf->exists('batchconfig-'.$key);
+    }
+  }
+
+  return;
+
+}
+
+use FS::upgrade_journal;
+sub _upgrade_data {
+
+  # check if there are any pending batch refunds and no download format configured
+  # that allows electronic refunds.
+  unless ( FS::upgrade_journal->is_done('removed_refunds_nodownload_format') ) {
+
+    ## get a list of all refunds in batches.
+    my $extrasql = " LEFT JOIN pay_batch USING ( batchnum ) WHERE cust_pay_batch.paycode = 'C' AND pay_batch.download IS NULL AND pay_batch.type = 'DEBIT' ";
+
+    my @batch_refunds = qsearch({
+      'table'   => 'cust_pay_batch',
+      'select'  => 'cust_pay_batch.*',
+      'extra_sql' => $extrasql,
+    });
+
+    my $replace_error;
+
+    if (@batch_refunds) {
+      warn "found ".scalar @batch_refunds." batch refunds.\n";
+      warn "Searching for their cust refunds...\n" if (scalar @batch_refunds > 0);
+
+      my $oldAutoCommit = $FS::UID::AutoCommit;
+      local $FS::UID::AutoCommit = 0;
+      my $dbh = dbh;
+
+      ## move refund to credit batch.
+      foreach my $cust_pay_batch (@batch_refunds) {
+        my $payby = $cust_pay_batch->payby eq "CARD" ? "CARD" : "CHEK";
+
+        my %pay_batch = (
+          'status' => 'O',
+          'payby'  => $payby,
+          'type'   => 'CREDIT',
+        );
+
+        my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
+
+        unless ( $pay_batch ) {
+          $pay_batch = new FS::pay_batch \%pay_batch;
+          my $error = $pay_batch->insert;
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            warn "error creating a $payby credit batch: $error\n";
+          }
+        }
+
+        $cust_pay_batch->batchnum($pay_batch->batchnum);
+        $replace_error = $cust_pay_batch->replace();
+        if ( $replace_error ) {
+          $dbh->rollback if $oldAutoCommit;
+          warn "Unable to move credit to a credit batch: $replace_error";
+        }
+        else {
+          warn "Moved cust pay credit ".$cust_pay_batch->paybatchnum." to ".$cust_pay_batch->payby." credit batch ".$cust_pay_batch->batchnum."\n";
+        }
+      }
+    } #end @batch_refunds
+    else { warn "No batch refunds found\n"; }
+
+    FS::upgrade_journal->set_done('removed_refunds_nodownload_format') unless $replace_error;
+  }
+
+  # 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 $conf->exists("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
 }
 
 =back