failure_status support
[Business-OnlinePayment-PaymenTech.git] / lib / Business / OnlinePayment / PaymenTech.pm
index bbbae48..5ae91b7 100644 (file)
@@ -8,7 +8,10 @@ use Tie::IxHash;
 use vars qw($VERSION $DEBUG @ISA $me);
 
 @ISA = qw(Business::OnlinePayment::HTTPS);
-$VERSION = '2.00';
+
+$VERSION = '2.05';
+$VERSION = eval $VERSION; # modperlstyle: convert the string into a number
+
 $DEBUG = 0;
 $me='Business::OnlinePayment::PaymenTech';
 
@@ -17,33 +20,57 @@ my %request_header = (
   'Content-Transfer-Encoding' => 'text',
   'Request-Number'  =>    1,
   'Document-Type'   =>    'Request',
-  #'Trace-Number'    =>    1,
   'Interface-Version' =>  "$me $VERSION",
 ); # Content-Type has to be passed separately
 
 tie my %new_order, 'Tie::IxHash', (
-  OrbitalConnectionUsername => ':login',
-  OrbitalConnectionPassword => ':password',
-  IndustryType              => 'EC', # Assume industry = Ecommerce
-  MessageType               => ':message_type',
-  BIN                       => ':bin',
-  MerchantID                => ':merchant_id',
-  TerminalID                => ':terminal_id',
-  CardBrand                 => '',
-  AccountNum                => ':card_number',
-  Exp                       => ':expiration',
-  CurrencyCode              => ':currency_code',
-  CurrencyExponent          => ':currency_exp',
-  CardSecValInd             => ':cvvind',
-  CardSecVal                => ':cvv2',
-#  AVSname                   => ':name', not needed
-  AVSzip                    => ':zip',
-  AVSaddress1               => ':address',
-  AVScity                   => ':city',
-  AVSstate                  => ':state',
-  OrderID                   => ':invoice_number',
-  Amount                    => ':amount',
-  Comments                  => ':email', # as per B:OP:WesternACH
+  OrbitalConnectionUsername => [ ':login', 32 ],
+  OrbitalConnectionPassword => [ ':password', 32 ],
+  IndustryType              => [ 'EC', 2 ],
+  MessageType               => [ ':message_type', 2 ],
+  BIN                       => [ ':bin', 6 ],
+  MerchantID                => [ ':merchant_id', 12 ],
+  TerminalID                => [ ':terminal_id', 3 ],
+  CardBrand                 => [ '', 2 ], 
+  AccountNum                => [ ':card_number', 19 ],
+  Exp                       => [ ':expiration', 4 ],
+  CurrencyCode              => [ ':currency_code', 3 ],
+  CurrencyExponent          => [ ':currency_exp', 6 ],
+  CardSecValInd             => [ ':cvvind', 1 ],
+  CardSecVal                => [ ':cvv2', 4 ],
+  AVSzip                    => [ ':zip', 10 ],
+  AVSaddress1               => [ ':address', 30 ],
+  AVScity                   => [ ':city', 20 ],
+  AVSstate                  => [ ':state', 2 ],
+  AVScountryCode            => [ ':country', 2 ],
+  OrderID                   => [ ':invoice_number', 22 ], 
+  Amount                    => [ ':amount', 12 ],
+  Comments                  => [ ':email', 64 ],
+  TxRefNum                  => [ ':order_number', 40 ],# used only for Refund
+);
+
+tie my %mark_for_capture, 'Tie::IxHash', (
+  OrbitalConnectionUsername => [ ':login', 32 ],
+  OrbitalConnectionPassword => [ ':password', 32 ],
+  OrderID                   => [ ':invoice_number', 22 ],
+  Amount                    => [ ':amount', 12 ],
+  BIN                       => [ ':bin', 6 ],
+  MerchantID                => [ ':merchant_id', 12 ],
+  TerminalID                => [ ':terminal_id', 3 ],
+  TxRefNum                  => [ ':order_number', 40 ],
+);
+
+tie my %reversal, 'Tie::IxHash', (
+  OrbitalConnectionUsername => [ ':login', 32 ],
+  OrbitalConnectionPassword => [ ':password', 32 ],
+  TxRefNum                  => [ ':order_number', 40 ],
+  TxRefIdx                  => [ '0', 4 ],
+  OrderID                   => [ ':invoice_number', 22 ],
+  BIN                       => [ ':bin', 6 ],
+  MerchantID                => [ ':merchant_id', 12 ],
+  TerminalID                => [ ':terminal_id', 3 ],
+  OnlineReversalInd         => [ 'Y', 1 ],
+# Always attempt to reverse authorization.
 );
 
 my %defaults = (
@@ -58,12 +85,6 @@ my @required = ( qw(
   action
   bin
   merchant_id
-  card_number
-  expiration
-  currency
-  address
-  city
-  zip
   invoice_number
   amount
   )
@@ -76,6 +97,34 @@ my %currency_code = (
   MXN => [484, 2],
 );
 
+my %paymentech_countries = map { $_ => 1 } qw( US CA GB UK );
+my %failure_status = (
+  # values of the RespCode element
+  # in theory RespMsg should be set to a descriptive message, but it looks
+  # like that's not reliable
+  # XXX we should have a way to indicate other actions required by the 
+  # processor, such as "honor with identification", "call for instructions",
+  # etc.
+  '00'  => undef,         # Approved
+  '04'  => 'pickup',      # Pickup
+  '33'  => 'expired',     # Card is Expired
+  '41'  => 'stolen',      # Lost/Stolen
+  '42'  => 'inactive',    # Account Not Active
+  '43'  => 'stolen',      # Lost/Stolen Card
+  '44'  => 'inactive',    # Account Not Active
+  #'45' duplicate transaction, should also have its own status
+  'B7'  => 'blacklisted', # Fraud
+  'B9'  => 'blacklisted', # On Negative File
+  'BB'  => 'stolen',      # Possible Compromise
+  'BG'  => 'blacklisted', # Blocked Account
+  'BQ'  => 'blacklisted', # Issuer has Flagged Account as Suspected Fraud
+  'C4'  => 'nsf',         # Over Credit Limit
+  'D5'  => 'blacklisted', # On Negative File
+  'D7'  => 'nsf',         # Insufficient Funds
+  'F3'  => 'inactive',    # Account Closed
+  'K6'  => 'nsf',         # NSF
+);
+
 sub set_defaults {
     my $self = shift;
 
@@ -83,8 +132,19 @@ sub set_defaults {
     $self->port('443') unless $self->port;
     $self->path('/authorize') unless $self->path;
 
-    $self->build_subs(qw( TxRefNum ProcStatus ApprovalStatus StatusMsg Response ));
-
+    $self->build_subs(qw( 
+      order_number
+    ));
+
+    #leaking gateway-specific anmes?  need to be mapped to B:OP standards :)
+    # ProcStatus 
+    # ApprovalStatus 
+    # StatusMsg 
+    # RespCode
+    # AuthCode
+    # AVSRespCode
+    # CVV2RespCode
+    # Response
 }
 
 sub build {
@@ -95,7 +155,8 @@ sub build {
   ref($skel) eq 'HASH' or die 'Tried to build non-hash';
   foreach my $k (keys(%$skel)) {
     my $v = $skel->{$k};
-    # Not recursive like B:OP:WesternACH; Paymentech requests are only one layer deep.
+    my $l;
+    ($v, $l) = @$v if(ref $v eq 'ARRAY');
     if($v =~ /^:(.*)/) {
       # Get the content field with that name.
       $data{$k} = $content{$1};
@@ -103,6 +164,8 @@ sub build {
     else {
       $data{$k} = $v;
     }
+    # Ruthlessly enforce field length.
+    $data{$k} = substr($data{$k}, 0, $l) if($data{$k} and $l);
   }
   return \%data;
 }
@@ -120,19 +183,15 @@ sub map_fields {
                   ('normal authorization' => 'AC',
                    'authorization only'   => 'A',
                    'credit'               => 'R',
+                   'void'                 => 'V',
                    'post authorization'   => 'MFC', # for our use, doesn't go in the request
                    ); 
     $content{'message_type'} = $message_type{lc($content{'action'})} 
       or die "unsupported action: '".$content{'action'}."'";
-    if($content{'message_type'} eq 'MFC') {
-      die 'MarkForCapture not implemented';
-      # for later implementation
-    }
 
     foreach (keys(%defaults) ) {
       $content{$_} = $defaults{$_} if !defined($content{$_});
     }
-    $DB::single=1;
     if(length($content{merchant_id}) == 12) {
       $content{bin} = '000002' # PNS
     }
@@ -158,16 +217,20 @@ sub map_fields {
     $content{name} = $content{first_name} . ' ' . $content{last_name};
 # According to the spec, the first 8 characters of this have to be unique.
 # The test server doesn't enforce this, but we comply anyway to the extent possible.
-    if($content{invoice_number}) {
-      # Mark it so that it's obvious that this is an invoice number
-      $content{invoice_number} = 'INV '.$content{invoice_number};
-    }
-    else {
-      # Otherwise, make something up!
+    if(! $content{invoice_number}) {
+      # Choose one arbitrarily
       $content{invoice_number} ||= sprintf("%04x%04x",time % 2**16,int(rand() * 2**16));
     }
 
-    $content{expiration} =~ s/\D//g; # Because Freeside sends it as mm/yy, not mmyy.
+    # Always send as MMYY
+    $content{expiration} =~ s/\D//g; 
+    $content{expiration} = sprintf('%04d',$content{expiration});
+
+    $content{country} ||= 'US';
+    $content{country} = ( $paymentech_countries{ $content{country} }
+                            ? $content{country}
+                            : ''
+                        ),
 
     $self->content(%content);
     return;
@@ -178,14 +241,31 @@ sub submit {
   $DB::single = $DEBUG;
 
   $self->map_fields();
+  my %content = $self->content;
 
-  # This will change when we add e-check support
   my @required_fields = @required;
 
-  $self->required_fields(@required_fields);
+  my $request;
+  if( $content{'message_type'} eq 'MFC' ) {
+    $request = { MarkForCapture => $self->build(\%mark_for_capture) };
+    push @required_fields, 'order_number';
+  }
+  elsif( $content{'message_type'} eq 'V' ) {
+    $request = { Reversal => $self->build(\%reversal) };
+  }
+  else { 
+    $request = { NewOrder => $self->build(\%new_order) }; 
+    push @required_fields, qw(
+      card_number
+      expiration
+      currency
+      address
+      city
+      zip
+      );
+  }
 
-  # This will change when we add mark-for-capture support
-  my $request = { NewOrder => $self->build(\%new_order) }; 
+  $self->required_fields(@required_fields);
 
   my $post_data = XMLout({ Request => $request }, KeepRoot => 1, NoAttr => 1, NoSort => 1);
 
@@ -202,28 +282,69 @@ sub submit {
 
   warn $page if $DEBUG;
 
-  my $response;
-  my $error = '';
-  if ($server_response =~ /200/){
-    $response = XMLin($page, KeepRoot => 0);
-    $self->Response($response);
-    my ($r) = values(%$response);
-    if(!exists($r->{'ProcStatus'})) {
-      $error = "Malformed response: '$page'";
-    }
-    elsif($r->{'ProcStatus'} != 0 || $r->{'ApprovalStatus'} != 1) {
-      $error = "Transaction error: '". ($r->{'ProcStatusMsg'} || $r->{'StatusMsg'}) . "'";
-    }
-    else {
-      # success!
+  my $response = XMLin($page, KeepRoot => 0);
+  #$self->Response($response);
+
+  #use Data::Dumper;
+  #warn Dumper($response) if $DEBUG;
+
+  my ($r) = values(%$response);
+  #foreach(qw(ProcStatus RespCode AuthCode AVSRespCode CVV2RespCode)) {
+  #  if(exists($r->{$_}) and
+  #     !ref($r->{$_})) {
+  #    $self->$_($r->{$_});
+  #  }
+  #}
+
+  foreach (keys %$r) {
+
+    #turn empty hashrefs into the empty string
+    $r->{$_} = '' if ref($r->{$_}) && ! keys %{ $r->{$_} };
+
+    #turn hashrefs with content into scalars
+    $r->{$_} = $r->{$_}{'content'}
+      if ref($r->{$_}) && exists($r->{$_}{'content'});
+  }
+
+  if ($server_response !~ /^200/) {
+
+    $self->is_success(0);
+    my $error = "Server error: '$server_response'";
+    $error .= " / Transaction error: '".
+              ($r->{'ProcStatusMsg'} || $r->{'StatusMsg'}) . "'"
+      if $r->{'ProcStatus'} != 0;
+    $self->error_message($error);
+
+  } else {
+
+    if ( !exists($r->{'ProcStatus'}) ) {
+
+      $self->is_success(0);
+      $self->error_message( "Malformed response: '$page'" );
+
+    } elsif ( $r->{'ProcStatus'} != 0 or 
+              # NewOrders get ApprovalStatus, Reversals don't.
+              ( exists($r->{'ApprovalStatus'}) ?
+                $r->{'ApprovalStatus'} != 1 :
+                $r->{'StatusMsg'} ne 'Approved' )
+            )
+    {
+
+      $self->failure_status( $failure_status{ $r->{RespCode} } || 'decline' );
+      $self->is_success(0);
+      $self->error_message( "Transaction error: '".
+                            ($r->{'ProcStatusMsg'} || $r->{'StatusMsg'}) . "'"
+                          );
+
+    } else { # success!
+
       $self->is_success(1);
-      $self->authorization($r->{'TxRefNum'});
+      # For credits, AuthCode is empty and gets converted to a hashref.
+      $self->authorization($r->{'AuthCode'}) if !ref($r->{'AuthCode'});
+      $self->order_number($r->{'TxRefNum'});
     }
-  }else{
-    $error = "Server error: '$server_response'";
+
   }
-  $self->error_message($error);
-  $self->is_success(0) if $error;
 
 }
 
@@ -236,39 +357,35 @@ Business::OnlinePayment::PaymenTech - Chase Paymentech backend for Business::Onl
 
 =head1 SYNOPSIS
 
-$trans = new Business::OnlinePayment('PaymenTech');
-$trans->content(
-  login           => "login",
-  password        => "password",
-  merchant_id     => "000111222333",
-  terminal_id     => "001",
-  type            => "CC",
-  card_number     => "5500000000000004",
-  expiration      => "0211",
-  address         => "123 Anystreet",
-  city            => "Sacramento",
-  zip             => "95824",
-  action          => "Normal Authorization",
-  amount          => "24.99",
-
-);
-
-$trans->submit;
-if($trans->is_approved) {
-  print "Approved: ".$trans->authorization;
-
-} else {
-  print "Failed: ".$trans->error_message;
-
-}
+  $trans = new Business::OnlinePayment('PaymenTech',
+    merchant_id     => "000111222333",
+    terminal_id     => "001",
+    currency        => "USD", # CAD, MXN
+  );
+
+  $trans->content(
+    login           => "login",
+    password        => "password",
+    type            => "CC",
+    card_number     => "5500000000000004",
+    expiration      => "0211",
+    address         => "123 Anystreet",
+    city            => "Sacramento",
+    zip             => "95824",
+    action          => "Normal Authorization",
+    amount          => "24.99",
+  );
+
+  $trans->submit;
+  if($trans->is_approved) {
+    print "Approved: ".$trans->authorization;
+  } else {
+    print "Failed: ".$trans->error_message;
+  }
 
 =head1 NOTES
 
-The only supported transaction types are Normal Authorization and Credit.  Paymentech 
-supports separate Authorize and Capture actions as well as recurring billing, but 
-those are not yet implemented.
-
-Electronic check processing is not yet supported.
+Electronic check processing and recurring billing are not yet supported.
 
 =head1 AUTHOR