Group
Extension

WebService-BR-Vindi/lib/WebService/BR/Vindi.pm

package WebService::BR::Vindi;

use 5.010001;
use strict;
use warnings;

our @ISA = qw();

our $VERSION = '0.04';


# Preloaded methods go here.
use MIME::Base64;
use JSON::XS;
use utf8;

# Configura as URLs de destino baseado na tag raiz
$WebService::BR::Vindi::Target = {
 prod => {
  '*' => 'https://app.vindi.com.br:443/api/v1',
 },
};

# Error messages translator
$WebService::BR::Vindi::ErrorMessages = {
 # First is the id
 global => {
  invalid_parameter => {
   __look_for__         => 'parameter',
   payment_company_code => 'Número do Cartão de Crédito parece ser inválido.',
   card_expiration      => 'Data de validade do Cartão inválida: %s.',
   card_number          => 'Número do Cartão %s.',
   merchant             => 'Erro interno: %s.',
  },
 },
};

#
# @param  $type Tipo da requisição (nome do end-point)
# @return $URL URL de destino a utilizar para a requisição
#
sub _getURL {
 my $class = shift;
 if ( exists( $WebService::BR::Vindi::Target->{$class->{target}}->{$_[0]} ) ) {
  return $WebService::BR::Vindi::Target->{$class->{target}}->{$_[0]};
 } else {
  return $WebService::BR::Vindi::Target->{$class->{target}}->{'*'}."/$_[0]";
 }
}

#
# Contrutor
#
sub new {
 my $self = shift;

 my $class = $#_ == 0 && ref($_[0]) eq 'HASH' ? $_[0] : { @_ };

 require LWP::UserAgent;
 require HTTP::Request::Common;
 require IO::Socket::SSL;

 #IO::Socket::SSL::set_ctx_defaults(
 # SSL_verify_mode => 0,
 # SSL_version     => "TLSv1"
 #);

 $class->{target}      ||= 'prod';
 $class->{timeout}     ||= 120;
 $class->{charset}     ||= 'UTF-8';
 $class->{api_key}     ||= '';

 $class->{ua}          ||= LWP::UserAgent->new( agent           => 'WebService-BR-Vindi.pm',
                                                timeout         => $class->{timeout} || 120 );
 $class->{ua}->ssl_opts( verify_hostname => 0 );
 $class->{ua}->env_proxy;

 # JSON helper
 $class->{json} = JSON::XS->new->allow_nonref->utf8;


 bless( $class, $self );
}

#
# Make the next request (only the first next request) failsafe: if it fails, send it to the Message Bus.
#
sub failsafe {
 die 'You need to specify the "app" parameter to the constructor to use this feature (this is a proprietary implementation).' if !$_[0]->{app};
 $_[0]->{failsafe} = 1;
 $_[0];
}

sub ua      { shift->{ua} }
sub json    { shift->{json} }
sub app     { shift->{app} }



#
# Faz uma requisição
#
# @param $Endpoint    Tipo da Requisição (equivalente à tag raiz, exemplo: "requisicao-transacao")
#        \%Params     HASH a enviar com os dados (será convertido em JSON string).
# @param $Endpoint    Tipo da Requisição (equivalente à tag raiz, exemplo: "requisicao-transacao")
#        $Params      JSON string a enviar com os dados.
#
sub post {
 my $class    = shift;
 $class->{response} = $class->request( 'post', @_ );
}
sub get {
 my $class    = shift;
 $class->{response} = $class->request( 'get', @_ );
}
sub put {
 my $class    = shift;
 $class->{response} = $class->request( 'put', @_ );
}
sub delete {
 my $class    = shift;
 $class->{response} = $class->request( 'delete', @_ );
}


#
# Custom helpers
#

#
# Delete all objects of a given kind
#
# @param $endpoint Endpoint, optionally with a query filter specification of what objects to delete
#
sub delete_all {
 my $class    = shift;
 my $endpoint = shift;

 my ( $object, $query ) = split( /\?/, $endpoint );

 # Get objects that match the query 
 my $all = $class->get( $endpoint );

 my @deleted = ();
 
 # If we got data, delete one by one
 if ( $all && $all->{$object} && $#{$all->{$object}} > -1 ) {
  for my $row ( @{ $all->{$object} } ) {

   warn "delete( $object/$row->{id} )..." if $class->{debug};

   my $res = $class->delete( "$object/$row->{id}" );

   if ( $res && ref( $res ) && $res->{ErrorStatus} ) {
   } else {
    push( @deleted, $row->{id} );
   }
   
  }
 }

 return { deleted => [ @deleted ] }; 
}

#
# Retorna resposata da última requisição como HASHREF
#
# @return \%Response
#
sub response {
 my $res;
 if ( $_[0]->{response} && $_[0]->{response}->is_success ) {
  eval {
   $res = $_[0]->{json}->decode( $_[0]->{response}->decoded_content );
  };

 } elsif ( $_[0]->{response} ) {
  $res = { ErrorStatus => $_[0]->{response}->status_line || 'Erro ao conectar com o gateway de pagamentos.' };
 }

 return $res || { ErrorStatus => 'Resposta inconforme do gateway de pagamentos.' };
}

#
# Retorna resposata da última requisição como HASHREF
#
# @return \%Response
#
sub responseAsJSON {
 if ( $_[0]->{response}->is_success ) {
  $_[0]->{response}->decoded_content;
 } else {
  $_[0]->{json}->encode( { ErrorStatus => $_[0]->{response}->status_line } );
 }
}

#
# Realiza uma requisição de dados. Uso interno.
#
# @see   #get #post #put #delete
# @param $Method   get post etc
#        $Endpoint script name, relative      
#        $Data     Dados a enviar (em geral o JSON)
#        \%Headers Cabeçalhos a enviar (nenhum necessário em geral)
#
sub request {
 my $class    = shift;
 my $Method   = shift || 'get';
 my $Endpoint = shift;
 my $Content  = shift;
 my $Headers  = shift || {};

 
 # Encode $Content in to JSON string if needed
 $Content = ref( $Content ) ?
  $class->{json}->encode( $Content ) :
  $Content;


 ( $Endpoint, my $QueryStr ) = split( /\?/, $Endpoint );
 my $URL = $class->_getURL( $Endpoint ).( $QueryStr ? "?$QueryStr" : '' );

 warn "REQUEST [$URL]: ".$Content     if $class->{debug};
 
 my $res;

 # POST
 $res = $class->{ua}->$Method(
  $URL,
#  Content_Type    => 'application/x-www-form-urlencoded',
  Content_Type    => 'application/json',
  Content_Charset => 'text/json;charset=UTF-8',
  Authorization   => "Basic ".MIME::Base64::encode_base64( $class->{api_key} ),
  %{$Headers},
  Content         => $Content,
 );

 # Debug only
 warn "RESPONSE CODE: ".$res->status_line     if $class->{debug};
 warn "RESPOSSE DATA: ".$res->decoded_content if $class->{debug};

# return $res;




 # Sucesso
 if ( $res->is_success ) {
  $class->{failsafe} = 0;

  return $class->translateErrors( $class->{json}->decode( $res->decoded_content ) );

 # Erro
 } else {

  # Send it to Message Queue if on failsafe mode
  if ( $class->{failsafe} ) {
   $class->app->message->post(
    'Vindi',
    { METHOD => $Method,
      PATH   => $Endpoint.( $QueryStr ? "?$QueryStr" : '' ),
      DATA   => $Content || undef,
      LOG    => $res->decoded_content || '',
    });
  }

  my $err = { ErrorStatus => $res->status_line };
  eval {
   my $json = $class->{json}->decode( $res->decoded_content );
   $err = $class->translateErrors( $json ) if $json->{errors} && $json->{errors}->[0];
  };
  
  $class->{failsafe} = 0;
  return $err;
 }

}


#
# Translete Vindi error messages into human friendly messages
#
sub translateErrors {
 my $class = shift;
 my $json  = shift;

 # Internal/unknown error?
 if ( !ref( $json ) ) {
  warn $json if $class->{debug};
  return { ErrorStatus => 'Erro ao comunicar com a operadora de Cobrança. Por favor verifique os dados passados e tente novamente mais tarde.' };

 # Translate it
 } elsif ( $json->{errors} ) {
  my $map = $WebService::BR::Vindi::ErrorMessages->{global};
  my $errors = {};

  # Translate error by error
  for my $error ( @{ $json->{errors} } ) {
   # By "id"
   if ( # Error id found on error map
        $map->{ $error->{id} } &&
        # Error object has the parameter it looks for
        $error->{ $map->{ $error->{id} }->{__look_for__} } &&
        # The value of the parameter of the error has an entry on the map to translate it
        $map->{ $error->{id} }->{ $error->{ $map->{ $error->{id} }->{__look_for__} } } ) {

    my $uid = $error->{id}.':'.$error->{ $map->{ $error->{id} }->{__look_for__} };
    $errors->{ $uid } ||= { messages => [], error => $map->{ $error->{id} }->{ $error->{ $map->{ $error->{id} }->{__look_for__} } } };
    push( @{ $errors->{ $uid }->{messages} }, $error->{message} ) if $#{$errors->{ $uid }->{messages}} == -1 || $errors->{ $uid }->{messages}->[-1] ne $error->{message};
   }  
  }

  # We have a translation
  if ( $#{ [ keys %{$errors} ] } > -1 ) {
   return { ErrorStatus => join( '; ', map { sprintf( $_->{error}, join( ', ', @{$_->{messages}} ) ) } values( %{$errors} ) ),
            errors => $json->{errors} };

  # No error found on ErrorMessage map
  } else {
   return { ErrorStatus => "Erro ao comunicar com a operadora de Cobrança ($json->{errors}->[0]->{id}: $json->{errors}->[0]->{parameter}: $json->{errors}->[0]->{message}). Por favor verifique os dados passados e tente novamente mais tarde.",
            errors      => $json->{errors} };
  }  
 
 # No errors, pass it on.  
 } else {
  return $json;
 }
 
}

#
# Retorna o nome da badeira do cartão baseado no número
#
# @param  $numero   Numero do cartao
# @return $bandeira Badeira do cartão, já no formato Vindi a ser enviado no JSON.
#
sub cardtype {
  my $number = $_[1];

  my $type = 'unknown';

  if ( $number =~ /^4[0-9]{12}(?:[0-9]{3})/ ) {
   $type = 'visa';

  } elsif ( $number =~ /^5[1-5][0-9]{14}/ ) {
   $type = 'mastercard';

  } elsif ( $number =~ /^3[47][0-9]{13}/ ) {
   $type = 'amex';

  } elsif ( $number =~ /^3(?:0[0-5]|[68][0-9])[0-9]{11}/ ) {
   $type = 'diners';

  } elsif ( $number =~ /^6(?:011|5[0-9]{2})[0-9]{12}/ ) {
   $type = 'discover';

  } elsif ( $number =~ /^(?:2131|1800|35\d{3})\d{11}/ ) {
   $type = 'jcb';
  }

  # TODO: "elo" e "aura"
  
  return $type;    
}

1;

__END__

=head1 NAME

WebService::BR::Vindi - Perl low level implementation of the https://vindi.com.br brazilian payment gateway.

=head1 SYNOPSIS

  use WebService::BR::Vindi;

  # Contruct the object
  my $vindi = WebService::BR::Vindi->new(
   api_key => "You API key",
   timeout => 120, # HTTP timeout
   debug   => 1 );

  # Conect to vindi REST API and get the result
  # post/get/put/delete methods always returns a perl HASHREF with the resulting data
  my $subscription = $vindi->get( 'subscriptions/1234' );

  if ( $subscription->{subscription}->{status} eq 'active' ) {
   # Do whatever you need
  }


  # A post method example with error handling
  # First parameter is the URL endpoint name - /customer
  # Second parameter is the POST DATA - which will be automatically JSON encoded and sent to the gateway
  my $customer = $vindi->post(
   'customers',
   {
     "name"          => 'Customer's name',
     "email"         => 'his@email.com',
     "registry_code" => '', 
     "code"          => 'XYZ',
   }
  );


  # Wops! Something went wrong!
  if ( $payment_profile->{ErrorStatus} ) {

   # "ErrorStatus" key try to translate the error to an human readable format whenever possible, or send whatever si possible back.
   warn "Something went terribly wrong while creating the customer: ".$customer->{ErrorStatus};

   # "error" if the vindi's plain error recieved
   warn Data::Dumper::Dumper( $customer->{error} );

  # You will probably want to keep this and INSERT in your database
  } else {
   $customer->{customer}->{id};
  }


  # A little more elaborated example - create a new payment_profile object
  my $payment_profile = $vindi->post(
   'payment_profiles',
   { "holder_name"          => 'Holder Name',
     "card_expiration"      => '12/2012',
     "card_number"          => 'XXXXXXXXXXXXXXXX',
     "card_cvv"             => 'XXX',
     "payment_method_code"  => "credit_card",
     "customer_id"          => $customer->{customer}->{id} # This is the vindi's customer ID we just created before
   }
  );

   
  # Wops! Something went wrong!
  if ( $payment_profile->{ErrorStatus} ) {
   warn "Something went terribly wrong while creating your payment profile: ".$payment_profile->{ErrorStatus};

  # All fine!
  } else {
   # Do whatever you need
   $payment_profile->{payment_profile};
  }



=head1 DESCRIPTION

This is a straight brindge to the Vindi.com.br payment gateway API.

Note that Vindi has no sandbox/test URL - instead, it uses a separate API KEY for testing. So, it's up to you
to specify the right API KEY when you call the methods (test or production).

=head1 METHDOS

=head2 new

Creates the client object.

=over

=item api_key

You secret Vindi API key. Required.

=over

=back

=item debug

Boolean, optional. If true, prints HTTPS request/response information to STDERR.

=over

=back

=item timeout

Integer, optional, defaults to 120s

=over

=back

=back

=head2 get

Make a GET request to Vindi API.

    my $customer = $vindi->get( '/customer/123' )

=over

=item endpoint

URI PATH. Required.

*** You don't need the "/v1" prefix.

=over

=back

=back

=head2 delete

Make a DELETE request to Vindi API.

    my $customer = $vindi->delete( '/customer/123' )

=over

=item endpoint

URI PATH. Required. Example: /customer/123

=over

=back

=back

=head2 put

Make a PUT request to Vindi API.

=over

=item endpoint

URI PATH. Required. No /vi prefix required.

=over

=back

=back

=head2 post

Make a POST request to Vindi API. You must specify endpoint as first parameter, and a hashref as the second.

    my $customer = $vindi->post( 'customer', { name => '...', ... } )

=over

=item endpoint

URL endpoint. Required. No /v1 required.

=over

=back

=item data

A hash to be sent with the data. Plese check the examples above or the Vindi API docs.

=over

=back

=back

=head2 Error handling

Every request, no mather of what type, return a hashref. If this hashref has a key called ErrorStatus with ant true value
in it, an error happened. You can print the ErrorStatus value to the user (this library is able to translate some errors to 
a reasonable human/end user readable text). Or you can check the Vindi's own error object, which will be available on the
"error" key.

    my $customer = $vindi->post( 'customer', { name => '...', ... } )
    if ( $customer->{ErrorStatus} ) {
        print $customer->{ErrorStatus};
        print Data::Dumper::Dumper( $customer->{error} );
    }

=over

=item ErrorStatus

Human friendly error message (whenever possible).

=over

=back

=item error

The error hashref as sent by the Vindi API.

=over

=back

=back


=head1 SEE ALSO

Please check Vindi's full v1 API docs at http://www.vindi.com.br/ (you will need an API key to access this page).

=head1 AUTHOR

Diego de Lima, E<lt>diego_de_lima@hotmail.comE<gt>

=head1 SPECIAL THANKS

This module was kindly made available by the https://modeloinicial.com.br/ team.

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2017 by Diego de Lima

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself, either Perl version 5.10.1 or,
at your option, any later version of Perl 5 you may have available.


=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.