0.08 Suppress elipsis character from strings truncated by Truncate::Unicode
[Business-BatchPayment-Paymentech.git] / Paymentech.pm
index bbe3d76..0743bba 100644 (file)
@@ -3,16 +3,14 @@ package Business::BatchPayment::Paymentech;
 use 5.006;
 use strict;
 use warnings;
-our $VERSION = '0.01';
+our $VERSION = '0.08';
+
+use Unicode::Truncate 'truncate_egc';
 
 =head1 NAME
 
 Business::BatchPayment::Paymentech - Chase Paymentech XML batch format.
 
-=head1 VERSION
-
-Version 0.01
-
 =head1 USAGE
 
 See L<Business::BatchPayment> for general usage notes.
@@ -30,6 +28,7 @@ my $processor = Business::BatchPayment->processor('Paymentech',
   industryType  => 'EC'
   login         => 'TESTUSER',
   password      => 'MYPASS',
+  with_recurringInd  => 1,
 );
 
 my $result = $processor->submit(@items);
@@ -55,6 +54,8 @@ unzip programs.  Unlikely to work on non-Unix systems.
 
 =item industryType - your 2-letter industry type code
 
+=item with_recurringInd - enable the recurring charge indicator field
+
 =back
 
 =cut
@@ -63,21 +64,30 @@ use File::Temp qw(tempdir);
 use DateTime;
 use XML::Writer;
 use XML::Simple;
+use Try::Tiny;
 
 use Moose;
 with 'Business::BatchPayment::Processor';
 with 'Business::BatchPayment::TestMode';
 
+use Encode;
+
 # could have some validation on all of these
 has [ qw(merchantID terminalID bin industryType login password) ] => (
-  is  => 'ro',
-  isa => 'Str',
+  is       => 'ro',
+  isa      => 'Str',
   required => 1,
 );
 
+has 'with_recurringInd' => (
+  is      => 'ro',
+  isa     => 'Bool',
+  default => 0,
+);
+
 has 'fileDateTime' => (
-  is => 'ro',
-  isa => 'Str',
+  is      => 'ro',
+  isa     => 'Str',
   default => sub {
     DateTime->now->strftime('%Y%m%d%H%M%S')
   },
@@ -90,12 +100,13 @@ my %BankAcctType = (
   'business savings'  => 'X',
 );
 
+my %paymentech_countries = map { $_ => 1 } qw( US CA GB UK );
+
 sub default_transport {
   my $self = shift;
   Business::BatchPayment::Paymentech::Transport->new(
     login     => $self->login,
     password  => $self->password,
-    put_path  => $self->fileDateTime,
     debug     => $self->debug,
     test_mode => $self->test_mode,
   );
@@ -105,91 +116,121 @@ sub format_request {
   my $self = shift;
   my $batch = shift;
 
-  # not doing anything with batch_id
-  my $items = $batch->items;
-  my $num_items = scalar @$items;
-
   my $output;
   my $xml = XML::Writer->new(
-    OUTPUT => \$output,
-    DATA_MODE => 1,
+    OUTPUT      => \$output,
+    DATA_MODE   => 1,
     DATA_INDENT => 2,
+    ENCODING    => 'utf-8',
   );
+  $self->format_header($batch, $xml);
+  my $count = 1;
+  foreach my $item ( @{ $batch->items } ) {
+    try {
+      $self->format_item($item, $batch, $xml, $count);
+      $count++;
+    } catch {
+      $self->format_error($item, $_);
+    };
+  }
+  $self->format_trailer($batch, $xml, $count);
+  return $output;
+}
+
+sub format_header {
+  my ($self, $batch, $xml) = @_;
+  my $num_items = $batch->count;
+
+  $xml->xmlDecl();
   $xml->startTag('transRequest', RequestCount => $num_items + 1);
   $xml->startTag('batchFileID');
   $xml->dataElement(userID => $self->login);
   $xml->dataElement(fileDateTime => $self->fileDateTime);
-  $xml->dataElement(fileID => $self->fileDateTime);
+  $xml->dataElement(fileID => sprintf('%06d-', $batch->batch_id) . 
+                              $self->fileDateTime);
   $xml->endTag('batchFileID');
+}
 
-  my $count = 1;
-  foreach my $item (@$items) {
-    if ( $item->action eq 'payment' ) {
-      $xml->startTag('newOrder', BatchRequestNo => $count);
-      my @order = (
-        industryType  => $self->industryType,
-        transType     => 'AC',
-        bin           => $self->bin,
-        merchantID    => $self->merchantID,
-        terminalID    => $self->terminalID,
+sub format_item {
+  my ($self, $item, $batch, $xml, $count) = @_;
+  if ( $item->action eq 'payment' ) {
+    $xml->startTag('newOrder', BatchRequestNo => $count);
+    my @order = (
+      industryType => $self->industryType,
+      transType    => 'AC',
+      bin          => $self->bin,
+      merchantID   => $self->merchantID,
+      terminalID   => $self->terminalID,
+    );
+    if ($item->payment_type eq 'CC') {
+      my $expiration = $item->expiration;
+      $expiration =~ s/\D//g;
+      push @order, (
+        ccAccountNum => $item->card_number,
+        ccExp        => $expiration,
       );
-      if ($item->payment_type eq 'CC') {
-        push @order, (
-          ccAccountNum  => $item->card_number,
-          ccExp         => $item->expiration,
-        );
-      }
-      elsif ( $item->payment_type eq 'ECHECK' ) {
-        push @order, (
-          cardBrand     => 'EC',
-          ecpCheckRT    => $item->routing_code,
-          ecpCheckDDA   => $item->account_number,
-          ecpBankAcctType => $BankAcctType{ $item->account_type },
-          ecpDelvMethod => 'A',
-        );
-      }
-      else {
-        die "payment type ".$item->type." not supported";
-      }
+    } elsif ( $item->payment_type eq 'ECHECK' ) {
       push @order, (
-        avsZip      => $item->zip,
-        avsAddress1 => substr($item->address,   0, 30),
-        avsAddress2 => substr($item->address2,  0, 30),
-        avsCity     => substr($item->city,      0, 20),
-        avsState    => $item->state,
-        avsName     => substr($item->first_name .' '. $item->last_name, 0, 30),
-        avsCountryCode => $item->country,
-        orderID     => $item->tid,
-        amount      => int( $item->amount * 100 ),
+        cardBrand       => 'EC',
+        ecpCheckRT      => $item->routing_code,
+        ecpCheckDDA     => $item->account_number,
+        ecpBankAcctType => $BankAcctType{ $item->account_type },
+        ecpDelvMethod   => 'A',
       );
-      while (@order) {
-        my $key = shift @order;
-        my $value = shift @order;
-        $xml->dataElement($key, $value);
+    } else {
+      die "payment type ".$item->type." not supported";
+    }
+    if ( $self->with_recurringInd ) {
+      if ( $item->recurring_billing eq 'F' ) {
+        push @order, ( recurringInd => 'RF' );
+      } elsif ( $item->recurring_billing eq 'S' ) {
+        push @order, ( recurringInd => 'RS' );
       }
-      $xml->endTag('newOrder');
-    } # if action eq 'payment'
-    else {
-      die "action ".$item->action." not supported";
+    } # else don't send recurringInd at all
+
+    push @order, (                   # truncate_egc will die() on empty string
+      avsZip      => $item->zip,
+      avsAddress1 => $item->address  ? truncate_egc($item->address,  30, '') : undef,
+      avsAddress2 => $item->address2 ? truncate_egc($item->address2, 30, '') : undef,
+      avsCity     => $item->city     ? truncate_egc($item->city,     20, '') : undef,
+      avsState    => $item->state    ? truncate_egc($item->state,     2, '') : undef,
+      avsName     => ($item->first_name || $item->last_name)
+                     ? truncate_egc($item->first_name.' '.$item->last_name, 30, '')
+                     : undef,
+      ( $paymentech_countries{ $item->country }
+        ? ( avsCountryCode  => $item->country )
+        : ()
+      ),
+      orderID        => $item->tid,
+      amount         => int( $item->amount * 100 ),
+    );
+    while (@order) {
+      my $key = shift @order;
+      my $value = shift @order;
+      $xml->dataElement($key, $value);
     }
+    $xml->endTag('newOrder');
+  } # if action eq 'payment'
+  else {
+    die "action ".$item->action." not supported";
+  }
+  '';
+}
 
-    $count++;
-  } # foreach $item
-
+sub format_trailer {
+  my ($self, $batch, $xml, $count) = @_;
   $xml->startTag('endOfDay', 'BatchRequestNo', $count);
   $xml->dataElement('bin' => $self->bin);
   $xml->dataElement('merchantID' => $self->merchantID);
   $xml->dataElement('terminalID' => $self->terminalID);
   $xml->endTag('endOfDay');
-
   $xml->endTag('transRequest');
-
-  return $output;
 }
 
 sub parse_response {
   my $self = shift;
   my $input = shift;
+  my $batch = Business::BatchPayment->create('Batch');
   
   my $tree = XML::Simple::XMLin($input, KeepRoot => 1);
   my $newOrderResp = $tree->{transResponse}->{newOrderResp};
@@ -197,36 +238,92 @@ sub parse_response {
     unless defined $newOrderResp;
 
   $newOrderResp = [ $newOrderResp ] if ref($newOrderResp) ne 'ARRAY';
-  my $items;
   foreach my $resp (@$newOrderResp) {
-    my ($mon, $day, $year, $hour, $min, $sec) =
-      $resp->{respDateTime} =~ /^(..)(..)(....)(..)(..)(..)$/;
-    my $dt = DateTime->new(
-      year    => $year,
-      month   => $mon,
-      day     => $day,
-      hour    => $hour,
-      minute  => $min,
-      second  => $sec,
-    );
-
-    my $item = Business::BatchPayment->create(Item =>
-        tid           => $resp->{orderID},
-        process_date  => $dt,
-        authorization => $resp->{authorizationCode},
-        order_number  => $resp->{txRefNum},
-        approved      => ($resp->{approvalStatus} == 1),
-        error_message => $resp->{procStatusMessage},
-    );
-    push @$items, $item;
+    try {
+      $batch->push( $self->parse_item($resp) );
+    } catch {
+      # parse_error needs a string representation of the 
+      # input data...and if it 's failing because it wasn't valid
+      # XML, we wouldn't get this far.
+      $self->parse_error(XML::Simple::XMLout($resp), $_);
+    };
   }
-  if ( @$items ) {
-    return Business::BatchPayment->create(Batch => items => $items);
+  $batch;
+}
+
+sub parse_item {
+  my ($self, $resp) = @_;
+
+  my ($mon, $day, $year, $hour, $min, $sec) =
+  $resp->{respDateTime} =~ /^(..)(..)(....)(..)(..)(..)$/;
+  my $dt = DateTime->new(
+    year    => $year,
+    month   => $mon,
+    day     => $day,
+    hour    => $hour,
+    minute  => $min,
+    second  => $sec,
+  );
+
+  my %failure_status = (
+    # API version 2.6, April 2013
+    '00'  => undef,       # Approved
+    '04'  => 'pickup',
+    '33'  => 'expired',
+    '41'  => 'stolen',
+    '42'  => 'inactive',
+    '43'  => 'stolen',
+    '44'  => 'inactive',
+    '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
+  ); # all others are "decline"
+
+  my $failure_status = undef;
+  my $error_message;
+
+  if ( $resp->{procStatus} ) {
+    $error_message = $resp->{procStatusMessage};
+  } elsif ( $resp->{respCode} ) {
+    $error_message = $resp->{respCodeMessage};
+    $failure_status = $failure_status{ $resp->{respCode} } || 'decline';
   } else {
-    return;
+    $error_message = '';
   }
+
+  my $item = Business::BatchPayment->create(Item =>
+    tid           => $resp->{orderID},
+    process_date  => $dt,
+    authorization => $resp->{authorizationCode},
+    order_number  => $resp->{txRefNum},
+    approved      => ($resp->{approvalStatus} == 1),
+    error_message => $error_message,
+    failure_status  => $failure_status,
+  );
+  $item;
 }
 
+# DEPRECATED
+
+# sub bytes_substr {
+#   my ($string, $offset, $length, $repl) = @_;
+#   my $bytes = substr(
+#     Encode::encode('utf8', $string || ''),
+#     $offset,
+#     $length,
+#     Encode::encode('utf8', $repl || '')
+#   );
+#   return Encode::decode('utf8', $bytes, Encode::FB_QUIET);
+# }
+
+
 package Business::BatchPayment::Paymentech::Transport;
 
 use File::Temp qw( tempdir );
@@ -262,7 +359,8 @@ sub upload {
   my $self = shift;
   my $content = shift;
   my $tmpdir = tempdir( CLEANUP => 1 );
-  my $filename = $self->put_path; # also the value of the fileId tag
+  $content =~ /<fileID>(.*)<\/fileID>/;
+  my $filename = $1;
   my $archive_dir = $self->archive_to;
 
   warn "Writing temp file to $tmpdir/$filename.xml.\n" if $self->debug;