From: jeff Date: Tue, 20 Nov 2007 03:03:08 +0000 (+0000) Subject: add ARB (recurring authorizations/subscriptions) support X-Git-Tag: Business_OnlinePayment_AuthorizeNet_3_18~2 X-Git-Url: http://git.freeside.biz/gitweb/?p=Business-OnlinePayment-AuthorizeNet.git;a=commitdiff_plain;h=511331906b11bc8f104f49b164a0a4b262099b0d add ARB (recurring authorizations/subscriptions) support --- diff --git a/AuthorizeNet.pm b/AuthorizeNet.pm index c9cebaa..188d895 100644 --- a/AuthorizeNet.pm +++ b/AuthorizeNet.pm @@ -1,275 +1,45 @@ -package Business::OnlinePayment::AuthorizeNet; +Package Business::OnlinePayment::AuthorizeNet; use strict; use Carp; use Business::OnlinePayment; -use Net::SSLeay qw/make_form post_https make_headers/; -use Text::CSV_XS; -use vars qw($VERSION @ISA @EXPORT @EXPORT_OK); +use vars qw($VERSION @ISA $me); -require Exporter; - -@ISA = qw(Exporter Business::OnlinePayment); -@EXPORT = qw(); -@EXPORT_OK = qw(); -$VERSION = '3.18'; +@ISA = qw(Business::OnlinePayment); +$VERSION = '3.19'; +$me = 'Business::OnlinePayment::AuthorizeNet'; sub set_defaults { my $self = shift; - $self->server('secure.authorize.net'); - $self->port('443'); - $self->path('/gateway/transact.dll'); - $self->build_subs(qw( order_number md5 avs_code cvv2_response cavv_response )); } -sub map_fields { +sub _map_processor { my($self) = @_; my %content = $self->content(); - - # ACTION MAP - my %actions = ('normal authorization' => 'AUTH_CAPTURE', - 'authorization only' => 'AUTH_ONLY', - 'credit' => 'CREDIT', - 'post authorization' => 'PRIOR_AUTH_CAPTURE', - 'void' => 'VOID', - ); - $content{'action'} = $actions{lc($content{'action'})} || $content{'action'}; - - # TYPE MAP - my %types = ('visa' => 'CC', - 'mastercard' => 'CC', - 'american express' => 'CC', - 'discover' => 'CC', - 'check' => 'ECHECK', - ); - $content{'type'} = $types{lc($content{'type'})} || $content{'type'}; - $self->transaction_type($content{'type'}); - - # ACCOUNT TYPE MAP - my %account_types = ('personal checking' => 'CHECKING', - 'personal savings' => 'SAVINGS', - 'business checking' => 'CHECKING', - 'business savings' => 'SAVINGS', - ); - $content{'account_type'} = $account_types{lc($content{'account_type'})} - || $content{'account_type'}; - - $content{'referer'} = defined( $content{'referer'} ) - ? make_headers( 'Referer' => $content{'referer'} ) - : ""; - - if (length $content{'password'} == 15) { - $content{'transaction_key'} = delete $content{'password'}; - } - - # stuff it back into %content - $self->content(%content); -} - -sub remap_fields { - my($self,%map) = @_; - - my %content = $self->content(); - foreach(keys %map) { - $content{$map{$_}} = $content{$_}; - } - $self->content(%content); -} - -sub get_fields { - my($self,@fields) = @_; - - my %content = $self->content(); - my %new = (); - foreach( grep defined $content{$_}, @fields) { $new{$_} = $content{$_}; } - return %new; + my %processors = ('recurring authorization' => 'ARB', + 'modify recurring authorization' => 'ARB', + 'cancel recurring authorization' => 'ARB', + ); + $processors{lc($content{'action'})} || 'AIM'; } sub submit { my($self) = @_; - $self->map_fields(); - $self->remap_fields( - type => 'x_Method', - login => 'x_Login', - password => 'x_Password', - transaction_key => 'x_Tran_Key', - action => 'x_Type', - description => 'x_Description', - amount => 'x_Amount', - currency => 'x_Currency_Code', - invoice_number => 'x_Invoice_Num', - order_number => 'x_Trans_ID', - auth_code => 'x_Auth_Code', - customer_id => 'x_Cust_ID', - customer_ip => 'x_Customer_IP', - last_name => 'x_Last_Name', - first_name => 'x_First_Name', - company => 'x_Company', - address => 'x_Address', - city => 'x_City', - state => 'x_State', - zip => 'x_Zip', - country => 'x_Country', - ship_last_name => 'x_Ship_To_Last_Name', - ship_first_name => 'x_Ship_To_First_Name', - ship_company => 'x_Ship_To_Company', - ship_address => 'x_Ship_To_Address', - ship_city => 'x_Ship_To_City', - ship_state => 'x_Ship_To_State', - ship_zip => 'x_Ship_To_Zip', - ship_country => 'x_Ship_To_Country', - phone => 'x_Phone', - fax => 'x_Fax', - email => 'x_Email', - email_customer => 'x_Email_Customer', - card_number => 'x_Card_Num', - expiration => 'x_Exp_Date', - cvv2 => 'x_Card_Code', - check_type => 'x_Echeck_Type', - account_name => 'x_Bank_Acct_Name', - account_number => 'x_Bank_Acct_Num', - account_type => 'x_Bank_Acct_Type', - bank_name => 'x_Bank_Name', - routing_code => 'x_Bank_ABA_Code', - customer_org => 'x_Customer_Organization_Type', - customer_ssn => 'x_Customer_Tax_ID', - license_num => 'x_Drivers_License_Num', - license_state => 'x_Drivers_License_State', - license_dob => 'x_Drivers_License_DOB', - recurring_billing => 'x_Recurring_Billing', - ); - - my $auth_type = $self->{_content}->{transaction_key} - ? 'transaction_key' - : 'password'; - - my @required_fields = ( qw(type action login), $auth_type ); - - unless ( $self->{_content}->{action} eq 'VOID' ) { - - if ($self->transaction_type() eq "ECHECK") { - - push @required_fields, qw( - amount routing_code account_number account_type bank_name - account_name - ); - - if (defined $self->{_content}->{customer_org} and - length $self->{_content}->{customer_org} - ) { - push @required_fields, qw( customer_org customer_ssn ); - } else { - push @required_fields, qw(license_num license_state license_dob); - } - - } elsif ($self->transaction_type() eq 'CC' ) { - - if ( $self->{_content}->{action} eq 'PRIOR_AUTH_CAPTURE' ) { - if ( $self->{_content}->{order_number} ) { - push @required_fields, qw( amount order_number ); - } else { - push @required_fields, qw( amount card_number expiration ); - } - } elsif ( $self->{_content}->{action} eq 'CREDIT' ) { - push @required_fields, qw( amount order_number card_number ); - } else { - push @required_fields, qw( - amount last_name first_name card_number expiration - ); - } - } else { - Carp::croak( "AuthorizeNet can't handle transaction type: ". - $self->transaction_type() ); - } + my $processor = $me. "::". $self->_map_processor(); - } - - $self->required_fields(@required_fields); - - my %post_data = $self->get_fields(qw/ - x_Login x_Password x_Tran_Key x_Invoice_Num - x_Description x_Amount x_Cust_ID x_Method x_Type x_Card_Num x_Exp_Date - x_Card_Code x_Auth_Code x_Echeck_Type x_Bank_Acct_Num - x_Bank_Account_Name x_Bank_ABA_Code x_Bank_Name x_Bank_Acct_Type - x_Customer_Organization_Type x_Customer_Tax_ID x_Customer_IP - x_Drivers_License_Num x_Drivers_License_State x_Drivers_License_DOB - x_Last_Name x_First_Name x_Company - x_Address x_City x_State x_Zip - x_Country - x_Ship_To_Last_Name x_Ship_To_First_Name x_Ship_To_Company - x_Ship_To_Address x_Ship_To_City x_Ship_To_State x_Ship_To_Zip - x_Ship_To_Country - x_Phone x_Fax x_Email x_Email_Customer x_Country - x_Currency_Code x_Trans_ID/); - - $post_data{'x_Test_Request'} = $self->test_transaction() ? 'TRUE' : 'FALSE'; - - #deal with perl-style bool - if ( $post_data{'x_Email_Customer'} - && $post_data{'x_Email_Customer'} !~ /^FALSE$/i ) { - $post_data{'x_Email_Customer'} = 'TRUE'; - } else { - $post_data{'x_Email_Customer'} = 'FALSE'; - } - - $post_data{'x_ADC_Delim_Data'} = 'TRUE'; - $post_data{'x_delim_char'} = ','; - $post_data{'x_encap_char'} = '"'; - $post_data{'x_ADC_URL'} = 'FALSE'; - $post_data{'x_Version'} = '3.1'; - - my $pd = make_form(%post_data); - my $s = $self->server(); - my $p = $self->port(); - my $t = $self->path(); - my $r = $self->{_content}->{referer}; - my($page,$server_response,%headers) = post_https($s,$p,$t,$r,$pd); - #escape NULL (binary 0x00) values - $page =~ s/\x00/\^0/g; - - #trim 'ip_addr="1.2.3.4"' added by eProcessingNetwork Authorize.Net compat - $page =~ s/,ip_addr="[\d\.]+"$//; - - my $csv = new Text::CSV_XS({ binary=>1, escape_char=>'' }); - $csv->parse($page); - my @col = $csv->fields(); - - $self->server_response($page); - $self->avs_code($col[5]); - $self->order_number($col[6]); - $self->md5($col[37]); - $self->cvv2_response($col[38]); - $self->cavv_response($col[39]); - - if($col[0] eq "1" ) { # Authorized/Pending/Test - $self->is_success(1); - $self->result_code($col[0]); - if ($col[4] =~ /^(.*)\s+(\d+)$/) { #eProcessingNetwork extra bits.. - $self->authorization($2); - } else { - $self->authorization($col[4]); - } - } else { - $self->is_success(0); - $self->result_code($col[2]); - $self->error_message($col[3]); - unless ( $self->result_code() ) { #additional logging information - #$page =~ s/\x00/\^0/g; - $self->error_message($col[3]. - " DEBUG: No x_response_code from server, ". - "(HTTPS response: $server_response) ". - "(HTTPS headers: ". - join(", ", map { "$_ => ". $headers{$_} } keys %headers ). ") ". - "(Raw HTTPS content: $page)" - ); - } - } + eval "use $processor"; + croak("unknown processor $processor ($@)") if $@; + + my $object = bless $self, $processor; + $object->set_defaults(); + $object->submit(); + bless $self, $me; } 1; @@ -379,6 +149,98 @@ Business::OnlinePayment::AuthorizeNet - AuthorizeNet backend for Business::Onlin print "Card was rejected: ".$tx->error_message."\n"; } + #### + # One step subscription, the simple case. + #### + + my $tx = new Business::OnlinePayment("AuthorizeNet::ARB"); + $tx->content( + type => 'CC', + login => 'testdrive', + password => 'testpass', + action => 'Recurring Authorization', + interval => '7 days', + start => '2008-3-10', + periods => '16', + amount => '99.95', + trialperiods => '4', + trialamount => '0', + description => 'Business::OnlinePayment test', + invoice_number => '1153B33F', + customer_id => 'vip', + first_name => 'Tofu', + last_name => 'Beast', + address => '123 Anystreet', + city => 'Anywhere', + state => 'GA', + zip => '84058', + card_number => '4111111111111111', + expiration => '09/02', + ); + $tx->submit(); + + if($tx->is_success()) { + print "Card processed successfully: ".$tx->order_number."\n"; + } else { + print "Card was rejected: ".$tx->error_message."\n"; + } + my $subscription = $tx->order_number + + + #### + # Subscription change. Modestly more complicated. + #### + + $tx->content( + type => 'CC', + subscription => '99W2C', + login => 'testdrive', + password => 'testpass', + action => 'Modify Recurring Authorization', + interval => '7 days', + start => '2008-3-10', + periods => '16', + amount => '29.95', + trialperiods => '4', + trialamount => '0', + description => 'Business::OnlinePayment test', + invoice_number => '1153B340', + customer_id => 'vip', + first_name => 'Tofu', + last_name => 'Beast', + address => '123 Anystreet', + city => 'Anywhere', + state => 'GA', + zip => '84058', + card_number => '4111111111111111', + expiration => '09/02', + ); + $tx->submit(); + + if($tx->is_success()) { + print "Update processed successfully."\n"; + } else { + print "Update was rejected: ".$tx->error_message."\n"; + } + $tx->content( + subscription => '99W2D', + login => 'testdrive', + password => 'testpass', + action => 'Cancel Recurring Authorization', + ); + $tx->submit(); + + #### + # Subscription cancellation. It happens. + #### + + if($tx->is_success()) { + print "Cancellation processed successfully."\n"; + } else { + print "Cancellation was rejected: ".$tx->error_message."\n"; + } + + =head1 SUPPORTED TRANSACTION TYPES =head2 CC, Visa, MasterCard, American Express, Discover @@ -387,7 +249,11 @@ Content required: type, login, password|transaction_key, action, amount, first_n =head2 Check -Content required: type, login, password|transaction_key, action, amount, first_name, last_name, account_number, routing_code, bank_name. +Content required: type, login, password|transaction_key, action, amount, first_name, last_name, account_number, routing_code, bank_name (non-subscription), account_type (subscription), check_type (subscription). + +=head2 Subscriptions + +Additional content required: interval, start, periods. =head1 DESCRIPTION @@ -395,20 +261,102 @@ For detailed information see L. =head1 METHODS AND FUNCTIONS -See L for the complete list. The following methods either override the methods in L or provide additional functions. +See L for the complete list. The following methods either override the methods in L or provide additional functions. =head2 result_code -Returns the response reason code (this is different than the response code). +Returns the response reason code (from the message.code field for subscriptions). =head2 error_message -Returns the response reason text. +Returns the response reason text (from the message.text field for subscriptions. =head2 server_response Returns the complete response from the server. +=head1 Handling of content(%content) data: + +=head2 action + +The following actions are valid + + normal authorization + authorization only + credit + post authorization + void + recurring authorization + modify recurring authorization + cancel recurring authorization + +=head2 interval + + Interval contains a number of digits, whitespace, and the units of days or months in either singular or plural form. + + +=head1 Setting AuthorizeNet ARB parameters from content(%content) + +The following rules are applied to map data to AuthorizeNet ARB parameters +from content(%content): + + # ARB param => $content{} + merchantAuthentication + name => 'login', + transactionKey => 'password', + subscription + paymentSchedule + interval + length => \( the digits in 'interval' ), + unit => \( days or months gleaned from 'interval' ), startDate => 'start', + totalOccurrences => 'periods', + trialOccurrences => 'trialperiods', + amount => 'amount', + trialAmount => 'trialamount', + payment + creditCard + cardNumber => 'card_number', + expiration => \( $year.'-'.$month ), # YYYY-MM from 'expiration' + bankAccount + accountType => 'account_type', + routingNumber => 'routing_code', + accountNumber => 'account_number, + nameOnAccount => 'name', + bankName => 'bank_name', + echeckType => 'check_type', + order + invoiceNumber => 'invoice_number', + description => 'description', + customer + type => 'customer_org', + id => 'customer_id', + email => 'email', + phoneNumber => 'phone', + faxNumber => 'fax', + driversLicense + number => 'license_num', + state => 'license_state', + dateOfBirth => 'license_dob', + taxid => 'customer_ssn', + billTo + firstName => 'first_name', + lastName => 'last_name', + company => 'company', + address => 'address', + city => 'city', + state => 'state', + zip => 'zip', + country => 'country', + shipTo + firstName => 'ship_first_name', + lastName => 'ship_last_name', + company => 'ship_company', + address => 'ship_address', + city => 'ship_city', + state => 'ship_state', + zip => 'ship_zip', + country => 'ship_country', + =head1 NOTE Unlike Business::OnlinePayment or pre-3.0 verisons of @@ -416,8 +364,9 @@ Business::OnlinePayment::AuthorizeNet, 3.1 requires separate first_name and last_name fields. Business::OnlinePayment::AuthorizeNet uses Authorize.Net's "Advanced -Integration Method (AIM) (formerly known as ADC direct response)", sending a -username and transaction_key or password with every transaction. Therefore, Authorize.Net's +Integration Method (AIM) (formerly known as ADC direct response)" and +"Automatic Recurring Billing (ARB)", sending a username and transaction_key +or password with every transaction. Therefore, Authorize.Net's referrer "security" is not necessary. In your Authorize.Net interface at https://secure.authorize.net/ make sure the list of allowable referers is blank. Alternatively, set the B field in the transaction content. @@ -430,6 +379,10 @@ order_number method on the object returned from the authorization. You must also submit the amount field with a value less than or equal to the amount specified in the original authorization. +For the subscription actions an authorization code is never returned by +the module. Instead it returns the value of subscriptionId in order_number. +This is the value to use for changing or cancelling subscriptions. + Recently (February 2002), Authorize.Net has turned address verification on by default for all merchants. If you do not have valid address information for your customer (such as in an IVR @@ -439,9 +392,10 @@ aren't denied due to a lack of address information. =head1 COMPATIBILITY -This module implements Authorize.Net's API verison 3.1 using the Advanced -Integration Method (AIM), formerly known as ADC Direct Response. See -http://www.authorize.net/support/AIM_guide.pdf for details. +This module implements Authorize.Net's API using the Advanced Integration +Method (AIM) version 3.1, formerly known as ADC Direct Response and the +Automatic Recurring Billing version 1.0 using the XML interface. See +http://www.authorize.net/support/AIM_guide.pdf and http://www.authorize.net/support/ARB_guide.pdf for details. =head1 AUTHOR @@ -455,6 +409,9 @@ Jason Spence contributed support for separate Authorization Only and Post Authorization steps and wrote some docs. OST paid for it. +Jeff Finucane added the ARB support. +ARB support sponsored by Plus Three, LP. L. + T.J. Mather sent a number of CVV2 patches. Mike Barry sent in a patch for the referer field. diff --git a/AuthorizeNet/AIM.pm b/AuthorizeNet/AIM.pm new file mode 100644 index 0000000..7e4e963 --- /dev/null +++ b/AuthorizeNet/AIM.pm @@ -0,0 +1,315 @@ +package Business::OnlinePayment::AuthorizeNet::AIM; + +use strict; +use Carp; +use Business::OnlinePayment::AuthorizeNet; +use Net::SSLeay qw/make_form post_https make_headers/; +use Text::CSV_XS; +use vars qw($VERSION @ISA @EXPORT @EXPORT_OK); + +require Exporter; + +@ISA = qw(Exporter Business::OnlinePayment::AuthorizeNet); +@EXPORT = qw(); +@EXPORT_OK = qw(); +$VERSION = '3.18'; + +sub set_defaults { + my $self = shift; + + $self->server('secure.authorize.net') unless $self->server; + $self->port('443') unless $self->port; + $self->path('/gateway/transact.dll') unless $self->path; + + $self->build_subs(qw( order_number md5 avs_code cvv2_response + cavv_response + )); +} + +sub map_fields { + my($self) = @_; + + my %content = $self->content(); + + # ACTION MAP + my %actions = ('normal authorization' => 'AUTH_CAPTURE', + 'authorization only' => 'AUTH_ONLY', + 'credit' => 'CREDIT', + 'post authorization' => 'PRIOR_AUTH_CAPTURE', + 'void' => 'VOID', + ); + $content{'action'} = $actions{lc($content{'action'})} || $content{'action'}; + + # TYPE MAP + my %types = ('visa' => 'CC', + 'mastercard' => 'CC', + 'american express' => 'CC', + 'discover' => 'CC', + 'check' => 'ECHECK', + ); + $content{'type'} = $types{lc($content{'type'})} || $content{'type'}; + $self->transaction_type($content{'type'}); + + # ACCOUNT TYPE MAP + my %account_types = ('personal checking' => 'CHECKING', + 'personal savings' => 'SAVINGS', + 'business checking' => 'CHECKING', + 'business savings' => 'SAVINGS', + ); + $content{'account_type'} = $account_types{lc($content{'account_type'})} + || $content{'account_type'}; + + $content{'referer'} = defined( $content{'referer'} ) + ? make_headers( 'Referer' => $content{'referer'} ) + : ""; + + if (length $content{'password'} == 15) { + $content{'transaction_key'} = delete $content{'password'}; + } + + # stuff it back into %content + $self->content(%content); +} + +sub remap_fields { + my($self,%map) = @_; + + my %content = $self->content(); + foreach(keys %map) { + $content{$map{$_}} = $content{$_}; + } + $self->content(%content); +} + +sub get_fields { + my($self,@fields) = @_; + + my %content = $self->content(); + my %new = (); + foreach( grep defined $content{$_}, @fields) { $new{$_} = $content{$_}; } + return %new; +} + +sub submit { + my($self) = @_; + + $self->map_fields(); + $self->remap_fields( + type => 'x_Method', + login => 'x_Login', + password => 'x_Password', + transaction_key => 'x_Tran_Key', + action => 'x_Type', + description => 'x_Description', + amount => 'x_Amount', + currency => 'x_Currency_Code', + invoice_number => 'x_Invoice_Num', + order_number => 'x_Trans_ID', + auth_code => 'x_Auth_Code', + customer_id => 'x_Cust_ID', + customer_ip => 'x_Customer_IP', + last_name => 'x_Last_Name', + first_name => 'x_First_Name', + company => 'x_Company', + address => 'x_Address', + city => 'x_City', + state => 'x_State', + zip => 'x_Zip', + country => 'x_Country', + ship_last_name => 'x_Ship_To_Last_Name', + ship_first_name => 'x_Ship_To_First_Name', + ship_company => 'x_Ship_To_Company', + ship_address => 'x_Ship_To_Address', + ship_city => 'x_Ship_To_City', + ship_state => 'x_Ship_To_State', + ship_zip => 'x_Ship_To_Zip', + ship_country => 'x_Ship_To_Country', + phone => 'x_Phone', + fax => 'x_Fax', + email => 'x_Email', + email_customer => 'x_Email_Customer', + card_number => 'x_Card_Num', + expiration => 'x_Exp_Date', + cvv2 => 'x_Card_Code', + check_type => 'x_Echeck_Type', + account_name => 'x_Bank_Acct_Name', + account_number => 'x_Bank_Acct_Num', + account_type => 'x_Bank_Acct_Type', + bank_name => 'x_Bank_Name', + routing_code => 'x_Bank_ABA_Code', + customer_org => 'x_Customer_Organization_Type', + customer_ssn => 'x_Customer_Tax_ID', + license_num => 'x_Drivers_License_Num', + license_state => 'x_Drivers_License_State', + license_dob => 'x_Drivers_License_DOB', + recurring_billing => 'x_Recurring_Billing', + ); + + my $auth_type = $self->{_content}->{transaction_key} + ? 'transaction_key' + : 'password'; + + my @required_fields = ( qw(type action login), $auth_type ); + + unless ( $self->{_content}->{action} eq 'VOID' ) { + + if ($self->transaction_type() eq "ECHECK") { + + push @required_fields, qw( + amount routing_code account_number account_type bank_name + account_name + ); + + if (defined $self->{_content}->{customer_org} and + length $self->{_content}->{customer_org} + ) { + push @required_fields, qw( customer_org customer_ssn ); + } else { + push @required_fields, qw(license_num license_state license_dob); + } + + } elsif ($self->transaction_type() eq 'CC' ) { + + if ( $self->{_content}->{action} eq 'PRIOR_AUTH_CAPTURE' ) { + if ( $self->{_content}->{order_number} ) { + push @required_fields, qw( amount order_number ); + } else { + push @required_fields, qw( amount card_number expiration ); + } + } elsif ( $self->{_content}->{action} eq 'CREDIT' ) { + push @required_fields, qw( amount order_number card_number ); + } else { + push @required_fields, qw( + amount last_name first_name card_number expiration + ); + } + } else { + Carp::croak( "AuthorizeNet can't handle transaction type: ". + $self->transaction_type() ); + } + + } + + $self->required_fields(@required_fields); + + my %post_data = $self->get_fields(qw/ + x_Login x_Password x_Tran_Key x_Invoice_Num + x_Description x_Amount x_Cust_ID x_Method x_Type x_Card_Num x_Exp_Date + x_Card_Code x_Auth_Code x_Echeck_Type x_Bank_Acct_Num + x_Bank_Account_Name x_Bank_ABA_Code x_Bank_Name x_Bank_Acct_Type + x_Customer_Organization_Type x_Customer_Tax_ID x_Customer_IP + x_Drivers_License_Num x_Drivers_License_State x_Drivers_License_DOB + x_Last_Name x_First_Name x_Company + x_Address x_City x_State x_Zip + x_Country + x_Ship_To_Last_Name x_Ship_To_First_Name x_Ship_To_Company + x_Ship_To_Address x_Ship_To_City x_Ship_To_State x_Ship_To_Zip + x_Ship_To_Country + x_Phone x_Fax x_Email x_Email_Customer x_Country + x_Currency_Code x_Trans_ID/); + + $post_data{'x_Test_Request'} = $self->test_transaction() ? 'TRUE' : 'FALSE'; + + #deal with perl-style bool + if ( $post_data{'x_Email_Customer'} + && $post_data{'x_Email_Customer'} !~ /^FALSE$/i ) { + $post_data{'x_Email_Customer'} = 'TRUE'; + } else { + $post_data{'x_Email_Customer'} = 'FALSE'; + } + + $post_data{'x_ADC_Delim_Data'} = 'TRUE'; + $post_data{'x_delim_char'} = ','; + $post_data{'x_encap_char'} = '"'; + $post_data{'x_ADC_URL'} = 'FALSE'; + $post_data{'x_Version'} = '3.1'; + + my $pd = make_form(%post_data); + my $s = $self->server(); + my $p = $self->port(); + my $t = $self->path(); + my $r = $self->{_content}->{referer}; + my($page,$server_response,%headers) = post_https($s,$p,$t,$r,$pd); + #escape NULL (binary 0x00) values + $page =~ s/\x00/\^0/g; + + #trim 'ip_addr="1.2.3.4"' added by eProcessingNetwork Authorize.Net compat + $page =~ s/,ip_addr="[\d\.]+"$//; + + my $csv = new Text::CSV_XS({ binary=>1, escape_char=>'' }); + $csv->parse($page); + my @col = $csv->fields(); + + $self->server_response($page); + $self->avs_code($col[5]); + $self->order_number($col[6]); + $self->md5($col[37]); + $self->cvv2_response($col[38]); + $self->cavv_response($col[39]); + + if($col[0] eq "1" ) { # Authorized/Pending/Test + $self->is_success(1); + $self->result_code($col[0]); + if ($col[4] =~ /^(.*)\s+(\d+)$/) { #eProcessingNetwork extra bits.. + $self->authorization($2); + } else { + $self->authorization($col[4]); + } + } else { + $self->is_success(0); + $self->result_code($col[2]); + $self->error_message($col[3]); + unless ( $self->result_code() ) { #additional logging information + #$page =~ s/\x00/\^0/g; + $self->error_message($col[3]. + " DEBUG: No x_response_code from server, ". + "(HTTPS response: $server_response) ". + "(HTTPS headers: ". + join(", ", map { "$_ => ". $headers{$_} } keys %headers ). ") ". + "(Raw HTTPS content: $page)" + ); + } + } +} + +1; +__END__ + +=head1 NAME + +Business::OnlinePayment::AuthorizeNet::AIM - AuthorizeNet AIM backend for Business::OnlinePayment + +=head1 AUTHOR + +Jason Kohles, jason@mediabang.com + +Ivan Kohler updated it for Authorize.Net protocol +3.0/3.1 and is the current maintainer. Please send patches as unified diffs +(diff -u). + +Jason Spence contributed support for separate +Authorization Only and Post Authorization steps and wrote some docs. +OST paid for it. + +T.J. Mather sent a number of CVV2 patches. + +Mike Barry sent in a patch for the referer field. + +Yuri V. Mkrtumyan sent in a patch to add the void action. + +Paul Zimmer sent in a patch for +card-less post authorizations. + +Daemmon Hughes sent in a patch for "transaction +key" authentication as well support for the recurring_billing flag and the md5 +method that returns the MD5 hash which is returned by the gateway. + +Steve Simitzis contributed a patch for better compatibility with +eProcessingNetwork's AuthorizeNet compatability mode. + +=head1 SEE ALSO + +perl(1). L L. + +=cut + diff --git a/AuthorizeNet/ARB.pm b/AuthorizeNet/ARB.pm new file mode 100644 index 0000000..16fddae --- /dev/null +++ b/AuthorizeNet/ARB.pm @@ -0,0 +1,342 @@ +package Business::OnlinePayment::AuthorizeNet::ARB; + +use strict; +use Carp; +use Business::OnlinePayment::AuthorizeNet; +use Business::OnlinePayment::HTTPS; +use XML::Simple; +use XML::Writer; +use Tie::IxHash; +use vars qw($VERSION $DEBUG @ISA $me); + +@ISA = qw(Business::OnlinePayment::AuthorizeNet Business::OnlinePayment::HTTPS); +$VERSION = '0.01'; +$DEBUG = 0; +$me='Business::OnlinePayment::AuthorizeNet::ARB'; + +sub set_defaults { + my $self = shift; + + $self->server('api.authorize.net') unless $self->server; + $self->port('443') unless $self->port; + $self->path('/xml/v1/request.api') unless $self->path; + + $self->build_subs(qw( order_number md5 avs_code cvv2_response + cavv_response + )); +} + +sub map_fields { + my($self) = @_; + + my %content = $self->content(); + + # ACTION MAP + my %actions = ('recurring authorization' + => 'ARBCreateSubscriptionRequest', + 'modify recurring authorization' + => 'ARBUpdateSubscriptionRequest', + 'cancel recurring authorization' + => 'ARBCancelSubscriptionRequest', + ); + $content{'action'} = $actions{lc($content{'action'})} || $content{'action'}; + + # TYPE MAP + my %types = ('visa' => 'CC', + 'mastercard' => 'CC', + 'american express' => 'CC', + 'discover' => 'CC', + 'check' => 'ECHECK', + ); + $content{'type'} = $types{lc($content{'type'})} || $content{'type'}; + $self->transaction_type($content{'type'}); + + # ACCOUNT TYPE MAP + my %account_types = ('personal checking' => 'checking', + 'personal savings' => 'savings', + 'business checking' => 'businessChecking', + 'business savings' => 'savings', + ); + $content{'account_type'} = $account_types{lc($content{'account_type'})} + || $content{'account_type'}; + + # MASSAGE EXPIRATION + $content{'expdate_yyyymm'} = $self->expdate_yyyymm($content{'expiration'}); + + # stuff it back into %content + $self->content(%content); + +} + +sub revmap_fields { + my $self = shift; + tie my(%map), 'Tie::IxHash', @_; + my %content = $self->content(); + map { + my $value; + if ( ref( $map{$_} ) eq 'HASH' ) { + $value = $map{$_} if ( keys %{ $map{$_} } ); + }elsif( exists( $content{ $map{$_} } ) ) { + $value = $content{ $map{$_} }; + } + + if (defined($value)) { + ($_ => $value); + }else{ + (); + } + } (keys %map); +} + +sub expdate_yyyymm { + my $self = shift; + my $expiration = shift; + my $expdate_yyyymm; + if ( defined($expiration) and $expiration =~ /^(\d{1,2})\D+(\d{2})$/ ) { + my ( $month, $year ) = ( $1, $2 ); + $expdate_yyyymm = sprintf( "20%02d-%02d", $year, $month ); + } + return defined($expdate_yyyymm) ? $expdate_yyyymm : $expiration; +}; + +sub _xmlwrite { + my ($self, $writer, $item, $value) = @_; + $writer->startTag($item); + if ( ref( $value ) eq 'HASH' ) { + foreach ( keys ( %$value ) ) { + $self->_xmlwrite($writer, $_, $value->{$_}); + } + }else{ + $writer->characters($value); + } + $writer->endTag($item); +} + +sub submit { + my($self) = @_; + + $self->map_fields(); + + my @required_fields = qw(action login password); + + if ( $self->{_content}->{action} eq 'ARBCreateSubscriptionRequest' ) { + push @required_fields, + qw( type interval start periods amount first_name last_name ); + + if ($self->transaction_type() eq "ECHECK") { + push @required_fields, + qw( amount routing_code account_number account_type account_name + check_type + ); + } elsif ($self->transaction_type() eq 'CC' ) { + push @required_fields, qw( card_number expiration ); + } + }elsif ( $self->{_content}->{action} eq 'ARBUpdateSubscriptionRequest' ) { + push @required_fields, qw( subscription ); + }elsif ( $self->{_content}->{action} eq 'ARBCancelSubscriptionRequest' ) { + push @required_fields, qw( subscription ); + }else{ + croak "$me can't handle transaction type: ". + $self->{_content}->{action}. " for ". + $self->transaction_type(); + } + + $self->required_fields(@required_fields); + + tie my %merchant, 'Tie::IxHash', + $self->revmap_fields( + name => 'login', + transactionKey => 'password', + ); + + my ($length,$unit) = + ($self->{_content}->{interval} or '') =~ /^\s*(\d+)\s+(day|month)s?\s*$/; + tie my %interval, 'Tie::IxHash', ( + ($length ? (length => $length) : () ), + ($unit ? (unit => $unit.'s') : () ), + ); + + tie my %schedule, 'Tie::IxHash', + $self->revmap_fields( + interval => \%interval, + startDate => 'start', + totalOccurrences => 'periods', + trialOccurrences => 'trialperiods', + phoneNumber => 'phone', + ); + + tie my %account, 'Tie::IxHash', ( + ( defined($self->transaction_type()) + && $self->transaction_type() eq 'CC' + ) ? $self->revmap_fields( + cardNumber => 'card_number', + expirationDate => 'expdate_yyyymm', + ) + : $self->revmap_fields( + accountType => 'account_type', + routingNumber => 'routing_code', + accountNumber => 'account_number', + nameOnAccount => 'account_name', + echeckType => 'check_type', + bankName => 'bank_name', + ) + ); + + tie my %payment, 'Tie::IxHash', + $self->revmap_fields( + ( ( defined($self->transaction_type()) && # require? + $self->transaction_type() eq 'CC' + ) ? 'creditCard' + : 'bankAccount' + ) => \%account, + ); + + tie my %order, 'Tie::IxHash', + $self->revmap_fields( + invoiceNumber => 'invoice_number', + description => 'description', + ); + + tie my %drivers, 'Tie::IxHash', + $self->revmap_fields( + number => 'license_num', + state => 'license_state', + dateOfBirth => 'license_dob', + ); + + tie my %billto, 'Tie::IxHash', + $self->revmap_fields( + firstName => 'first_name', + lastName => 'last_name', + company => 'company', + address => 'address', + city => 'city', + state => 'state', + zip => 'zip', + country => 'country', + ); + + tie my %shipto, 'Tie::IxHash', + $self->revmap_fields( + firstName => 'ship_first_name', + lastName => 'ship_last_name', + company => 'ship_company', + address => 'ship_address', + city => 'ship_city', + state => 'ship_state', + zip => 'ship_zip', + country => 'ship_country', + ); + + tie my %customer, 'Tie::IxHash', + $self->revmap_fields( + type => 'customer_org', + id => 'customer_id', + email => 'email', + phoneNumber => 'phone', + faxNumber => 'fax', + driversLicense => \%drivers, + taxid => 'customer_ssn', + ); + + tie my %sub, 'Tie::IxHash', + $self->revmap_fields( + paymentSchedule => \%schedule, + amount => 'amount', + trialAmount => 'trialamount', + payment => \%payment, + order => \%order, + customer => \%customer, + billTo => \%billto, + shipTo => \%shipto, + ); + + + tie my %req, 'Tie::IxHash', + $self->revmap_fields ( + merchantAuthentication => \%merchant, + subscriptionId => 'subscription', + subscription => \%sub, + ); + + my $ns = "AnetApi/xml/v1/schema/AnetApiSchema.xsd"; + my $post_data; + my $writer = new XML::Writer( OUTPUT => \$post_data, + DATA_MODE => 1, + DATA_INDENT => 1, + ENCODING => 'utf-8', + ); + $writer->xmlDecl(); + $writer->startTag($self->{_content}->{action}, 'xmlns', $ns); + foreach ( keys ( %req ) ) { + $self->_xmlwrite($writer, $_, $req{$_}); + } + $writer->endTag($self->{_content}->{action}); + $writer->end(); + + if ($self->test_transaction()) { + $self->server('apitest.authorize.net'); + } + + warn $post_data if $DEBUG; + my($page,$server_response,%headers) = + $self->https_post( { 'Content-Type' => 'text/xml' }, $post_data); + + #trim leading (4?) characters of unknown origin not in spec + $page =~ s/^(.*?){messages}->{message}) eq 'ARRAY') { + $message = $response->{messages}->{message}->[0]; + }else{ + $message = $response->{messages}->{message}; + } + }else{ + $response->{messages}->{resultCode} = "Server Failed"; + $message->{code} = $server_response; + } + + $self->server_response($page); + $self->order_number($response->{subscriptionId}); + $self->result_code($message->{code}); + $self->error_message($message->{text}); + + if($response->{messages}->{resultCode} eq "Ok" ) { + $self->is_success(1); + } else { + $self->is_success(0); + unless ( $self->error_message() ) { #additional logging information + $self->error_message( + "(HTTPS response: $server_response) ". + "(HTTPS headers: ". + join(", ", map { "$_ => ". $headers{$_} } keys %headers ). ") ". + "(Raw HTTPS content: $page)" + ); + } + } +} + +1; +__END__ + +=head1 NAME + +Business::OnlinePayment::AuthorizeNet::ARB - AuthorizeNet ARB backend for Business::OnlinePayment + +=head1 AUTHOR + +Jeff Finucane, authorizenetarb@weasellips.com + +=head1 SEE ALSO + +perl(1). L. + +=cut + diff --git a/Changes b/Changes index e2cedac..3755e3b 100644 --- a/Changes +++ b/Changes @@ -3,6 +3,7 @@ Revision history for Perl extension Business::OnlinePayment::AuthorizeNet. 3.18 unreleased - Patch From Steve Simitzis for better compatiblity with eProcessingNetwork's AuthorizeNet compatability mode. + - added ARB support, rearranging code in the process 3.17 Tue Jul 10 21:12:46 PDT 2007 - Trim the extra 'ip_addr="1.2.3.4"' added by eProcessingNetwork's diff --git a/MANIFEST b/MANIFEST index dee6f70..fc7b921 100644 --- a/MANIFEST +++ b/MANIFEST @@ -1,13 +1,19 @@ +AuthorizeNet/AIM.pm +AuthorizeNet/ARB.pm AuthorizeNet.pm Changes MANIFEST Makefile.PL README -t/load.t +t/00load.t +t/card_arb.t t/credit_card.t t/check.t t/bop.t t/capture.t +t/mixed_operation.t t/test_account t/test_account_ach +t/test_account_arb t/lib/test_account.pl +META.yml Module meta-data (added by MakeMaker) diff --git a/Makefile.PL b/Makefile.PL index 1f9c265..6476f7a 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -6,7 +6,11 @@ WriteMakefile( #the maintainer 'PREREQ_PM' => { 'Net::SSLeay' => 0, 'Text::CSV_XS' => 0, - 'Business::OnlinePayment' => 0, + 'Business::OnlinePayment' => 3, + 'Business::OnlinePayment::HTTPS' => 0, 'Test::More' => 0.42, + 'Tie::IxHash' => 0, + 'XML::Simple' => 0, + 'XML::Writer' => 0, }, ); diff --git a/t/00load.t b/t/00load.t new file mode 100644 index 0000000..2e4c366 --- /dev/null +++ b/t/00load.t @@ -0,0 +1,5 @@ +#!/usr/bin/perl -w + +use Test::More tests => 1; + +use_ok 'Business::OnlinePayment::AuthorizeNet'; diff --git a/t/card_arb.t b/t/card_arb.t new file mode 100644 index 0000000..e560339 --- /dev/null +++ b/t/card_arb.t @@ -0,0 +1,65 @@ +#!/usr/bin/perl -w + +use Test::More; +require "t/lib/test_account.pl"; + +my($login, $password) = test_account_or_skip('arb'); +plan tests => 5; + +use_ok 'Business::OnlinePayment'; + +my $tx = Business::OnlinePayment->new("AuthorizeNet"); +$tx->content( + type => 'VISA', + login => $login, + password => $password, + action => 'Recurring Authorization', + description => 'Business::OnlinePayment::ARB visa test', + amount => '49.95', + invoice_number => '100100', + customer_id => 'jsk', + first_name => 'Tofu', + last_name => 'Beast', + address => '123 Anystreet', + city => 'Anywhere', + state => 'UT', + zip => '84058', + card_number => '4007000000027', + expiration => expiration_date(), + interval => '1 month', + start => '2007-12-01', + periods => '3', +); +$tx->test_transaction(1); # test, dont really charge +$tx->submit(); + +ok($tx->is_success()) or diag $tx->error_message; + +my $subscription = $tx->order_number(); +like($subscription, qr/^[0-9]{1,13}$/, "Get order number"); + +SKIP: { + + skip "No order number", 2 unless $subscription; + + $tx->content( + login => $login, + password => $password, + action => 'Modify Recurring Authorization', + subscription => $subscription, + amount => '19.95', + ); + $tx->test_transaction(1); + $tx->submit(); + ok($tx->is_success()) or diag $tx->error_message; + + $tx->content( + login => $login, + password => $password, + action => 'Cancel Recurring Authorization', + subscription => $subscription, + ); + $tx->test_transaction(1); + $tx->submit(); + ok($tx->is_success()) or diag $tx->error_message; +} diff --git a/t/lib/Business/FraudDetect/_Fake.pm b/t/lib/Business/FraudDetect/_Fake.pm new file mode 100644 index 0000000..d09faa7 --- /dev/null +++ b/t/lib/Business/FraudDetect/_Fake.pm @@ -0,0 +1,23 @@ +package Business::FraudDetect::_Fake; + +use vars qw( @ISA $result $fraud_score ); + +@ISA = qw ( Business::OnlinePayment ); + +sub _glean_parameters_from_parent { + my ($self, $parent) = @_; + $result = $parent->fraud_detect_faked_result; + $fraud_score = $parent->fraud_detect_faked_score; +} + +sub fraud_score { + $fraud_score; +} + +sub submit { + my $self = shift; + $result ? $self->error_message('') : $self->error_message('Planned failure.'); + $self->is_success($result); +} + +1; diff --git a/t/lib/test_account.pl b/t/lib/test_account.pl index 38b282b..0b06973 100644 --- a/t/lib/test_account.pl +++ b/t/lib/test_account.pl @@ -28,4 +28,9 @@ sub expiration_date { return sprintf("%02d/%02d", $month, $year); } +sub tomorrow { + my($day, $month, $year) = (localtime(time+86400))[3..5]; + return sprintf("%04d-%02d-%02d", $year+1900, ++$month, $day); +} + 1; diff --git a/t/mixed_operation.t b/t/mixed_operation.t new file mode 100644 index 0000000..b248245 --- /dev/null +++ b/t/mixed_operation.t @@ -0,0 +1,98 @@ +#!/usr/bin/perl -w + +BEGIN { push @INC, "t/lib" }; + +use Test::More; + +require "t/lib/test_account.pl"; + + +my($arblogin, $arbpassword) = test_account_or_skip('arb'); +my($aimlogin, $aimpassword) = test_account_or_skip(); +plan tests => 9; + +use_ok 'Business::OnlinePayment'; +my $tx = Business::OnlinePayment->new("AuthorizeNet", + fraud_detect => '_Fake', + fraud_detect_faked_result => '0', + fraud_detect_faked_score => '2', + maximum_fraud_score => '1', + ); +$tx->content( + type => 'VISA', + login => $arblogin, + password => $arbpassword, + action => 'Recurring Authorization', + description => 'Business::OnlinePayment::ARB mixed test', + amount => '1.05', + first_name => 'Tofu', + last_name => 'Beast', + card_number => '4007000000027', + expiration => expiration_date(), + interval => '1 month', + start => tomorrow(), + periods => '6', +); +$tx->test_transaction(1); # test, dont really charge +$tx->submit(); + +ok(!$tx->is_success()) or diag "ARB Fraud detection unexpectedly did not fail."; + +$tx->fraud_detect_faked_result(1); +$tx->submit(); + +ok(!$tx->is_success()) or diag "ARB Fraud detection unexpectedly did not deny."; + +$tx->fraud_detect_faked_score(0); +$tx->submit(); + +ok($tx->is_success()) or diag $tx->error_message(); + +my $subscription = $tx->order_number(); +like($subscription, qr/^[0-9]{1,13}$/, "Get order number"); + +SKIP: { + + skip "No order number", 1 unless $subscription; + + $tx->content( + login => $arblogin, + password => $arbpassword, + action => 'Cancel Recurring Authorization', + subscription => $subscription, + ); + $tx->test_transaction(1); + $tx->submit(); + ok($tx->is_success()) or diag $tx->error_message; +} + +$tx->server('test.authorize.net'); +$tx->path('/gateway/transact.dll'); +$tx->content( + type => 'VISA', + login => $aimlogin, + password => $aimpassword, + action => 'Normal Authorization', + description => 'Business::OnlinePayment::AIM mixed test', + amount => '1.06', + first_name => 'Tofu', + last_name => 'Beast', + card_number => '4007000000027', + expiration => expiration_date(), +); +$tx->test_transaction(1); #test, don't really charge +$tx->fraud_detect_faked_result(0); +$tx->fraud_detect_faked_score(2); +$tx->submit(); + +ok(!$tx->is_success()) or diag "AIM Fraud detection unexpectedly did not fail."; + +$tx->submit(); +$tx->fraud_detect_faked_result(1); + +ok(!$tx->is_success()) or diag "AIM Fraud detection unexpectedly did not deny."; + +$tx->fraud_detect_faked_score(0); +$tx->submit(); +ok($tx->is_success()) or diag $tx->error_message; + diff --git a/t/test_account_arb b/t/test_account_arb new file mode 100644 index 0000000..bd0ffa6 --- /dev/null +++ b/t/test_account_arb @@ -0,0 +1,2 @@ +7d9P9X9vT2 +773dV9999Pyz9GdW