package Business::OnlinePayment::WesternACH; use strict; use Carp; use Business::OnlinePayment 3; use Business::OnlinePayment::HTTPS; use XML::Simple; use MIME::Base64; use Date::Format 'time2str'; use Date::Parse 'str2time'; use vars qw($VERSION @ISA $me $DEBUG); @ISA = qw(Business::OnlinePayment::HTTPS); $VERSION = '0.08'; $me = 'Business::OnlinePayment::WesternACH'; $DEBUG = 0; my $defaults = { command => 'payment', check_ver => 'yes', sec_code => 'PPD', tender_type => 'check', check_number => 9999, schedule => 'live', }; my $required = { map { $_ => 1 } ( qw( login password command amount tender_type _full_name routing_code check_number _check_type ))}; # Structure of the XML request document # Right sides of the hash entries are Business::OnlinePayment # field names. Those that start with _ are local method names. my $auth = { Authentication => { username => 'login', password => 'password', } }; my $request = { TransactionRequest => { %$auth, Request => { command => 'command', Payment => { type => '_payment_type', amount => 'amount', # effective date: not supported Tender => { type => 'tender_type', amount => 'amount', InvoiceNumber => { value => 'invoice_number' }, AccountHolder => { value => '_full_name' }, Address => { value => 'address' }, ClientID => { value => 'customer_id' }, UserDefinedID => { value => 'email' }, CheckDetails => { routing => 'routing_code', account => 'account_number', check => 'check_number', type => '_check_type', verification => 'check_ver', }, Authorization => { schedule => 'schedule' }, SECCode => { value => 'sec_code' }, }, }, } } }; my $returns_request = { TransactionRequest => { %$auth, Request => { command => 'command', DateRange => { start => '_start', end => '_end', }, }, } }; sub set_defaults { my $self = shift; $self->server('www.webcheckexpress.com'); $self->port(443); $self->path('/requester.php'); return; } sub submit { my $self = shift; $Business::OnlinePayment::HTTPS::DEBUG = $DEBUG; $DB::single = $DEBUG; # If you're debugging this, you probably want to stop here. my $xml_request; if ($self->{_content}->{command} eq 'get_returns') { # Setting get_returns overrides anything else. $xml_request = XMLout($self->build($returns_request), KeepRoot => 1); } else { # Error-check and prepare as a normal transaction. eval { # Return-with-error situations croak "Unsupported transaction type: '" . $self->transaction_type . "'" if(not $self->transaction_type =~ /^e?check$/i); croak "Unsupported action: '" . $self->{_content}->{action} . "'" if(!defined($self->_payment_type)); croak 'Test transactions not supported' if($self->test_transaction()); }; if($@) { $self->is_success(0); $self->error_message($@); return; } $xml_request = XMLout($self->build($request), KeepRoot => 1); } my ($xml_reply, $response, %reply_headers) = $self->https_post({ 'Content-Type' => 'text/xml' }, $xml_request); if(not $response =~ /^200/) { croak "HTTPS error: '$response'"; } $self->server_response($xml_reply); my $reply = XMLin($xml_reply, KeepRoot => 1)->{TransactionResponse}; if(exists($reply->{Response})) { $self->is_success( ( $reply->{Response}->{status} eq 'successful') ? 1 : 0); $self->error_message($reply->{Response}->{ErrorMessage}); if(exists($reply->{Response}->{TransactionID})) { # get_returns puts its results here my $tid = $reply->{Response}->{TransactionID}; if($self->{_content}->{command} eq 'get_returns') { if(ref($tid) eq 'ARRAY') { $self->{_content}->{returns} = [ map { $_->{value} } @$tid ]; } else { $self->{_content}->{returns} = [ $tid->{value} ]; } } else { # It's not get_returns $self->authorization($tid->{value}); } } } elsif(exists($reply->{FatalException})) { $self->is_success(0); $self->error_message($reply->{FatalException}); } return; } sub get_returns { my $self = shift; my $content = $self->{_content}; if(exists($content->{'command'})) { croak 'get_returns: command is already set on this transaction'; } if ( exists($content->{'returns_method'}) && $content->{'returns_method'} eq 'requester') { # Obsolete, deprecated method supported for now as a fallback option. $content->{'command'} = 'get_returns'; $self->submit; if($self->is_success) { if(exists($content->{'returns'})) { return @{$content->{'returns'}}; } else { return (); } } # you need to check error_message() for details. return (); } else { $Business::OnlinePayment::HTTPS::DEBUG = $DEBUG; $DB::single = $DEBUG; if (defined($content->{'login'}) and defined($content->{'password'})) { # transret.php doesn't respect date ranges. It returns anything from the # same month as the date argument. Therefore we generate one request for # each month in the date range, and then filter them by date later. my $path = ('transret.php?style=csv&sort=id&date='); my $starttime = str2time($self->_start); my $endtime = str2time($self->_end) - 1; my @months = map { s/^(....)(..)$/$1-$2-01/; $_ } ( time2str('%Y%m', $starttime)..time2str('%Y%m', $endtime) ); my $headers = { Authorization => 'Basic ' . MIME::Base64::encode($content->{'login'} . ':' . $content->{'password'}) }; my @tids; foreach my $m (@months) { $self->path($path . $m); # B:OP:HTTPS::https_get doesn't use $DEBUG. my ($page, $reply, %headers) = $self->https_get( { headers => $headers }, {}, ); if ($reply =~ /^200/) { $self->is_success(1); } else { $self->error_message($reply); carp $reply if $DEBUG; carp $page if $DEBUG >= 3; $self->is_success(0); return; } my $index_date_ret = 2; # Usual position of 'Date Returned' foreach my $trans (split("\cJ", $page)) { my @fields = split(',', $trans); # fields: # id, Date Returned, Type, Amount, Name, Customer ID Number, # Email Address, Invoice Number, Status Code, SEC # we only care about id and date. next if scalar(@fields) < 10; if($fields[0] eq 'id') { # Use this header row to find the 'Date Returned' field. ($index_date_ret) = grep { lc($fields[$_]) eq 'date returned' } ( 0..scalar(@fields)-1 ); $index_date_ret ||= 2; } next if not($fields[0] =~ /^\d+$/); my $rettime = str2time($fields[$index_date_ret]); next if (!$rettime or $rettime < $starttime or $rettime > $endtime); carp $trans if $DEBUG > 1; push @tids, $fields[0]; } } return @tids; } else { croak 'login and password required'; } } } sub build { my $self = shift; my $content = { $self->content }; my $skel = shift; my $data; if (ref($skel) ne 'HASH') { croak 'Failed to build non-hash' }; foreach my $k (keys(%$skel)) { my $val = $skel->{$k}; # Rules for building from the skeleton: # 1. If the value is a hashref, build it recursively. if(ref($val) eq 'HASH') { $data->{$k} = $self->build($val); } # 2. If the value starts with an underscore, it's treated as a method name. elsif($val =~ /^_/ and $self->can($val)) { $data->{$k} = $self->can($val)->($self); } # 3. If the value is undefined, keep it undefined. elsif(!defined($val)) { $data->{$k} = undef; } # 4. If the value is the name of a key in $self->content, look up that value. elsif(exists($content->{$val})) { $data->{$k} = $content->{$val}; } # 5. If the value is a key in $defaults, use that value. elsif(exists($defaults->{$val})) { $data->{$k} = $defaults->{$val}; } # 6. If the value is not required, use an empty string. elsif(! $required->{$val}) { $data->{$k} = ''; } # 7. Fail. else { croak "Missing request field: '$val'"; } } return $data; } sub XML { # For testing build(). my $self = shift; return XMLout($self->build($request), KeepRoot => 1); } sub _payment_type { my $self = shift; my $action = $self->{_content}->{action}; if(!defined($action) or $action =~ /^normal authorization$/i) { return 'debit'; } elsif($action =~ /^credit$/i) { return 'credit'; } else { return; } } sub _check_type { my $self = shift; my $type = $self->{_content}->{account_type}; return 'checking' if($type =~ /checking/i); return 'savings' if($type =~ /savings/i); croak "Invalid account_type: '$type'"; } sub _full_name { my $self = shift; return join(' ',$self->{_content}->{first_name},$self->{_content}->{last_name}); } sub _start { my $self = shift; if($self->{_content}->{start}) { my $start = time2str('%Y-%m-%d', str2time($self->{_content}->{start})); croak "Invalid start date: '".$self->{_content}->{start} if !$start; return $start; } else { return time2str('%Y-%m-%d', time - 86400); } } sub _end { my $self = shift; my $end = $self->{_content}->{end}; if($end) { $end = time2str('%Y-%m-%d', str2time($end)); croak "Invalid end date: '".$self->{_content}->{end} if !$end; return $end; } else { return time2str('%Y-%m-%d', time); } } 1; __END__ =head1 NAME Business::OnlinePayment::WesternACH - Western ACH backend for Business::OnlinePayment =head1 SYNOPSIS use Business::OnlinePayment; #### # Electronic check authorization. We only support # 'Normal Authorization' and 'Credit'. #### my $tx = new Business::OnlinePayment("WesternACH"); $tx->content( type => 'ECHECK', login => 'testdrive', password => 'testpass', action => 'Normal Authorization', description => 'Business::OnlinePayment test', amount => '49.95', invoice_number => '100100', first_name => 'Jason', last_name => 'Kohles', address => '123 Anystreet', city => 'Anywhere', state => 'UT', zip => '84058', account_type => 'personal checking', account_number => '1000468551234', routing_code => '707010024', check_number => '1001', # optional ); $tx->submit(); if($tx->is_success()) { print "Check processed successfully: ".$tx->authorization."\n"; } else { print "Check was rejected: ".$tx->error_message."\n"; } my $tx = new Business::OnlinePayment("WesternACH"); $tx->content( login => 'testdrive', password => 'testpass', start => '2009-06-25', # optional; defaults to yesterday end => '2009-06-26', # optional; defaults to today ); $tx->get_returns; =head1 SUPPORTED TRANSACTION TYPES =head2 ECHECK Content required: type, login, password|transaction_key, action, amount, first_name, last_name, account_number, routing_code, account_type. =head1 DESCRIPTION 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. =head2 result_code Currently returns nothing; these transactions don't seem to have result codes. =head2 error_message Returns the response reason text. This can come from several locations in the response document or from certain local errors. =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 credit =head1 AUTHOR Mark Wells with advice from Ivan Kohler . =head1 SEE ALSO perl(1). L. =cut