package Business::OnlinePayment::ElavonVirtualMerchant;
use base qw(Business::OnlinePayment::HTTPS);

use strict;
use vars qw( $VERSION $DEBUG %maxlength );
use Carp;

$VERSION = '0.03';
$VERSION = eval $VERSION;
$DEBUG   = 0;

=head1 NAME

Business::OnlinePayment::ElavonVirtualMerchant - Elavon Virtual Merchant backend for Business::OnlinePayment

=head1 SYNOPSIS

  use Business::OnlinePayment::ElavonVirtualMerchant;

  my $tx = new Business::OnlinePayment("ElavonVirtualMerchant", { default_ssl_user_id => 'whatever' });
    $tx->content(
        type           => 'CC',
        login          => 'testdrive',
        password       => '', #password or transaction key
        action         => 'Normal Authorization',
        description    => 'Business::OnlinePayment test',
        amount         => '49.95',
        invoice_number => '100100',
        customer_id    => 'jsk',
        first_name     => 'Jason',
        last_name      => 'Kohles',
        address        => '123 Anystreet',
        city           => 'Anywhere',
        state          => 'UT',
        zip            => '84058',
        card_number    => '4007000000027',
        expiration     => '09/02',
        cvv2           => '1234',
    );
    $tx->submit();

    if($tx->is_success()) {
        print "Card processed successfully: ".$tx->authorization."\n";
    } else {
        print "Card was rejected: ".$tx->error_message."\n";
    }

=head1 DESCRIPTION

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,
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_.

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.

=head1 SUBROUTINES

=cut

=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 Converge gateway URL
and initializes internal data structures.

=cut

sub set_defaults {
    my $self = shift;
    my %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

Converts credit card types and transaction types from the Business::OnlinePayment values to Elavon's.

=cut

sub _map_fields {
    my ($self) = @_;

    my %content = $self->content();

    if (uc($self->transaction_type) eq 'ECHECK') {

      $content{'ssl_transaction_type'} = 'ECSPURCHASE';

    } 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'};

      $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
for the transaction type are present, and submits the transaction.  Saves the results.

=cut

%maxlength = (
        ssl_description        => 255,
        ssl_invoice_number     => 25,
        ssl_customer_code      => 17,

        ssl_first_name         => 20,
        ssl_last_name          => 30,
        ssl_company            => 50,
        ssl_avs_address        => 30,
        ssl_city               => 30,
        ssl_phone              => 20,

        ssl_ship_to_first_name => 20,
        ssl_ship_to_last_name  => 30,
        ssl_ship_to_company    => 50,
        ssl_ship_to_address1   => 30,
        ssl_ship_to_city       => 30,
        ssl_ship_to_phone      => 20, #though we don't map anything to this...
);

sub submit {
    my ($self) = @_;

    $self->_map_fields();

    my %content = $self->content;
    warn "INITIAL PARAMETERS:\n" . join("\n", map{ "$_ => $content{$_}" } keys(%content)) if $self->debug;

    my %required;
    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;
    # 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_avs_address ssl_address2
                                ssl_city ssl_state ssl_avs_zip ssl_country
                                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}) ) {
      $self->error_message("Elavon can't handle transaction type: ".
        "$content{action} on " . $self->transaction_type() );
      $self->is_success(0);
      return;
    }

    $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 $expdate_mmyy = $self->expdate_mmyy( $content{"expiration"} );
      my $zip          = $content{'zip'};
      $zip =~ s/[^[:alnum:]]//g;

      my $cvv2indicator = $content{"cvv2"} ? 1 : 9; # 1 = Present, 9 = Not Present

      $self->_revmap_fields(

        ssl_card_number        => 'card_number',
        ssl_exp_date           => \$expdate_mmyy,    # MMYY from 'expiration'
        ssl_cvv2cvc2_indicator => \$cvv2indicator,
        ssl_cvv2cvc2           => 'cvv2',
        ssl_description        => 'description',
        ssl_invoice_number     => 'invoice_number',
        ssl_customer_code      => 'customer_id',

        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_ship_to_first_name => 'ship_first_name',
        ssl_ship_to_last_name  => 'ship_last_name',
        ssl_ship_to_company    => 'ship_company',
        ssl_ship_to_address1   => 'ship_address',
        ssl_ship_to_city       => 'ship_city',
        ssl_ship_to_state      => 'ship_state',
        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}});

    # some final non-overridable parameters
    $params{ssl_test_mode}='true' if $self->test_transaction;
    $params{ssl_show_form}='false';
    $params{ssl_result_format}='ASCII';
    
    # send request
    warn "POST PARAMETERS:\n" . join("\n", map{ "$_ => $params{$_}" } keys(%params)) if $self->debug;
    my ( $page, $resp, %resp_headers ) = 
      $self->https_post( %params );

    $self->response_code( $resp );
    $self->response_page( $page );
    $self->response_headers( \%resp_headers );

    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;

    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 } );
    $self->error_message( $results{ errorMessage } || $results{ ssl_result_message } );


    if ( $resp =~ /^(HTTP\S+ )?200/ && $status eq "0" ) {
        $self->is_success(1);
    } else {
        $self->is_success(0);
    }
}

1;
__END__

=head1 SEE ALSO

L<Business::OnlinePayment>, L<Business::OnlinePayment::HTTPS>, Elavon Converge Developers' Guide

=head1 BUGS

Duplicates code to handle deprecated 'type' codes.

Only provides a small selection of possible transaction types.

=head1 COPYRIGHT AND LICENSE

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,
at your option, any later version of Perl 5 you may have available.

=cut

