tax engine refactoring for Avalara and Billsoft tax vendors, #25718
[freeside.git] / FS / FS / pay_batch.pm
index 4f223e1..b6b69f3 100644 (file)
@@ -1,19 +1,19 @@
 package FS::pay_batch;
+use base qw( FS::Record );
 
 use strict;
-use vars qw( @ISA $DEBUG %import_info %export_info $conf );
+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 Date::Parse qw(str2time);
+use Business::CreditCard qw(cardtype);
+use FS::Misc qw(send_email); # for error notification
 use FS::Record qw( dbh qsearch qsearchs );
 use FS::Conf;
 use FS::cust_pay;
-use FS::agent;
-use Date::Parse qw(str2time);
-use Business::CreditCard qw(cardtype);
-use Scalar::Util 'blessed';
-use IO::Scalar;
-
-@ISA = qw(FS::Record);
 
 =head1 NAME
 
@@ -49,10 +49,14 @@ from FS::Record.  The following fields are currently supported:
 
 =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 upload - 
+=item title - unique batch identifier
 
+For incoming batches, the combination of 'title', 'payby', and 'agentnum'
+must be unique.
 
 =back
 
@@ -118,9 +122,22 @@ sub check {
     || $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;
 
+  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;
 }
 
@@ -128,22 +145,10 @@ sub check {
 
 Returns the L<FS::agent> object for this batch.
 
-=cut
-
-sub agent {
-  qsearchs('agent', { 'agentnum' => $_[0]->agentnum });
-}
-
 =item cust_pay_batch
 
 Returns all L<FS::cust_pay_batch> objects for this batch.
 
-=cut
-
-sub cust_pay_batch {
-  qsearch('cust_pay_batch', { 'batchnum' => $_[0]->batchnum });
-}
-
 =item rebalance
 
 =cut
@@ -182,7 +187,7 @@ foreach my $INC (@INC) {
              \\%FS::pay_batch::$mod\::export_info,
              \$FS::pay_batch::$mod\::name)";
     $name ||= $mod; # in case it's not defined
-    if$@) {
+    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;
@@ -225,11 +230,6 @@ sub import_results {
   my $job = $param->{'job'};
   $job->update_statustext(0) if $job;
 
-  my $gateway = $param->{'gateway'};
-  if ( $gateway ) {
-    return $self->import_from_gateway($gateway, 'file' => $fh, 'job' => $job);
-  }
-
   my $format = $param->{'format'};
   my $info = $import_info{$format}
     or die "unknown format $format";
@@ -387,12 +387,12 @@ sub import_results {
       foreach ('paid', '_date', 'payinfo') {
         $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
       }
-      $error = $new_cust_pay_batch->approve($hash{'paybatch'} || $self->batchnum);
+      $error = $new_cust_pay_batch->approve(%hash);
       $total += $hash{'paid'};
 
     } elsif ( &{$declined_condition}(\%hash) ) {
 
-      $error = $new_cust_pay_batch->decline;
+      $error = $new_cust_pay_batch->decline($hash{'error_message'});;
 
     }
 
@@ -436,17 +436,12 @@ sub import_results {
 
 }
 
-use MIME::Base64;
-use Storable 'thaw';
 use Data::Dumper;
 sub process_import_results {
   my $job = shift;
-  my $param = thaw(decode_base64(shift));
+  my $param = shift;
   $param->{'job'} = $job;
   warn Dumper($param) if $DEBUG;
-  my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
-  my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
-
   my $gatewaynum = delete $param->{'gatewaynum'};
   if ( $gatewaynum ) {
     $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
@@ -461,12 +456,20 @@ sub process_import_results {
         '<',
         "$dir/$file" )
       or die "unable to open '$file'.\n";
-  my $error = $batch->import_results($param);
+  
+  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 GATEWAY [ OPTIONS ]
+=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.
@@ -477,15 +480,16 @@ or declined payment can have its status changed by a later import.
 
 OPTIONS may include:
 
-- file: a file name or handle to use as a data source.
+- 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 $gateway = shift;
   my %opt = @_;
+  my $gateway = $opt{'gateway'};
   my $conf = FS::Conf->new;
 
   # unavoidable duplication with import_batch, for now
@@ -508,121 +512,257 @@ sub import_from_gateway {
     unless eval { $gateway->isa('FS::payment_gateway') };
 
   my %proc_opt = (
-    'input' => $opt{'file'}, # will do nothing if it's empty
+    'input' => $opt{'filehandle'}, # will do nothing if it's empty
     # any other constructor options go here
   );
 
+  my @item_errors;
+  my $mail_on_error = $conf->config('batch-errors_to');
+  if ( $mail_on_error ) {
+    # 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 @batches = $processor->receive;
-  my $error;
+
   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) {
-      # cust_pay_batch.paybatchnum should be in the 'tid' attribute
-      my $paybatchnum = $item->tid;
-      my $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
-      if (!$cust_pay_batch) {
-        # XXX for one-way batch protocol this needs to create new payments
-        $error = "unknown paybatchnum $paybatchnum";
-        last ITEM;
-      }
 
-      my $batchnum = $cust_pay_batch->batchnum;
-      if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
-        warn "batch ID ".$batch->batch_id.
-              " does not match batchnum ".$cust_pay_batch->batchnum."\n";
-      }
+      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;
 
-      # lock the batch and check its status
-      my $pay_batch = FS::pay_batch->by_key($batchnum);
-      $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
-      if ( $pay_batch->status ne 'I' and !$reconsider ) {
-        $error = "batch $batchnum no longer in transit";
-        last ITEM;
-      }
+      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";
+        }
 
-      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
+        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;
         }
-        elsif ( !$reconsider ) {
-          # then we're not allowed to change its status, so bail out
-          $error = "paybatchnum ".$item->tid.
+        $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 . "'";
-          last ITEM;
+          }
         }
-      }
 
-      # create a new cust_pay_batch with whatever information we got back
-      my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
-      my $new_payinfo;
-      # update payinfo, if needed
-      if ( $item->assigned_token ) {
-        $new_payinfo = $item->assigned_token;
-      } elsif ( $cust_pay_batch->payby eq 'CARD' ) {
-        $new_payinfo = $item->card_number if $item->card_number;
-      } else { #$cust_pay_batch->payby eq 'CHEK'
-        $new_payinfo = $item->account_number . '@' . $item->routing_code
-          if $item->account_number;
-      }
-      $new_cust_pay_batch->payinfo($new_payinfo) if $new_payinfo;
+        if ( $error ) {        
+          push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
+          next ITEM;
+        }
 
-      # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
-      # paid, if the batch says it's different from the amount requested
-      if ( defined $item->amount ) {
-        $new_cust_pay_batch->paid($item->amount);
-      } else {
-        $new_cust_pay_batch->paid($cust_pay_batch->amount);
-      }
+        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
 
-      # set payment date to when it was processed
-      $new_cust_pay_batch->_date($item->payment_date->epoch)
-        if $item->payment_date;
-
-      # approval status
-      if ( $item->approved ) {
-        # follow Billing_Realtime format for paybatch
-        my $paybatch = $gateway->gatewaynum .
-          '-' .
-          $gateway->gateway_module .
-          ':' .
-          $item->authorization .
-          ':' .
-          $item->order_number;
-
-        $error = $new_cust_pay_batch->approve($paybatch);
-        $total += $new_cust_pay_batch->paid;
-      }
-      else {
-        $error = $new_cust_pay_batch->decline($item->error_message);
-      }
-      last ITEM if $error;
       $num++;
-      $job->update_statustext(int(100 * $num/( $batch->count + 1 ) ),
+      $job->update_statustext(int(100 * $num/( $total_items ) ),
         'Importing batch items')
-        if $job;
+      if $job;
+
     } #foreach $item
 
-    if ( $error ) {
+  } #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 ( $mail_on_error ) {
+      my $subject = "Batch import errors"; #?
+      my $body = "Import from gateway ".$gateway->label."\n".$error_text;
+      send_email(
+        to      => $mail_on_error,
+        from    => $conf->config('invoice_from'),
+        subject => $subject,
+        body    => $body,
+      );
+    } else {
+      # Bail out.
       $dbh->rollback if $oldAutoCommit;
-      return $error;
+      die $error_text;
     }
+  }
 
-  } #foreach $batch (input batch, not pay_batch)
-
-  # Auto-resolve
+  # Auto-resolve (with brute-force error handling)
   foreach my $pay_batch (values %pay_batch_for_update) {
-    $error = $pay_batch->try_to_resolve;
+    my $error = $pay_batch->try_to_resolve;
 
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -637,7 +777,7 @@ sub import_from_gateway {
 =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'
+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 
@@ -658,8 +798,8 @@ sub try_to_resolve {
     }
   );
 
-  if ( @unresolved ) {
-    my $days = $conf->config('batch-auto_resolve_days') || '';
+  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 
@@ -680,6 +820,9 @@ sub try_to_resolve {
       }
       return $error if $error;
     }
+  } elsif ( @unresolved ) {
+    # auto resolve is not enabled, and we're not ready to resolve
+    return;
   }
 
   $self->set_status('R');
@@ -712,6 +855,9 @@ sub prepare_for_export {
     return "error updating pay_batch status: $error\n" if $error;
   } 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";
   }
@@ -785,7 +931,7 @@ sub export_batch {
 
   my $info = $export_info{$format} or die "Format not found: '$format'\n";
 
-  &{$info->{'init'}}($conf) if exists($info->{'init'});
+  &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'});
 
   my $oldAutoCommit = $FS::UID::AutoCommit;
   local $FS::UID::AutoCommit = 0;
@@ -876,7 +1022,6 @@ sub manual_approve {
   my $self = shift;
   my $date = time;
   my %opt = @_;
-  my $paybatch = $opt{'paybatch'} || $self->batchnum;
   my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
   my $conf = FS::Conf->new;
   return 'manual batch approval disabled' 
@@ -906,7 +1051,9 @@ sub manual_approve {
       '_date'   => $date,
       'usernum' => $usernum,
     };
-    my $error = $new_cust_pay_batch->approve($paybatch);
+    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";
@@ -931,7 +1078,7 @@ sub _upgrade_data {
   for my $format (keys %export_info) {
     my $mod = "FS::pay_batch::$format";
     if ( $mod->can('_upgrade_gateway') 
-        and length( $conf->config("batchconfig-$format") ) ) {
+        and $conf->exists("batchconfig-$format") ) {
 
       local $@;
       my ($module, %gw_options) = $mod->_upgrade_gateway;
@@ -960,7 +1107,7 @@ sub _upgrade_data {
 
         # and if appropriate, make it the system default
         for my $payby (qw(CARD CHEK)) {
-          if ( $conf->config("batch-fixed_format-$payby") eq $format ) {
+          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");