RT#41568: Add ACH support to B::OP::ElavonVirtualMerchant
authorJonathan Prykop <jonathan@freeside.biz>
Fri, 8 Apr 2016 11:55:51 +0000 (06:55 -0500)
committerJonathan Prykop <jonathan@freeside.biz>
Fri, 8 Apr 2016 11:55:51 +0000 (06:55 -0500)
ElavonVirtualMerchant.pm

index 438c696..29ed092 100644 (file)
@@ -1,11 +1,13 @@
 package Business::OnlinePayment::ElavonVirtualMerchant;
-use base qw(Business::OnlinePayment::viaKLIX);
+use base qw(Business::OnlinePayment::HTTPS);
 
 use strict;
-use vars qw( $VERSION %maxlength );
+use vars qw( $VERSION $DEBUG %maxlength );
+use Carp;
 
 $VERSION = '0.03';
 $VERSION = eval $VERSION;
+$DEBUG   = 0;
 
 =head1 NAME
 
@@ -15,9 +17,9 @@ Business::OnlinePayment::ElavonVirtualMerchant - Elavon Virtual Merchant backend
 
   use Business::OnlinePayment::ElavonVirtualMerchant;
 
-  my $tx = new Business::OnlinePayment("ElavonVirtualMerchant", { default_ssl_userid => 'whatever' });
+  my $tx = new Business::OnlinePayment("ElavonVirtualMerchant", { default_ssl_user_id => 'whatever' });
     $tx->content(
-        type           => 'VISA',
+        type           => 'CC',
         login          => 'testdrive',
         password       => '', #password or transaction key
         action         => 'Normal Authorization',
@@ -33,7 +35,7 @@ Business::OnlinePayment::ElavonVirtualMerchant - Elavon Virtual Merchant backend
         zip            => '84058',
         card_number    => '4007000000027',
         expiration     => '09/02',
-        cvv2           => '1234', #optional
+        cvv2           => '1234',
     );
     $tx->submit();
 
@@ -45,21 +47,50 @@ Business::OnlinePayment::ElavonVirtualMerchant - Elavon Virtual Merchant backend
 
 =head1 DESCRIPTION
 
-This module lets you use the Elavon (formerly Nova Information Systems) Virtual Merchant real-time payment gateway, a successor to viaKlix, from an application that uses the Business::OnlinePayment interface.
+This module lets you use the Elavon (formerly Nova Information Systems) Converge
+(formerly Virtual Merchant, a successor of viaKlix) real-time payment gateway 
+from an application that uses the Business::OnlinePayment interface.
 
-You need an account with Elavon.  Elavon uses a three-part set of credentials to allow you to configure multiple 'virtual terminals'.  Since Business::OnlinePayment only passes a login and password with each transaction, you must pass the third item, the user_id, to the constructor.
+You need an account with Elavon.  Elavon uses a three-part set of credentials to 
+allow you to configure multiple 'virtual terminals'.  Since Business::OnlinePayment 
+only passes a login and password with each transaction, you must pass the third item,
+default_ssl_user_id, to the constructor.  You may pass defaults for other Converge 
+request fields to the constructor by prepending the field names with default_.
 
-Elavon offers a number of transaction types, including electronic gift card operations and 'PINless debit'.  Of these, only credit card transactions fit the Business::OnlinePayment model.
+Converge offers a number of transaction types.  Of these, only credit card sale
+(ccsale), credit card refund (cccredit) and echeck sale (ecspurchase) transactions 
+are currently supported.
 
-Since the Virtual Merchant API is just a newer version of the viaKlix API, this module subclasses Business::OnlinePayment::viaKlix.
+=head1 SUBROUTINES
 
-This module does not use Elavon's XML encoding as this doesn't appear to offer any benefit over the standard encoding.
+=cut
 
-=head1 SUBROUTINES
+=head2 debug LEVEL
+
+Get/set debug level
+
+=cut
+
+sub debug {
+    my $self = shift;
+
+    if (@_) {
+        my $level = shift || 0;
+        if ( ref($self) ) {
+            $self->{"__DEBUG"} = $level;
+        }
+        else {
+            $DEBUG = $level;
+        }
+        $Business::OnlinePayment::HTTPS::DEBUG = $level;
+    }
+    return ref($self) ? ( $self->{"__DEBUG"} || $DEBUG ) : $DEBUG;
+}
 
 =head2 set_defaults
 
-Sets defaults for the Virtual Merchant gateway URL.
+Sets defaults for the Converge gateway URL
+and initializes internal data structures.
 
 =cut
 
@@ -67,12 +98,30 @@ sub set_defaults {
     my $self = shift;
     my %opts = @_;
 
-    $self->SUPER::set_defaults(%opts);
     # standard B::OP methods/data
     $self->server("www.myvirtualmerchant.com");
     $self->port("443");
     $self->path("/VirtualMerchant/process.do");
 
+    $self->build_subs(qw( 
+                          order_number avs_code cvv2_response
+                          response_page response_code response_headers
+                     ));
+
+    # module specific data
+    if ( $opts{debug} ) {
+        $self->debug( $opts{debug} );
+        delete $opts{debug};
+    }
+
+    my %_defaults = ();
+    foreach my $key (keys %opts) {
+      $key =~ /^default_(\w*)$/ or next;
+      $_defaults{$1} = $opts{$key};
+      delete $opts{$key};
+    }
+    $self->{_defaults} = \%_defaults;
+
 }
 
 =head2 _map_fields
@@ -86,32 +135,111 @@ sub _map_fields {
 
     my %content = $self->content();
 
-    #ACTION MAP
-    my %actions = (
-        'normal authorization' => 'CCSALE',  # Authorization/Settle transaction
-        'credit'               => 'CCCREDIT', # Credit (refund)
-    );
+    if (uc($self->transaction_type) eq 'ECHECK') {
 
-    $content{'ssl_transaction_type'} = $actions{ lc( $content{'action'} ) }
-      || $content{'action'};
+      $content{'ssl_transaction_type'} = 'ECSPURCHASE';
 
-    # TYPE MAP
-    my %types = (
-        'visa'             => 'CC',
-        'mastercard'       => 'CC',
-        'american express' => 'CC',
-        'discover'         => 'CC',
-        'cc'               => 'CC',
-    );
+    } else { # or credit card, or non-supported type (support checked during submit)
+
+      #ACTION MAP
+      my %actions = (
+          'normal authorization' => 'CCSALE',  # Authorization/Settle transaction
+          'credit'               => 'CCCREDIT', # Credit (refund)
+      );
+
+      $content{'ssl_transaction_type'} = $actions{ lc( $content{'action'} ) }
+        || $content{'action'};
+
+      # TYPE MAP
+      my %types = (
+          'visa'             => 'CC',
+          'mastercard'       => 'CC',
+          'american express' => 'CC',
+          'discover'         => 'CC',
+          'cc'               => 'CC',
+      );
 
-    $content{'type'} = $types{ lc( $content{'type'} ) } || $content{'type'};
+      $content{'type'} = $types{ lc( $content{'type'} ) } || $content{'type'};
 
-    $self->transaction_type( $content{'type'} );
+      $self->transaction_type( $content{'type'} );
+
+    } # end credit card
 
     # stuff it back into %content
     $self->content(%content);
 }
 
+=head2 _revmap_fields
+
+Accepts I<%map> and sets the content field specified
+by map keys to be the value of the content field
+specified by map values, e.g.
+
+       ssl_merchant_id => 'login'
+
+will set ssl_merchant_id to the current value of login.
+
+Values may also be references to strings, e.g.
+
+       ssl_exp_date => \$expdate_mmyy,
+
+will set ssl_exp_date to the value of $expdate_mmyy.
+
+=cut
+
+sub _revmap_fields {
+    my ( $self, %map ) = @_;
+    my %content = $self->content();
+    foreach ( keys %map ) {
+        $content{$_} =
+          ref( $map{$_} )
+          ? ${ $map{$_} }
+          : $content{ $map{$_} };
+    }
+    $self->content(%content);
+}
+
+=head2 expdate_mmyy
+
+Accepts I<$expiration>.  Returns mmyy normalized value,
+or original value if it couldn't be normalized.
+
+=cut
+
+sub expdate_mmyy {
+    my $self       = shift;
+    my $expiration = shift;
+    my $expdate_mmyy;
+    if ( defined($expiration) and $expiration =~ /^(\d+)\D+\d*(\d{2})$/ ) {
+        my ( $month, $year ) = ( $1, $2 );
+        $expdate_mmyy = sprintf( "%02d", $month ) . $year;
+    }
+    return defined($expdate_mmyy) ? $expdate_mmyy : $expiration;
+}
+
+=head2 required_fields
+
+Accepts I<@fields> and makes sure each of those fields
+have been set in content.
+
+=cut
+
+sub required_fields {
+    my($self,@fields) = @_;
+
+    my @missing;
+    my %content = $self->content();
+    foreach(@fields) {
+      next
+        if (exists $content{$_} && defined $content{$_} && $content{$_}=~/\S+/);
+      push(@missing, $_);
+    }
+
+    Carp::croak("missing required field(s): " . join(", ", @missing) . "\n")
+      if(@missing);
+
+}
+
 =head2 submit
 
 Maps data from Business::OnlinePayment name space to Elavon's, checks that all required fields
@@ -145,26 +273,53 @@ sub submit {
     $self->_map_fields();
 
     my %content = $self->content;
+    warn "INITIAL PARAMETERS:\n" . join("\n", map{ "$_ => $content{$_}" } keys(%content)) if $self->debug;
 
     my %required;
-    $required{CC_CCSALE} =  [ qw( ssl_transaction_type ssl_merchant_id ssl_pin
-                                ssl_amount ssl_card_number ssl_exp_date
-                                ssl_cvv2cvc2_indicator 
-                              ) ];
+    my @alwaysrequired = qw(
+      ssl_transaction_type
+      ssl_merchant_id
+      ssl_pin
+      ssl_user_id
+      ssl_amount
+    );
+    $required{CC_CCSALE} =  [ @alwaysrequired, qw(
+                                ssl_card_number
+                                ssl_exp_date
+                                ssl_cvv2cvc2_indicator
+                              ),
+                            ];
     $required{CC_CCCREDIT} = $required{CC_CCSALE};
+    $required{ECHECK_ECSPURCHASE} = [ @alwaysrequired,
+                                      qw(
+                                        ssl_aba_number
+                                        ssl_bank_account_number
+                                        ssl_bank_account_type
+                                        ssl_agree
+                                      ),
+                                    ];
     my %optional;
-    $optional{CC_CCSALE} =  [ qw( ssl_user_id ssl_salestax ssl_cvv2cvc2
+    # these are actually each sometimes required, depending on account type & settings,
+    # but we can let converge handle error messages for that
+    my @alwaysoptional = qw(
+      ssl_first_name
+      ssl_last_name
+      ssl_company
+      ssl_email
+    );
+    $optional{CC_CCSALE} =  [ @alwaysoptional, qw( ssl_salestax ssl_cvv2cvc2
                                 ssl_description ssl_invoice_number
-                                ssl_customer_code ssl_company ssl_first_name
-                                ssl_last_name ssl_avs_address ssl_address2
+                                ssl_customer_code
+                                ssl_avs_address ssl_address2
                                 ssl_city ssl_state ssl_avs_zip ssl_country
-                                ssl_phone ssl_email ssl_ship_to_company
+                                ssl_phone ssl_ship_to_company
                                 ssl_ship_to_first_name ssl_ship_to_last_name
                                 ssl_ship_to_address1 ssl_ship_to_city
                                 ssl_ship_to_state ssl_ship_to_zip
                                 ssl_ship_to_country
                               ) ];
     $optional{CC_CCCREDIT} = $optional{CC_CCSALE};
+    $optional{ECHECK_ECSPURCHASE} = [ @alwaysoptional ];
 
     my $type_action = $self->transaction_type(). '_'. $content{ssl_transaction_type};
     unless ( exists($required{$type_action}) ) {
@@ -174,18 +329,26 @@ sub submit {
       return;
     }
 
-    my $expdate_mmyy = $self->expdate_mmyy( $content{"expiration"} );
-    my $zip          = $content{'zip'};
-    $zip =~ s/[^[:alnum:]]//g;
+    $self->_revmap_fields(
+      ssl_merchant_id => 'login',
+      ssl_pin         => 'password',
+      ssl_amount      => 'amount',
+      ssl_first_name  => 'first_name',
+      ssl_last_name   => 'last_name',
+      ssl_company     => 'company',
+      ssl_email       => 'email',
+    );
+
+    if (uc($self->transaction_type) eq 'CC') {
 
-    my $cvv2indicator = $content{"cvv2"} ? 1 : 9; # 1 = Present, 9 = Not Present
+      my $expdate_mmyy = $self->expdate_mmyy( $content{"expiration"} );
+      my $zip          = $content{'zip'};
+      $zip =~ s/[^[:alnum:]]//g;
 
-    $self->_revmap_fields(
+      my $cvv2indicator = $content{"cvv2"} ? 1 : 9; # 1 = Present, 9 = Not Present
 
-        ssl_merchant_id        => 'login',
-        ssl_pin                => 'password',
+      $self->_revmap_fields(
 
-        ssl_amount             => 'amount',
         ssl_card_number        => 'card_number',
         ssl_exp_date           => \$expdate_mmyy,    # MMYY from 'expiration'
         ssl_cvv2cvc2_indicator => \$cvv2indicator,
@@ -194,16 +357,12 @@ sub submit {
         ssl_invoice_number     => 'invoice_number',
         ssl_customer_code      => 'customer_id',
 
-        ssl_first_name         => 'first_name',
-        ssl_last_name          => 'last_name',
-        ssl_company            => 'company',
         ssl_avs_address        => 'address',
         ssl_city               => 'city',
         ssl_state              => 'state',
         ssl_avs_zip            => \$zip,          # 'zip' with non-alnums removed
         ssl_country            => 'country',
         ssl_phone              => 'phone',
-        ssl_email              => 'email',
 
         ssl_ship_to_first_name => 'ship_first_name',
         ssl_ship_to_last_name  => 'ship_last_name',
@@ -214,27 +373,52 @@ sub submit {
         ssl_ship_to_zip        => 'ship_zip',
         ssl_ship_to_country    => 'ship_country',
 
-    );
+      );
+
+    } else { # ECHECK
 
+      my $account_type;
+      if (uc($content{'account_type'}) =~ 'PERSONAL') {
+        $account_type = 0;
+      } elsif (uc($content{'account_type'}) =~ 'BUSINESS') {
+        $account_type = 1;
+      } else {
+        $self->error_message("Unrecognized account type: ".$content{'account_type'});
+        $self->is_success(0);
+        return;
+      }
+
+      $self->_revmap_fields(
+        ssl_aba_number          => 'routing_code',
+        ssl_bank_account_number => 'account_number',
+        ssl_bank_account_type   => \$account_type,
+        ssl_agree               => \'1',
+      );
+
+    }
+
+    # set defaults for anything that hasn't been set yet
+    %content = $self->content;
+    foreach ( keys ( %{($self->{_defaults})} ) ) {
+      $content{$_} ||= $self->{_defaults}->{$_};
+    }
+    $self->content(%content);
+
+    # truncate long rows & validate required fields
     my %params = $self->get_fields( @{$required{$type_action}},
                                     @{$optional{$type_action}},
                                   );
-
     $params{$_} = substr($params{$_},0,$maxlength{$_})
       foreach grep exists($maxlength{$_}), keys %params;
+    $self->required_fields(@{$required{$type_action}});
 
-    foreach ( keys ( %{($self->{_defaults})} ) ) {
-      $params{$_} = $self->{_defaults}->{$_} unless exists($params{$_});
-    }
-
+    # some final non-overridable parameters
     $params{ssl_test_mode}='true' if $self->test_transaction;
-    
     $params{ssl_show_form}='false';
     $params{ssl_result_format}='ASCII';
-
-    $self->required_fields(@{$required{$type_action}});
     
-    warn join("\n", map{ "$_ => $params{$_}" } keys(%params)) if $self->debug > 1;
+    # send request
+    warn "POST PARAMETERS:\n" . join("\n", map{ "$_ => $params{$_}" } keys(%params)) if $self->debug;
     my ( $page, $resp, %resp_headers ) = 
       $self->https_post( %params );
 
@@ -242,15 +426,17 @@ sub submit {
     $self->response_page( $page );
     $self->response_headers( \%resp_headers );
 
-    warn "$page\n" if $self->debug > 1;
+    warn "RESPONSE FROM SERVER:\n$page\n" if $self->debug;
     # $page should contain key/value pairs
 
     my $status ='';
     my %results = map { s/\s*$//; split '=', $_, 2 } grep { /=/ } split '^', $page;
 
-    # AVS and CVS values may be set on success or failure
-    $self->avs_code( $results{ssl_avs_response} );
-    $self->cvv2_response( $results{ ssl_cvv2_response } );
+    if (uc($self->transaction_type) eq 'CC') {
+      # AVS and CVS values may be set on success or failure
+      $self->avs_code( $results{ssl_avs_response} );
+      $self->cvv2_response( $results{ ssl_cvv2_response } );
+    }
     $self->result_code( $status = $results{ errorCode } || $results{ ssl_result } );
     $self->order_number( $results{ ssl_txn_id } );
     $self->authorization( $results{ ssl_approval_code } );
@@ -269,21 +455,20 @@ __END__
 
 =head1 SEE ALSO
 
-Business::OnlinePayment, Business::OnlinePayment::viaKlix, Elavon Virtual Merchant Developers' Guide
-
-=head1 AUTHOR
-
-Richard Siddall, E<lt>elavon@elirion.netE<gt>
+L<Business::OnlinePayment>, L<Business::OnlinePayment::HTTPS>, Elavon Converge Developers' Guide
 
 =head1 BUGS
 
 Duplicates code to handle deprecated 'type' codes.
 
-Method for passing raw card track data is not documented by Elavon.
+Only provides a small selection of possible transaction types.
 
 =head1 COPYRIGHT AND LICENSE
 
-Copyright (C) 2009 by Richard Siddall.  This module is largely based on Business::OnlinePayment::viaKlix by Jeff Finucane.
+Copyright (C) 2016 Freeside Internet Services.
+
+Based on the original ElavonVirtualMerchant module by Richard Siddall,
+which was largely based on Business::OnlinePayment::viaKlix by Jeff Finucane.
 
 This library is free software; you can redistribute it and/or modify
 it under the same terms as Perl itself, either Perl version 5.8.8 or,