Batch payment refactoring
authormark <mark>
Fri, 25 Sep 2009 02:30:21 +0000 (02:30 +0000)
committermark <mark>
Fri, 25 Sep 2009 02:30:21 +0000 (02:30 +0000)
FS/FS/Conf.pm
FS/FS/pay_batch.pm
FS/FS/pay_batch/BoM.pm [new file with mode: 0644]
FS/FS/pay_batch/PAP.pm [new file with mode: 0644]
FS/FS/pay_batch/ach_spiritone.pm [new file with mode: 0644]
FS/FS/pay_batch/chase_canada.pm [new file with mode: 0644]
FS/FS/pay_batch/paymentech.pm [new file with mode: 0644]
FS/FS/pay_batch/td_canada_trust.pm [new file with mode: 0644]
httemplate/misc/download-batch.cgi
httemplate/search/cust_pay_batch.cgi

index 0954572..bd1c004 100644 (file)
@@ -2386,6 +2386,13 @@ worry that config_items is freeside-specific and icky.
   },
 
   {
+    'key'         => 'batchconfig-paymentech',
+    'section'     => 'billing',
+    'description' => 'Configuration for Chase Paymentech batching, four lines: 1. BIN, 2. Terminal ID, 3. Merchant ID, 4. Username',
+    'type'        => 'textarea',
+  },
+
+  {
     'key'         => 'payment_history-years',
     'section'     => 'UI',
     'description' => 'Number of years of payment history to show by default.  Currently defaults to 2.',
index 5448b03..ffa6e20 100644 (file)
@@ -1,11 +1,13 @@
 package FS::pay_batch;
 
 use strict;
-use vars qw( @ISA );
+use vars qw( @ISA $DEBUG %import_info %export_info $conf );
 use Time::Local;
 use Text::CSV_XS;
+use XML::Simple qw(XMLin XMLout);
 use FS::Record qw( dbh qsearch qsearchs );
 use FS::cust_pay;
+use FS::Conf;
 
 @ISA = qw(FS::Record);
 
@@ -137,6 +139,42 @@ sub set_status {
   $self->replace();
 }
 
+# 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.
@@ -155,222 +193,19 @@ sub import_results {
   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 {
-      '';
-    };
-
-    $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) );
-    };
-
-    $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" );
-    };
-
-
-  }elsif ( $format eq 'PAP' ) {
-
-    $filetype = "Fixed264";
-
-    @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
-    );
-
-    $formatre = '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$'; 
-
-    $end_condition = sub {
-      my $hash = shift;
-      $hash->{'recordtype'} eq 'W';
-    };
-
-    $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;
-      '';
-    };
-
-    $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'};
-    };
-
-    $approved_condition = sub {
-      1;
-    };
-
-    $declined_condition = sub {
-      0;
-    };
-
-  }elsif ( $format eq 'ach-spiritone' ) {
-
-    $filetype = "CSV";
-
-    @fields = (
-      '',            # Name
-      'paybatchnum', # ID: 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.
-    );
-
-    $end_condition = sub {
-      '';
-    };
-
-    $hook = sub {
-      my $hash = shift;
-      $hash->{'_date'} = time;  # got a better one?
-      $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'aba'};
-    };
-
-    $approved_condition = sub {
-      1;
-    };
-
-    $declined_condition = sub {
-      0;
-    };
-
-
-  } else {
-    return "Unknown format $format";
-  }
+  my $info = $import_info{$format}
+    or die "unknown format $format";
+
+  my $filetype            = $info->{'filetype'};      # CSV or fixed
+  my @fields              = @{ $info->{'fields'} };
+  my $formatre            = $info->{'formatre'};      # for fixed
+  my @all_values;
+  my $begin_condition     = $info->{'begin_condition'};
+  my $end_condition       = $info->{'end_condition'};
+  my $end_hook            = $info->{'end_hook'};
+  my $hook                = $info->{'hook'};
+  my $approved_condition  = $info->{'approved'};
+  my $declined_condition  = $info->{'declined'};
 
   my $csv = new Text::CSV_XS;
 
@@ -390,36 +225,66 @@ sub import_results {
   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
+    return $error;
   }
 
   my $total = 0;
   my $line;
-  while ( defined($line=<$fh>) ) {
 
-    next if $line =~ /^\s*$/; #skip blank lines
+  # Order of operations has been changed here.
+  # We now slurp everything into @all_values, then 
+  # process one line at a time.
 
-    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') {
+    my @xmlkeys = @{ $info->{'xmlkeys'} };  # for XML
+    my $xmlrow  = $info->{'xmlrow'};        # also for XML
+
+    # Do everything differently.
+    my $data = XMLin($fh, KeepRoot => 1);
+    my $rows = $data;
+    # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
+    $rows = $rows->{$_} foreach( @$xmlrow );
+    if(!defined($rows)) {
       $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} ];
     }
+  }
+  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() ];
+      }elsif ($filetype eq 'fixed'){
+        my @values = $line =~ /$formatre/;
+        unless (@values) {
+          $dbh->rollback if $oldAutoCommit;
+          return "can't parse: ". $line;
+        };
+        push @all_values, \@values;
+      }else{
+        $dbh->rollback if $oldAutoCommit;
+        return "Unknown file type $filetype";
+      }
+    }
+  }
+
+  foreach (@all_values) {
+    my @values = @$_;
 
     my %hash;
     foreach my $field ( @fields ) {
@@ -428,8 +293,9 @@ sub import_results {
       $hash{$field} = $value;
     }
 
-    if ( &{$end_condition}(\%hash) ) {
-      my $error = &{$end_hook}(\%hash, $total);
+    if ( defined($end_condition) and &{$end_condition}(\%hash) ) {
+      my $error;
+      $error = &{$end_hook}(\%hash, $total) if defined($end_hook);
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         return $error;
@@ -514,7 +380,6 @@ sub import_results {
 
     }
 
-
   }
   
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -522,6 +387,94 @@ sub import_results {
 
 }
 
+sub export_batch {
+# Formerly httemplate/misc/download-batch.cgi
+  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 $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;  
+
+  my $error;
+
+  my $first_download;
+  if($self->status eq 'O') {
+    $first_download = 1;
+  }
+  elsif($self->status eq 'I' and
+        $FS::CurrentUser::CurrentUser->access_right('Reprocess batches')) {
+    $first_download = 0;
+  }
+  else {
+    die "No pending batch.\n"
+  }
+
+  $error = $self->set_status('I');
+  die "error updating pay_batch status: $error\n" if $error;
+
+  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 $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;
+      $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
+        $cust_pay_batch->amount($balance);
+        $error = $cust_pay_batch->replace;
+      }
+      # 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) . "\n";
+    }
+  }
+  my $f = $info->{'footer'};
+  if(ref($f) eq 'CODE') {
+    $batch .= &$f($self, $batchcount, $batchtotal) . "\n";
+  }
+  else {
+    $batch .= $f . "\n";
+  }
+
+  if ($info->{'autopost'}) {
+    $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;
+}
+
 =back
 
 =head1 BUGS
diff --git a/FS/FS/pay_batch/BoM.pm b/FS/FS/pay_batch/BoM.pm
new file mode 100644 (file)
index 0000000..7bfc22a
--- /dev/null
@@ -0,0 +1,73 @@
+package FS::pay_batch::BoM;
+
+use strict;
+use vars qw(@ISA %import_info %export_info $name);
+use Time::Local 'timelocal';
+use FS::Conf;
+
+my $conf;
+my ($origid, $datacenter, $typecode, $shortname, $longname, $mybank, $myacct);
+
+$name = 'BoM';
+
+%import_info = (
+  'filetype'    => 'CSV',
+  'fields'      => [],
+  'hook'        => sub { die "Can't import BoM" },
+  'approved'    => sub { 1 },
+  'declined'    => sub { 0 },
+);
+
+%export_info = (
+  init => sub {
+    $conf = shift;
+    ($origid,
+     $datacenter,
+     $typecode, 
+     $shortname, 
+     $longname, 
+     $mybank, 
+     $myacct) = $conf->config("batchconfig-BoM");
+  },
+  header => sub { 
+    my $pay_batch = shift;
+    sprintf( "A%10s%04u%06u%05u%54s\n", 
+      $origid,
+      $pay_batch->batchnum,
+      jdate($pay_batch->download),
+      $datacenter,
+      "") .
+    sprintf( "XD%03u%06u%-15s%-30s%09u%-12s   \n",
+      $typecode,
+      jdate($pay_batch->download),
+      $shortname,
+      $longname,
+      $mybank,
+      $myacct);
+  },
+  row => sub {
+    my ($cust_pay_batch, $pay_batch) = @_;
+    my ($account, $aba) = split('@', $cust_pay_batch->payinfo);
+    sprintf( "D%010.0f%09u%-12s%-29s%-19s\n",
+      $cust_pay_batch->amount * 100,
+      $aba,
+      $account,
+      $cust_pay_batch->payname,
+      $cust_pay_batch->paybatchnum
+      );
+  },
+  footer => sub {
+    my ($pay_batch, $batchcount, $batchtotal) = @_;
+    sprintf( "YD%08u%014.0f%56s\n", $batchcount, $batchtotal*100, "").
+    sprintf( "Z%014u%04u%014u%05u%41s\n", 
+      $batchtotal*100, $batchcount, "0", "0", "");
+  },
+);
+
+sub jdate {
+  my (@date) = localtime(shift);
+  sprintf("%03d%03d", $date[5] % 100, $date[7] + 1);
+}
+
+1;
+
diff --git a/FS/FS/pay_batch/PAP.pm b/FS/FS/pay_batch/PAP.pm
new file mode 100644 (file)
index 0000000..432ef07
--- /dev/null
@@ -0,0 +1,103 @@
+package FS::pay_batch::PAP;
+
+use strict;
+use vars qw(@ISA %import_info %export_info $name);
+use Time::Local 'timelocal';
+use FS::Conf;
+
+my $conf;
+my ($origid, $datacenter, $typecode, $shortname, $longname, $mybank, $myacct);
+
+$name = 'PAP';
+
+%import_info = (
+  'filetype'    => 'fixed',
+  'formatre'    => '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$',
+  'fields'      => [
+    'recordtype',
+    'batchnum',
+    'datacenter',
+    'paid',
+    '_date',
+    'bank',
+    'payinfo',
+    'paybatchnum',
+  ],
+  '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'};
+  },
+  'approved'    => sub { 1 },
+  'declined'    => sub { 0 },
+# Why does pay_batch.pm have approved_condition and declined_condition?
+# It doesn't even try to handle the case of neither condition being met.
+  '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;
+      '';
+  },
+  'end_condition' => sub {
+      my $hash = shift;
+      $hash->{recordtype} eq 'W';
+  },
+);
+
+%export_info = (
+  init => sub {
+    $conf = shift;
+    ($origid,
+     $datacenter,
+     $typecode, 
+     $shortname, 
+     $longname, 
+     $mybank, 
+     $myacct) = $conf->config("batchconfig-PAP");
+  },
+  header => sub { 
+    my $pay_batch = shift;
+    sprintf( "H%10sD%3s%06u%-15s%09u%-12s%04u%19s\n",
+      $origid,
+      $typecode,
+      cdate($pay_batch->download),
+      $shortname,
+      $mybank,
+      $myacct,
+      $pay_batch->batchnum,
+      "" )
+  },
+  row => sub {
+    my ($cust_pay_batch, $pay_batch) = @_;
+    my ($account, $aba) = split('@', $cust_pay_batch->payinfo);
+    sprintf( "D%-23s%06u%-19s%09u%-12s%010.0f\n",
+      $cust_pay_batch->payname,
+      cdate($pay_batch->download),
+      $cust_pay_batch->paybatchnum,
+      $aba,
+      $account,
+      $cust_pay_batch->amount*100 );
+  },
+  footer => sub {
+    my ($pay_batch, $batchcount, $batchtotal) = @_;
+    sprintf( "T%08u%014.0f%57s\n",
+      $batchcount,
+      $batchtotal*100,
+      "" );
+  },
+);
+
+sub cdate {
+  my (@date) = localtime(shift);
+  sprintf("%02d%02d%02d", $date[3], $date[4] + 1, $date[5] % 100);
+}
+
+1;
+
diff --git a/FS/FS/pay_batch/ach_spiritone.pm b/FS/FS/pay_batch/ach_spiritone.pm
new file mode 100644 (file)
index 0000000..bd3bb14
--- /dev/null
@@ -0,0 +1,65 @@
+package FS::pay_batch::ach_spiritone;
+
+use strict;
+use vars qw(@ISA %import_info %export_info $name);
+use Time::Local 'timelocal';
+use FS::Conf;
+use File::Temp;
+
+my $conf;
+my ($origid, $datacenter, $typecode, $shortname, $longname, $mybank, $myacct);
+
+$name = 'ach-spiritone'; # note spelling
+
+%import_info = (
+  'filetype'    => 'CSV',
+  'fields'      => [
+    '', #name
+    'paybatchnum',  
+    'aba',
+    'payinfo', 
+    '', #transaction type
+    'paid',
+    '', #default transaction type
+    '', #default amount
+  ],
+  'hook'        => sub {
+      my $hash = shift;
+      $hash->{'_date'} = time;
+      $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'aba'};
+  },
+  'approved'    => sub { 1 },
+  'declined'    => sub { 0 },
+);
+
+%export_info = (
+# This is the simplest case.
+  row => sub {
+    my ($cust_pay_batch, $pay_batch) = @_;
+    my ($account, $aba) = split('@', $cust_pay_batch->payinfo);
+    my $payname = $cust_pay_batch->first . ' ' . $cust_pay_batch->last;
+    $payname =~ tr/",/  /; 
+    qq!"$payname","!.$cust_pay_batch->paybatchnum.
+    qq!","$aba","$account","27","!.$cust_pay_batch->amount.
+    qq!","27","0.00"!; #"
+  },
+  autopost => sub {
+    my ($pay_batch, $batch) = @_;
+    my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+    my $fh = new File::Temp(
+      TEMPLATE => 'paybatch.'. $pay_batch->batchnum .'.XXXXXXXX',
+      DIR      => $dir,
+    ) or return "can't open temp file: $!\n";
+
+    print $fh $batch;
+    seek $fh, 0, 0;
+
+    my $error = $pay_batch->import_results( 'filehandle' => $fh,
+                                         'format'     => $name,
+                                       );
+    return $error if $error;
+  },
+);
+
+1;
+
diff --git a/FS/FS/pay_batch/chase_canada.pm b/FS/FS/pay_batch/chase_canada.pm
new file mode 100644 (file)
index 0000000..909e4ae
--- /dev/null
@@ -0,0 +1,104 @@
+package FS::pay_batch::chase_canada;
+
+use strict;
+use vars qw(@ISA %import_info %export_info $name);
+use Time::Local 'timelocal';
+use FS::Conf;
+
+my $conf;
+my $origid;
+
+$name = 'csv-chase_canada-E-xactBatch';
+
+%import_info = (
+  'filetype'    => 'CSV',
+  'fields'      => [
+    '',
+    '',
+    '',
+    'paid',
+    'auth',
+    'payinfo',
+    '',
+    '',
+    'bankcode',
+    'bankmess',
+    'etgcode',
+    'etgmess',
+    '',
+    'paybatchnum',
+    '',
+    'result',
+  ],
+  'hook'        => sub {
+    my $hash = shift;
+    my $cpb = shift;
+    $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} );
+    $hash->{'_date'} = time;
+    $hash->{'payinfo'} = $cpb->{'payinfo'}
+      if( substr($hash->{'payinfo'}, -4) eq substr($cpb->{'payinfo'}, -4) );
+  },
+  'approved'    => sub { 
+    my $hash = shift;
+    $hash->{'etgcode'} eq '00' && $hash->{'result'} eq 'Approved';
+  },
+  'declined'    => sub { 
+    my $hash = shift;
+    $hash->{'etgcode'} ne '00' || $hash->{'result'} eq 'Declined';
+  },
+);
+
+%export_info = (
+  init => sub {
+    $conf = shift;
+    ($origid) = $conf->config("batchconfig-$name");
+  },
+  header => sub { 
+    my $pay_batch = shift;
+    sprintf( '$$E-xactBatchFileV1.0$$%s:%03u$$%s',
+      sdate($pay_batch->download),
+      $pay_batch->batchnum, 
+      $origid );
+  },
+  row => sub {
+    my ($cust_pay_batch, $pay_batch) = @_;
+    my $payname = $cust_pay_batch->payname;
+    $payname =~ tr/",/  /;
+                
+    join(',', 
+      $cust_pay_batch->paybatchnum,
+      $cust_pay_batch->custnum,
+      $cust_pay_batch->invnum,
+      qq!"$payname"!,
+      '00',
+      $cust_pay_batch->payinfo,
+      $cust_pay_batch->amount,
+      expdate($cust_pay_batch->exp),
+      '',
+      ''
+    );
+  },
+  # no footer
+);
+
+sub sdate {
+  my (@date) = localtime(shift);
+  sprintf('%02d/%02d/%02d', $date[5] % 100, $date[4] + 1, $date[3]);
+}
+
+sub expdate {
+  my $exp = shift;
+  $exp =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+  my ($mon, $y) = ($2, $1);
+  if($conf->exists('batch-increment_expiration')) {
+    my ($curmon, $curyear) = (localtime(time))[4,5];
+    $curmon++;
+    $curyear -=  100;
+    $y++ while $y < $curyear || ($y == $curyear && $mon < $curmon);
+  }
+  $mon = "0$mon" if $mon =~ /^\d$/;
+  $y = "0$y" if $y =~ /^\d$/;
+  return "$mon$y";
+}
+
+1;
diff --git a/FS/FS/pay_batch/paymentech.pm b/FS/FS/pay_batch/paymentech.pm
new file mode 100644 (file)
index 0000000..33a9fda
--- /dev/null
@@ -0,0 +1,112 @@
+package FS::pay_batch::paymentech;
+
+use strict;
+use vars qw(@ISA %import_info %export_info $name);
+use Time::Local;
+use Date::Format 'time2str';
+use Date::Parse 'str2time';
+use FS::Conf;
+use XML::Simple qw(XMLin XMLout);
+
+my $conf;
+my ($bin, $merchantID, $terminalID, $username);
+$name = 'paymentech';
+
+%import_info = (
+  filetype    => 'XML',
+  xmlrow         => [ qw(transResponse newOrderResp) ],
+  fields      => [
+    'paybatchnum',
+    '_date',
+    'approvalStatus',
+    ],
+  xmlkeys     => [
+    'orderID',
+    'respDateTime',
+    'approvalStatus',
+    ],
+  'hook'        => sub {
+      my ($hash, $oldhash) = @_;
+      my ($mon, $day, $year, $hour, $min, $sec) = 
+        $hash->{'_date'} =~ /^(..)(..)(....)(..)(..)(..)$/;
+      $hash->{'_date'} = timelocal($sec, $min, $hour, $day, $mon-1, $year);
+      $hash->{'paid'} = $oldhash->{'amount'};
+    },
+  'approved'    => sub { my $hash = shift;
+                            $hash->{'approvalStatus'} 
+    },
+  'declined'    => sub { my $hash = shift;
+                            ! $hash->{'approvalStatus'} 
+    },
+);
+
+my %paytype = (
+  'personal checking' => 'C',
+  'personal savings'  => 'S',
+  'business checking' => 'X',
+  'business savings'  => 'X',
+  );
+
+%export_info = (
+  init  => sub {
+    my $conf = shift;
+    ($bin, $terminalID, $merchantID, $username) =
+       $conf->config('batchconfig-paymentech');
+    },
+# Here we do all the work in the header function.
+  header => sub {
+    my $pay_batch = shift;
+    my @cust_pay_batch = @{(shift)};
+    my $count = 0;
+    XMLout( {
+      transRequest => {
+        RequestCount => scalar(@cust_pay_batch),
+        batchFileID  => {
+          userID        => $username,
+          fileDateTime  => time2str('%Y%m%d%H%M%s',time),
+          fileID        => 'batch'.time2str('%Y%m%d',time),
+        },
+        newOrder => [ map { {
+          # $_ here refers to a cust_pay_batch record.
+          BatchRequestNo => $count++,
+          industryType   => 'EC',
+          transType      => 'AC',
+          bin            => $bin,
+          merchantID     => $merchantID,
+          terminalID     => $terminalID,
+          ($_->payby eq 'CARD') ? (
+            # Credit card stuff
+            ccAccountNum   => $_->payinfo,
+            ccExp          => time2str('%y%m',str2time($_->exp)),
+          ) : (
+            # ECP (electronic check) stuff
+            ecpCheckRT     => ($_->payinfo =~ /@(\d+)/),
+            ecpCheckDDA    => ($_->payinfo =~ /(\d+)@/),
+            ecpBankAcctType => $paytype{lc($_->cust_main->paytype)},
+            ecpDelvMethod  => 'B'
+          ),
+          avsZip         => $_->zip,
+          avsAddress1    => $_->address1,
+          avsAddress2    => $_->address2,
+          avsCity        => $_->city,
+          avsState       => $_->state,
+          avsName        => $_->first . ' ' . $_->last,
+          avsCountryCode => $_->country,
+          orderID        => $_->paybatchnum,
+          amount         => $_->amount * 100,
+          } } @cust_pay_batch
+        ],
+        endOfDay => {
+          BatchRequestNo => $count++,
+          bin            => $bin,
+          merchantID     => $merchantID,
+          terminalID     => $terminalID
+        },
+      } 
+    }, KeepRoot => 1, NoAttr => 1);
+  },
+  row => sub {},
+);
+
+1;
+
diff --git a/FS/FS/pay_batch/td_canada_trust.pm b/FS/FS/pay_batch/td_canada_trust.pm
new file mode 100644 (file)
index 0000000..43b9237
--- /dev/null
@@ -0,0 +1,104 @@
+package FS::pay_batch::td_canada_trust;
+
+# Formerly known as csv-td_canada_trust-merchant_pc_batch,
+# which I'm sure we can all agree is both a terrible name 
+# and an illegal Perl identifier.
+
+use strict;
+use vars qw(@ISA %import_info %export_info $name);
+use Time::Local 'timelocal';
+use FS::Conf;
+
+my $conf;
+my ($origid, $datacenter, $typecode, $shortname, $longname, $mybank, $myacct);
+
+$name = 'csv-td_canada_trust-merchant_pc_batch';
+
+%import_info = (
+  'filetype'    => 'CSV',
+  'fields'      => [
+    'paybatchnum',  
+    'paid',
+    '', # card type
+    '_date',
+    'time',
+    'payinfo', 
+    '', # expiry date
+    '', # auth number
+    'type', # transaction type
+    'result', # processing result
+    '', # terminal ID
+  ],
+  'hook'        => sub {
+      my $hash = shift;
+      my $date = $hash->{'_date'};
+      my $time = $hash->{'time'};
+      $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100);
+      $hash->{'_date'} = timelocal( substr($time, 4, 2),
+                                    substr($time, 2, 2),
+                                    substr($time, 0, 2),
+                                    substr($date, 6, 2),
+                                    substr($date, 4, 2)-1,
+                                    substr($date, 0, 4)-1900 );
+  },
+  'approved'    => sub { 
+    my $hash = shift;
+    $hash->{'type'} eq '0' && $hash->{'result'} == 3
+  },
+  'declined'    => sub { 
+    my $hash = shift;
+    $hash->{'type'} eq '0' && ( $hash->{'result'} == 4
+                            ||  $hash->{'result'} == 5 )
+  },
+  '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;
+  },
+);
+
+%export_info = (
+  init => sub { 
+    $conf = shift; 
+  },
+  # no header
+  row => sub {
+    my ($cust_pay_batch, $pay_batch) = @_;
+
+    return join(',', 
+      '',
+      '',
+      '',
+      '', 
+      $cust_pay_batch->payinfo,
+      expdate($cust_pay_batch->exp),
+      $cust_pay_batch->amount,
+      $cust_pay_batch->paybatchnum
+      );
+  },
+# no footer
+);
+
+sub expdate {
+  my $exp = shift;
+  $exp =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+  my ($mon, $y) = ($2, $1);
+  if($conf->exists('batch-increment_expiration')) {
+    my ($curmon, $curyear) = (localtime(time))[4,5];
+    $curmon++;
+    $curyear -=  100;
+    $y++ while $y < $curyear || ($y == $curyear && $mon < $curmon);
+  }
+  $mon = "0$mon" if $mon =~ /^\d$/;
+  $y = "0$y" if $y =~ /^\d$/;
+  return "$mon$y";
+}
+
+1;
+
index 57905da..01bf5d2 100644 (file)
@@ -1,146 +1,9 @@
-%if ($format eq "BoM") {
-%
-%  my($origid,$datacenter,$typecode,$shortname,$longname,$mybank,$myacct) =
-%    $conf->config("batchconfig-$format");
-%  
-<% sprintf( "A%10s%04u%06u%05u%54s\n",$origid,$pay_batch->batchnum,$jdate,$datacenter,"").
-        sprintf( "XD%03u%06u%-15s%-30s%09u%-12s   \n",$typecode,$jdate,$shortname,$longname,$mybank,$myacct )
-  %>
-%
-%}elsif ($format eq "PAP"){
-%
-%  my($origid,$datacenter,$typecode,$shortname,$longname,$mybank,$myacct) =
-%    $conf->config("batchconfig-$format");
-%  
-<% sprintf( "H%10sD%3s%06u%-15s%09u%-12s%04u%19s\n",$origid,$typecode,$cdate,$shortname,$mybank,$myacct,$pay_batch->batchnum,"") %>
-%
-%
-%}elsif ($format eq "csv-td_canada_trust-merchant_pc_batch"){
-%#  1;
-%}elsif ($format eq "csv-chase_canada-E-xactBatch"){
-%
-%  my($origid) = $conf->config("batchconfig-$format");
-<% sprintf( '$$E-xactBatchFileV1.0$$%s:%03u$$%s',$sdate,$pay_batch->batchnum, $origid)
-  %>
-%
-%}elsif ($format eq "ach-spiritone"){
-%#  1;
-%}else{
-%  die "Unknown format for batch in batchconfig. \n";
-%}
-%
-%
-%for my $cust_pay_batch ( sort { $a->paybatchnum <=> $b->paybatchnum }
-%                           qsearch('cust_pay_batch',
-%                            {'batchnum'=>$pay_batch->batchnum} )
-%) {
-%
-%  $cust_pay_batch->exp =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
-%  my( $mon, $y ) = ( $2, $1 );
-%  if ( $conf->exists('batch-increment_expiration') ) {
-%    my( $curmon, $curyear ) = (localtime(time))[4,5];
-%    $curmon++; $curyear-=100;
-%    $y++ while $y < $curyear || ( $y == $curyear && $mon < $curmon );
-%  }
-%  $mon = "0$mon" if $mon =~ /^\d$/;
-%  $y = "0$y" if $y =~ /^\d$/;
-%  my $exp = "$mon$y";
-%
-%  if ( $first_download ) {
-%    my $balance = $cust_pay_batch->cust_main->balance;
-%    if ( $balance <= 0 ) {
-%      my $error = $cust_pay_batch->delete;
-%      if ( $error ) {
-%        $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-%        die $error;
-%      }
-%      next;
-%    } elsif ( $balance < $cust_pay_batch->amount ) {
-%      $cust_pay_batch->amount($balance);
-%      my $error = $cust_pay_batch->replace;
-%      if ( $error ) {
-%        $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-%        die $error;
-%      }
-%    #} elsif ( $balance > $cust_pay_batch->amount ) {
-%    } 
-%  }
-%
-%  $batchcount++;
-%  $batchtotal += $cust_pay_batch->amount;
-%  
-%  if ($format eq "BoM") {
-%
-%    my( $account, $aba ) = split( '@', $cust_pay_batch->payinfo );
-%    
-<% sprintf( "D%010.0f%09u%-12s%-29s%-19s\n",$cust_pay_batch->amount*100,$aba,$account,$cust_pay_batch->payname,$cust_pay_batch->paybatchnum) %>
-%
-%
-%  } elsif ($format eq "PAP"){
-%
-%    my( $account, $aba ) = split( '@', $cust_pay_batch->payinfo );
-%    
-<% sprintf( "D%-23s%06u%-19s%09u%-12s%010.0f\n",$cust_pay_batch->payname,$cdate,$cust_pay_batch->paybatchnum,$aba,$account,$cust_pay_batch->amount*100) %>
-%
-%
-%  } elsif ($format eq "csv-td_canada_trust-merchant_pc_batch") {
-%
-%    
-,,,,<% $cust_pay_batch->payinfo %>,<% $exp %>,<% $cust_pay_batch->amount %>,<% $cust_pay_batch->paybatchnum %>
-%
-%
-%  } elsif ($format eq "csv-chase_canada-E-xactBatch"){
-%
-%  my $payname=$cust_pay_batch->payname; $payname =~ tr/",/  /; #payinfo too? :P
-<% $cust_pay_batch->paybatchnum %>,<% $cust_pay_batch->custnum %>,<% $cust_pay_batch->invnum %>,"<% $payname %>",00,<% $cust_pay_batch->payinfo %>,<% $cust_pay_batch->amount %>,<% $exp %>,,
-%
-%
-%  }elsif ($format eq "ach-spiritone"){
-%
-%  my( $account, $aba ) = split( '@', $cust_pay_batch->payinfo );
-%  my $payname=$cust_pay_batch->first. " ". $cust_pay_batch->last;
-%  $payname =~ tr/",/  /;                                        #payinfo too?
-%  my $batchline = qq!"$payname","!.$cust_pay_batch->paybatchnum.
-%                  qq!","$aba","$account","27","!.$cust_pay_batch->amount.
-%                  qq!","27","0.00"!;
-%  push @batchlines, $batchline;
-<% $batchline %>
-%
-%  } else {
-%    die "I'm already dead, but you did not know that.\n";
-%  }
-%
-%}
-%
-%if ($format eq "BoM") {
-%
-%  
-<% sprintf( "YD%08u%014.0f%56s\n",$batchcount,$batchtotal*100,"" ).
-        sprintf( "Z%014u%05u%014u%05u%41s\n",$batchtotal*100,$batchcount,"0","0","" ) %>
-%
-%
-%} elsif ($format eq "PAP"){
-%
-%  
-<% sprintf( "T%08u%014.0f%57s\n",$batchcount,$batchtotal*100,"" ) %>
-%
-%
-%} elsif ($format eq "csv-td_canada_trust-merchant_pc_batch"){
-%  #1;
-%} elsif ($format eq "csv-chase_canada-E-xactBatch"){
-%  #1;
-%} elsif ($format eq "ach-spiritone"){
-%  #1;
-%} else {
-%  die "I'm already dead (again), but you did not know that.\n";
-%}
-%
-<%init>
+<% $pay_batch->export_batch($format) %>
 
-my $conf=new FS::Conf;
+<%init>
 
 #http_header('Content-Type' => 'text/comma-separated-values' ); #IE chokes
-http_header('Content-Type' => 'text/plain' );
+http_header('Content-Type' => 'text/plain' ); # not necessarily correct...
 
 my $batchnum;
 if ( $cgi->param('batchnum') =~ /^(\d+)$/ ) {
@@ -152,62 +15,9 @@ if ( $cgi->param('batchnum') =~ /^(\d+)$/ ) {
 my $format;
 if ( $cgi->param('format') =~ /^([\w\- ]+)$/ ) {
   $format = $1;
-} else {
-  $format = $conf->config('batch-default_format');
-}
-
-my $autopost;
-if ( $format eq 'ach-spiritone' ) {
-  $autopost = 1;
-}else{
-  $autopost = 0;
-}
-
-my $oldAutoCommit = $FS::UID::AutoCommit;
-local $FS::UID::AutoCommit = 0;
-my $dbh = dbh;
-
-my $pay_batch = qsearchs('pay_batch', {'batchnum'=>$batchnum, 'status'=>'O'} );
-my $first_download = 1;
-unless ($pay_batch) {
-  $pay_batch = qsearchs('pay_batch', {'batchnum'=>$batchnum, 'status'=>'I'} )
-    if $FS::CurrentUser::CurrentUser->access_right('Reprocess batches');
-  $first_download = 0;
 }
-die "No pending batch. \n" unless $pay_batch;
 
-my $error = $pay_batch->set_status('I');
-die "error updating batch status: $error\n" if $error;
+my $pay_batch = qsearchs('pay_batch', { batchnum => $batchnum } );
+die "Batch not found: '$batchnum'" if !$pay_batch;
 
-my $batchtotal=0;
-my $batchcount=0;
-
-my (@date)=localtime($pay_batch->download);
-my $jdate = sprintf("%03d", $date[5] % 100).sprintf("%03d", $date[7] + 1);
-my $cdate = sprintf("%02d", $date[3]).sprintf("%02d", $date[4] + 1).
-            sprintf("%02d", $date[5] % 100);
-my $sdate = sprintf("%02d", $date[5] % 100).'/'.sprintf("%02d", $date[4] + 1).
-            '/'.sprintf("%02d", $date[3]);
-
-my @batchlines = ();
 </%init>
-<%cleanup>
-if ($autopost) {
-  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
-  my $fh = new File::Temp(
-    TEMPLATE => 'paybatch.'. $batchnum .'.XXXXXXXX',
-    DIR      => $dir,
-  ) or die "can't open temp file: $!\n";
-
-  print $fh map{ "$_\n" } @batchlines;
-  seek $fh, 0, 0;
-
-  $error = $pay_batch->import_results( 'filehandle' => $fh,
-                                       'format'     => $format,
-                                     );
-  die $error if $error;
-}
-
-$dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
-</%cleanup>
index 2fceb93..2056876 100755 (executable)
@@ -147,6 +147,7 @@ if ( $pay_batch ) {
                     qq!<OPTION VALUE="PAP">80 byte file for TD Canada Trust PAP Batch</OPTION>!.
                     qq!<OPTION VALUE="BoM">Bank of Montreal ECA batch</OPTION>!.
                     qq!<OPTION VALUE="ach-spiritone">Spiritone ACH batch</OPTION>!.
+                    qq!<OPTION VALUE="paymentech">Chase Paymentech</OPTION>!.
                     qq!</SELECT>!;
     }
     $html_init .= qq!<INPUT TYPE="hidden" NAME="batchnum" VALUE="$batchnum"><INPUT TYPE="submit" VALUE="Download"></FORM><BR>!;
@@ -171,6 +172,7 @@ if ( $pay_batch ) {
                     qq!<OPTION VALUE="PAP">264 byte results for TD Canada Trust PAP Batch</OPTION>!.
                     qq!<OPTION VALUE="BoM">Bank of Montreal ECA results</OPTION>!.
                     qq!<OPTION VALUE="ach-spiritone">Spiritone ACH batch</OPTION>!.
+                    qq!<OPTION VALUE="paymentech">Chase Paymentech</OPTION>!.
                     qq!</SELECT><BR>!;
     }
     $html_init .= qq!<INPUT TYPE="hidden" NAME="batchnum" VALUE="$batchnum">!;