communigate provisioning phase 2: add svc_domain.trailer -> communigate TrailerText...
[freeside.git] / FS / FS / pay_batch.pm
index 7d9d9fb..6a27554 100644 (file)
@@ -1,8 +1,12 @@
 package FS::pay_batch;
 
 use strict;
-use vars qw( @ISA );
-use FS::Record qw( qsearch qsearchs );
+use vars qw( @ISA $DEBUG %import_info %export_info $conf );
+use Time::Local;
+use Text::CSV_XS;
+use FS::Record qw( dbh qsearch qsearchs );
+use FS::cust_pay;
+use FS::Conf;
 
 @ISA = qw(FS::Record);
 
@@ -27,14 +31,16 @@ FS::pay_batch - Object methods for pay_batch records
 
 =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
 
-=item status -  
+=item payby - CARD or CHEK
+
+=item status - O (Open), I (In-transit), or R (Resolved)
 
 =item download - 
 
@@ -49,7 +55,7 @@ FS::Record.  The following fields are currently supported:
 
 =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.
@@ -88,7 +94,7 @@ returns the error, otherwise returns false.
 
 =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.
 
@@ -102,6 +108,7 @@ sub check {
 
   my $error = 
     $self->ut_numbern('batchnum')
+    || $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
     || $self->ut_enum('status', [ 'O', 'I', 'R' ])
   ;
   return $error if $error;
@@ -109,6 +116,379 @@ sub check {
   $self->SUPER::check;
 }
 
+=item rebalance
+
+=cut
+
+sub rebalance {
+  my $self = shift;
+}
+
+=item set_status 
+
+=cut
+
+sub set_status {
+  my $self = shift;
+  $self->status(shift);
+  $self->download(time)
+    if $self->status eq 'I' && ! $self->download;
+  $self->upload(time)
+    if $self->status eq 'R' && ! $self->upload;
+  $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.
+
+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 $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 $skip_condition      = $info->{'skip_condition'};
+  my $hook                = $info->{'hook'};
+  my $approved_condition  = $info->{'approved'};
+  my $declined_condition  = $info->{'declined'};
+
+  my $csv = new Text::CSV_XS;
+
+  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 $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;
+  }
+
+  my $total = 0;
+  my $line;
+
+  # Order of operations has been changed here.
+  # We now slurp everything into @all_values, then 
+  # process one line at a time.
+
+  if ($filetype eq 'XML') {
+    eval "use XML::Simple";
+    die $@ if $@;
+    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;
+      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;
+      }else{
+        $dbh->rollback if $oldAutoCommit;
+        return "Unknown file type $filetype";
+      }
+    }
+  }
+
+  foreach (@all_values) {
+    my @values = @$_;
+
+    my %hash;
+    my $line = pop @values;
+    foreach my $field ( @fields ) {
+      my $value = shift @values;
+      next unless $field;
+      $hash{$field} = $value;
+    }
+
+    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;
+      }
+      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 $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);
+
+    if ( &{$approved_condition}(\%hash) ) {
+
+      $new_cust_pay_batch->status('Approved');
+
+    } elsif ( &{$declined_condition}(\%hash) ) {
+
+      $new_cust_pay_batch->status('Declined');
+
+    }
+
+    my $error = $new_cust_pay_batch->replace($cust_pay_batch);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
+    }
+
+    if ( $new_cust_pay_batch->status =~ /Approved/i ) {
+
+      my $cust_pay = new FS::cust_pay ( {
+        'custnum'  => $custnum,
+       'payby'    => $payby,
+        'paybatch' => $self->batchnum,
+        map { $_ => $hash{$_} } (qw( paid _date payinfo )),
+      } );
+      $error = $cust_pay->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
+      }
+      $total += $hash{'paid'};
+  
+      $cust_pay->cust_main->apply_payments;
+
+    } elsif ( $new_cust_pay_batch->status =~ /Declined/i ) {
+
+      #false laziness w/cust_main::collect
+
+      my $due_cust_event = $new_cust_pay_batch->cust_main->due_cust_event(
+        #'check_freq' => '1d', #?
+        'eventtable' => 'cust_pay_batch',
+        'objects'    => [ $new_cust_pay_batch ],
+      );
+      unless( ref($due_cust_event) ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $due_cust_event;
+      }
+
+      foreach my $cust_event ( @$due_cust_event ) {
+        
+        #XXX lock event
+    
+        #re-eval event conditions (a previous event could have changed things)
+        next unless $cust_event->test_conditions;
+
+       if ( my $error = $cust_event->do_event() ) {
+         # gah, even with transactions.
+         #$dbh->commit if $oldAutoCommit; #well.
+         $dbh->rollback if $oldAutoCommit;
+          return $error;
+       }
+
+      }
+
+    }
+
+  }
+  
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+# Formerly httemplate/misc/download-batch.cgi
+sub export_batch {
+  my $self = shift;
+  my $conf = new FS::Conf;
+  my $format = shift || $conf->config('batch-default_format')
+               or die "No batch format configured\n";
+  my $info = $export_info{$format} or die "Format not found: '$format'\n";
+  &{$info->{'init'}}($conf) if exists($info->{'init'});
+
+  my $curuser = $FS::CurrentUser::CurrentUser;
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;  
+
+  my $first_download;
+  my $status = $self->status;
+  if ($status eq 'O') {
+    $first_download = 1;
+    my $error = $self->set_status('I');
+    die "error updating pay_batch status: $error\n" if $error;
+  } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
+    $first_download = 0;
+  } else {
+    die "No pending batch.\n";
+  }
+
+  my $batch = '';
+  my $batchtotal = 0;
+  my $batchcount = 0;
+
+  my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
+                      qsearch('cust_pay_batch', { batchnum => $self->batchnum } );
+
+  my $h = $info->{'header'};
+  if(ref($h) eq 'CODE') {
+    $batch .= &$h($self, \@cust_pay_batch) . "\n";
+  }
+  else {
+    $batch .= $h . "\n";
+  }
+  foreach my $cust_pay_batch (@cust_pay_batch) {
+    if($first_download) {
+      my $balance = $cust_pay_batch->cust_main->balance;
+      my $error = '';
+      if($balance <= 0) { # then don't charge this customer
+        $error = $cust_pay_batch->delete;
+        undef $cust_pay_batch;
+      }
+      elsif($balance < $cust_pay_batch->amount) { # then reduce the charge to the remaining balance
+        $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, $batchcount, $batchtotal) . "\n";
+    }
+  }
+  my $f = $info->{'footer'};
+  if(ref($f) eq 'CODE') {
+    $batch .= &$f($self, $batchcount, $batchtotal) . "\n";
+  }
+  else {
+    $batch .= $f . "\n";
+  }
+
+  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;
+}
+
 =back
 
 =head1 BUGS