Skip to content

Commit

Permalink
Nuvei: Base Gateway Layout
Browse files Browse the repository at this point in the history
Description
-------------------------
[SER-1351](https://spreedly.atlassian.net/browse/SER-1351)

This commit add a the basic structure for the new Nuvei Gateway

Unit test
-------------------------
Finished in 0.015656 seconds.

3 tests, 9 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

191.62 tests/s, 574.86 assertions/s

Remote test
-------------------------
Finished in 11.099935 seconds.

5 tests, 13 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

0.45 tests/s, 1.17 assertions/s

Rubocop
-------------------------
801 files inspected, no offenses detected
  • Loading branch information
Javier Pedroza committed Jul 23, 2024
1 parent dfaccf4 commit c7a7a76
Show file tree
Hide file tree
Showing 4 changed files with 444 additions and 0 deletions.
265 changes: 265 additions & 0 deletions lib/active_merchant/billing/gateways/nuvei.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
module ActiveMerchant
module Billing
class NuveiGateway < Gateway
self.test_url = 'https://ppp-test.nuvei.com/ppp/api/v1'
self.live_url = 'https://secure.safecharge.com/ppp/api/v1'

self.supported_countries = %w[US CA IN NZ GB AU US]
self.default_currency = 'USD'
self.money_format = :cents
self.supported_cardtypes = %i[visa master american_express discover union_pay]
self.currencies_without_fractions = %w[CLP KRW JPY ISK MMK PYG UGX VND XAF XOF]
self.homepage_url = 'https://www.nuvei.com/'
self.display_name = 'Nuvei'

ENDPOINTS_MAPPING = {
authenticate: '/getSessionToken',
purchase: '/payment', # /authorize with transactionType: "Auth"
capture: '/settleTransaction',
refund: '/refundTransaction',
void: '/voidTransaction',
general_credit: '/payout'
}

def initialize(options = {})
requires!(options, :merchant_id, :merchant_site_id, :secret_key)
super
end

def authorize(money, payment, options = {})
post = {}
post[:transactionType] = 'Auth'
post.merge!(build_post_data(:authorize))
add_invoice(post, money, options)
add_payment(post, payment)
add_address(post, payment, options)
add_customer_ip(post, options)

commit(:purchase, post)
end

def purchase(money, payment, options = {}); end

def capture(money, authorization, options = {})
post = {}
post[:relatedTransactionId] = authorization
post.merge!(build_post_data(:capture))
add_invoice(post, money, options)

commit(:capture, post)
end

def refund(money, authorization, options = {}); end

def void(authorization, options = {}); end

def credit(money, payment, options = {}); end

def supports_scrubbing?
true
end

def scrub(transcript)
transcript.
gsub(%r((Authorization: Basic )\w+), '\1[FILTERED]').
gsub(%r(("cardNumber\\?":\\?")[^"\\]*)i, '\1[FILTERED]').
gsub(%r(("cardCvv\\?":\\?")\d+), '\1[FILTERED]').
gsub(%r(("cardHolderName\\?":\\?")\d+), '\1[FILTERED]').
gsub(%r(("merchantId\\?":\\?")\d+), '\1[FILTERED]').
gsub(%r(("merchantSiteId\\?":\\?")\d+), '\1[FILTERED]').
gsub(%r(("merchantKey\\?":\\?")\d+), '\1[FILTERED]')
end

private

def add_customer_ip(post, options)
return unless options[:ip_address]

post[:deviceDetails] = {
ipAddress: options[:ip_address]
}
end

def add_invoice(post, money, options)
post[:amount] = amount(money)
post[:currency] = (options[:currency] || currency(money))
end

def credit_card_hash(payment)
{
cardNumber: payment.number,
cardHolderName: payment.name,
expirationMonth: format(payment.month, :two_digits),
expirationYear: format(payment.year, :four_digits),
CVV: payment.verification_value
}
end

def add_payment(post, payment)
if payment.is_a?(CreditCard)
post[:paymentOption] = { card: credit_card_hash(payment) }
else
post[:paymentOption] = { card: { cardToken: payment } }
end
end

def add_customer_names(billing_address, full_name, payment_method)
first_name, last_name = split_names(full_name).tap do |names|
names[0] = payment_method&.first_name unless names[0].present? || payment_method.is_a?(String)
names[1] = payment_method&.last_name unless names[1].present? || payment_method.is_a?(String)
end

billing_address[:firstName] = first_name
billing_address[:lastName] = last_name
end

def add_customer_phone(billing_address, options)
billing_address[:phone] = options[:phone] || options.dig(:billing_address, :phone)
end

def add_address(post, payment, options)
return unless address = options[:billing_address] || options[:address]

billing_address = {}
billing_address[:email] = address[:email] || options[:email]
billing_address[:country] = address[:country] if address[:country]
add_customer_names(billing_address, address[:name], payment) if address[:name]
add_customer_phone(billing_address, options)
post[:billingAddress] = billing_address
end

def current_timestamp
Time.now.utc.strftime('%Y%m%d%H%M%S')
end

def build_post_data(action)
client_request_id = SecureRandom.uuid
client_unique_id = SecureRandom.hex(16)

{
merchantId: @options[:merchant_id],
merchantSiteId: @options[:merchant_site_id],
timeStamp: current_timestamp,
clientRequestId: client_request_id,
clientUniqueId: client_unique_id
}.compact
end

def calculate_checksum(post, action)
case action
when :authenticate
Digest::SHA256.hexdigest("#{post[:merchantId]}#{post[:merchantSiteId]}#{post[:clientRequestId]}#{post[:timeStamp]}#{@options[:secret_key]}")
when :capture
Digest::SHA256.hexdigest("#{post[:merchantId]}#{post[:merchantSiteId]}#{post[:clientRequestId]}#{post[:clientUniqueId]}#{post[:amount]}#{post[:currency]}#{post[:relatedTransactionId]}#{post[:timeStamp]}#{@options[:secret_key]}")
else
Digest::SHA256.hexdigest("#{post[:merchantId]}#{post[:merchantSiteId]}#{post[:clientRequestId]}#{post[:amount]}#{post[:currency]}#{post[:timeStamp]}#{@options[:secret_key]}")
end
end

def send_request(post)
post[:checksum] = calculate_checksum(post, 'authenticate')

response = parse(ssl_post(url(:authenticate), post.to_json, headers)).with_indifferent_access

expiration_time = Time.strptime(post[:timeStamp], '%Y%m%d%H%M%S')
@options[:session_token] = response.dig('sessionToken')
@options[:token_expires] = expiration_time.strftime('%Y%m%d%H%M%S')
@options[:valid_credentials] = true

Response.new(
response[:sessionToken].present?,
message_from(response),
response,
test: test?,
error_code: response[:errCode]
)
end

def fetch_session_token
post = build_post_data(:authenticate)
send_request(post)
end

def session_token_valid?
return false unless @options[:session_token] && @options[:token_expires]

token_time = Time.strptime(@options[:token_expires], '%Y%m%d%H%M%S').utc
current_time = Time.now.utc
(current_time - token_time) > 15 * 60
end

def commit(action, post, authorization = nil, method = :post)
MultiResponse.run do |r|
r.process { fetch_session_token } unless session_token_valid?
r.process do
api_request(action, post, authorization, method).tap do |response|
response.params.merge!(@options.slice(:session_token, :token_expires)) if @options[:valid_credentials]
end
end
end
end

def url(action, id = nil)
"#{test? ? test_url : live_url}#{ENDPOINTS_MAPPING[action] % id}"
end

def error_code_from(action, response)
(response[:statusName] || response[:status]) unless success_from(response)
end

def api_request(action, post, authorization, method = :post)
@options[:checksum] = calculate_checksum(post, action)
post[:sessionToken] = @options[:session_token] unless action == :capture
post[:checksum] = @options[:checksum]

response = parse(ssl_request(method, url(action, authorization), post.to_json, headers))

Response.new(
success_from(response),
message_from(response),
response,
authorization: authorization_from(action, response, post),
test: test?,
error_code: error_code_from(action, response)
)
rescue ResponseError => e
response = parse(e.response.body)
# if current access_token is invalid then clean it
if e.response.code == '401'
@options[:session_token] = ''
@options[:new_credentials] = true
end
Response.new(false, message_from(response), response, test: test?)
end

def headers
{ 'Content-Type' => 'application/json' }.tap do |headers|
headers['Authorization'] = "Bearer #{@options[:session_token]}" if @options[:session_token]
end
end

def parse(body)
body = '{}' if body.blank?

JSON.parse(body).with_indifferent_access
rescue JSON::ParserError
{
errors: body,
status: 'Unable to parse JSON response'
}.with_indifferent_access
end

def success_from(response)
response[:status] == 'SUCCESS' && response[:transactionStatus] == 'APPROVED'
end

def authorization_from(action, response, post)
response.dig(:transactionId)
end

def message_from(response)
response[:status]
end
end
end
end
5 changes: 5 additions & 0 deletions test/fixtures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,11 @@ nmi:
nmi_secure:
security_key: '6457Thfj624V5r7WUwc5v6a68Zsd6YEm'

nuvei:
merchant_id: 'merchantId'
merchant_site_id: 'siteId'
secret_key: 'secretKey'

ogone:
login: LOGIN
user: USER
Expand Down
59 changes: 59 additions & 0 deletions test/remote/gateways/remote_nuvei_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
require 'test_helper'
require 'timecop'

class RemoteNuveiTest < Test::Unit::TestCase
def setup
@gateway = NuveiGateway.new(fixtures(:nuvei))

@amount = 100
@credit_card = credit_card('4761344136141390', verification_value: '999', first_name: 'Cure', last_name: 'Tester')
@declined_card = credit_card('4000128449498204')

@options = {
email: 'test@gmail.com',
billing_address: address.merge(name: 'Cure Tester'),
ip_address: '127.0.0.1'
}
end

def test_transcript_scrubbing
transcript = capture_transcript(@gateway) do
@gateway.authorize(@amount, @credit_card, @options)
end

@gateway.scrub(transcript)
end

def test_successful_session_token_generation
response = @gateway.send(:fetch_session_token)
assert_success response
assert_not_nil response.params[:sessionToken]
end

def test_successful_authorize
response = @gateway.authorize(@amount, @credit_card, @options)
assert_success response

assert_not_nil response.params[:transactionId]
assert_match 'SUCCESS', response.message
assert_match 'APPROVED', response.params['transactionStatus']
end

def test_successful_authorize_and_capture
response = @gateway.authorize(@amount, @credit_card, @options)
assert_success response

capture_response = @gateway.capture(@amount, response.authorization)

assert_success capture_response
assert_match 'SUCCESS', capture_response.message
assert_match 'APPROVED', capture_response.params['transactionStatus']
end

def test_successful_zero_auth
response = @gateway.authorize(0, @credit_card, @options)
assert_success response
assert_match 'SUCCESS', response.message
assert_match 'APPROVED', response.params['transactionStatus']
end
end
Loading

0 comments on commit c7a7a76

Please sign in to comment.