# https://developer.deluxe.com/s/article-api-reference # We use Oauth2 client to get an authorization token. Then pass that token into a REST api. # We get a payment_intent from the front end HostedPaymentForm, then call authorize and complete on it. # Effective::DeluxeApi.new.health_check module Effective class DeluxeApi SCRUB = /[^\w\d#,\s]/ # All required attr_accessor :environment attr_accessor :client_id attr_accessor :client_secret attr_accessor :access_token attr_accessor :currency attr_accessor :purchase_response def initialize(environment: nil, client_id: nil, client_secret: nil, access_token: nil, currency: nil) self.environment = environment || EffectiveOrders.deluxe.fetch(:environment) self.client_id = client_id || EffectiveOrders.deluxe.fetch(:client_id) self.client_secret = client_secret || EffectiveOrders.deluxe.fetch(:client_secret) self.access_token = access_token || EffectiveOrders.deluxe.fetch(:access_token) self.currency = currency || EffectiveOrders.deluxe.fetch(:currency) end def payment raise('expected purchase response to be present') unless purchase_response.kind_of?(Hash) purchase_response end # This calls Authorize Payment and Complete Payment # Returns true if all good. # Returns false if there was an error. # Always sets the @purchase_response which is api.payment def purchase!(order, payment_intent) raise('expected a deluxe payment provider') unless ['deluxe', 'deluxe_delayed'].include?(order.payment_provider) payment_intent = decode_payment_intent_payload(payment_intent) if payment_intent.kind_of?(String) raise('expected payment_intent to be a Hash') unless payment_intent.kind_of?(Hash) raise('expected a token payment') unless payment_intent['type'] == 'Token' # Start a purchase. Which is an Authorization and a Completion self.purchase_response = nil # Process Authorization authorization = authorize_payment(order, payment_intent) self.purchase_response = authorization valid = [0].include?(authorization['responseCode']) return false unless valid ## Complete Payment payment = complete_payment(order, authorization) self.purchase_response = payment valid = [0].include?(payment['responseCode']) return false unless valid # Valid purchase. This is authorized and completed. true end # Health Check def health_check get('/') end def healthy? response = health_check() return false unless response.kind_of?(Hash) return false unless response['timestamp'].to_s.start_with?(Time.zone.now.strftime('%Y-%m-%d')) return false unless response['environment'].present? true end # Authorize Payment def authorize_payment(order, payment_intent) response = post('/payments/authorize', params: authorize_payment_params(order, payment_intent)) # Sanity check response raise('expected responseCode') unless response.kind_of?(Hash) && response['responseCode'].present? # Sanity check response approved vs authorized valid = [0].include?(response['responseCode']) # We might be approved for an amount less than the order total. Not sure what to do here if valid && (amountApproved = response['amountApproved']) != (amountAuthorized = order.total_to_f) raise("expected authorize payment amountApproved #{amountApproved} to be the same as the amountAuthorized #{amountAuthorized} but it was not") end # Generate the card info we can store card = card_info(payment_intent) # Return the authorization params merged with the card info response.reverse_merge(card) end # Complete Payment def complete_payment(order, authorization) response = post('/payments/complete', params: complete_payment_params(order, authorization)) # Sanity check response raise('expected responseCode') unless response.kind_of?(Hash) && response['responseCode'].present? # Sanity check response approved vs authorized valid = [0].include?(response['responseCode']) # We might be approved for an amount less than the order total. Not sure what to do here if valid && (amountApproved = response['amountApproved']) != (amountAuthorized = order.total_to_f) raise("expected complete payment amountApproved #{amountApproved} to be the same as the amountAuthorized #{amountAuthorized} but it was not") end # The authorization information authorization = { 'paymentId' => authorization } if authorization.kind_of?(String) # Return the complete params merged with the authorization params response.reverse_merge(authorization) end def complete_payment_params(order, payment_intent) raise('expected an Effective::Order') unless order.kind_of?(Effective::Order) payment_id = extract_payment_id(payment_intent) amount = { amount: order.total_to_f, currency: currency } # Params passed into Complete Payment { paymentId: payment_id, amount: amount } end def authorize_payment_params(order, payment_intent) raise('expected an Effective::Order') unless order.kind_of?(Effective::Order) token = extract_token(payment_intent) amount = { amount: order.total_to_f, currency: currency } billingAddress = if (address = order.billing_address).present? { email: order.email, address: scrub(address.address1, limit: 250), address2: scrub(address.address2), city: scrub(address.city, limit: 50), state: address.state_code, country: address.country_code, postalCode: address.postal_code }.compact end shippingAddress = if (address = order.shipping_address).present? { address: scrub(address.address1, limit: 250), address2: scrub(address.address2), city: scrub(address.city, limit: 50), state: address.state_code, country: address.country_code, postalCode: address.postal_code }.compact end paymentMethod = { token: { token: token['token'], expiry: (token['expDate'] || token['expiry']), cvv: token['cvv'] }.compact, billingAddress: billingAddress }.compact customData = [ ({ name: 'order_id', value: order.to_param }), ({ name: 'user_id', value: order.user_id.to_s } if order.user_id.present?), ({ name: 'organization_id', value: order.organization_id.to_s } if order.organization_id.present?) ].compact # Params passed into Authorize Payment { amount: amount, paymentMethod: paymentMethod, shippingAddress: shippingAddress, customData: customData, }.compact end def get(endpoint, params: nil) query = ('?' + params.compact.map { |k, v| "$#{k}=#{v}" }.join('&')) if params.present? uri = URI.parse(api_url + endpoint + query.to_s) http = Net::HTTP.new(uri.host, uri.port) http.read_timeout = 10 http.use_ssl = true result = with_retries do puts "[GET] #{uri}" if Rails.env.development? response = http.get(uri, headers) raise Exception.new("#{response.code} #{response.body}") unless response.code == '200' response end JSON.parse(result.body) end def post(endpoint, params:) uri = URI.parse(api_url + endpoint) http = Net::HTTP.new(uri.host, uri.port) http.read_timeout = 10 http.use_ssl = true result = with_retries do puts "[POST] #{uri} #{params}" if Rails.env.development? response = http.post(uri.path, params.to_json, headers) raise Exception.new("#{response.code} #{response.body}") unless response.code == '200' response end JSON.parse(result.body) end # Takes a payment_intent and returns the card info we can store def card_info(payment_intent) token = extract_token(payment_intent) # Return the authorization params merged with the card info last4 = token['maskedPan'].to_s.last(4) card = token['cardType'].to_s.downcase date = token['expDate'] cvv = token['cvv'] active_card = "**** **** **** #{last4} #{card} #{date}" if last4.present? { 'active_card' => active_card, 'card' => card, 'expDate' => date, 'cvv' => cvv }.compact end # Decode the base64 encoded JSON object into a Hash def decode_payment_intent_payload(payload) raise('expected a string') unless payload.kind_of?(String) payment_intent = (JSON.parse(Base64.decode64(payload)) rescue nil) raise('expected payment_intent to be a Hash') unless payment_intent.kind_of?(Hash) raise('expected a token payment') unless payment_intent['type'] == 'Token' payment_intent end private def headers { "Content-Type": "application/json", "Authorization": "Bearer #{authorization_token}", "PartnerToken": access_token } end def client OAuth2::Client.new( client_id, client_secret, site: client_url, token_url: '/secservices/oauth2/v2/token' # https://sandbox.api.deluxe.com/secservices/oauth2/v2/token ) end def authorization_token @authorization_token ||= Rails.cache.fetch(authorization_cache_key, expires_in: 60.minutes) do puts "[AUTH] Oauth2 Get Token" if Rails.env.development? client.client_credentials.get_token.token end end # https://sandbox.api.deluxe.com def client_url case environment when 'production' then 'https://api.deluxe.com' when 'sandbox' then 'https://sandbox.api.deluxe.com' # No trailing / else raise('unexpected deluxe environment') end end # https://sandbox.api.deluxe.com/dpp/v1/gateway/ def api_url client_url + '/dpp/v1/gateway' end def extract_token(payment_intent) raise('expected a payment intent') unless payment_intent.kind_of?(Hash) token = payment_intent['data'] || payment_intent raise('expected a payment intent Hash') unless token['token'].present? && token['expDate'].present? token end def extract_payment_id(authorization) return authorization if authorization.kind_of?(String) raise('expected an authorization Hash') unless authorization.kind_of?(Hash) payment_id = authorization['paymentId'] raise('expected a paymentId') unless payment_id.present? payment_id end def scrub(value, limit: 100) return value unless value.kind_of?(String) value.gsub(SCRUB, '').first(limit) end def authorization_cache_key "deluxe_api_#{client_id}" end def with_retries(retries: (Rails.env.development? ? 0 : 3), wait: 2, &block) raise('expected a block') unless block_given? begin return yield rescue Exception => e # Reset cache and query for a new authorization token on any error Rails.cache.delete(authorization_cache_key) @authorization_token = nil if (retries -= 1) > 0 sleep(wait); retry else raise end end end end end