# frozen_string_literal: true require 'net/https' require 'timeout' require 'uri' class PayPalRecurring API_VERSION = '72.0' USER_AGENT = 'Neocities PayPalRecurring' OPEN_TIMEOUT = 5 READ_TIMEOUT = 10 WRITE_TIMEOUT = 10 ENDPOINTS = { sandbox: { api: 'https://api-3t.sandbox.paypal.com/nvp', site: 'https://www.sandbox.paypal.com/cgi-bin/webscr' }, production: { api: 'https://api-3t.paypal.com/nvp', site: 'https://www.paypal.com/cgi-bin/webscr' } }.freeze ACTIONS = { cancel: 'Cancel', suspend: 'Suspend', reactivate: 'Reactivate' }.freeze INITIAL_AMOUNT_ACTIONS = { cancel: 'CancelOnFailure', continue: 'ContinueOnFailure' }.freeze OUTSTANDING = { next_billing: 'AddToNextBilling', no_auto: 'NoAutoBill' }.freeze PERIOD = { daily: 'Day', weekly: 'Weekly', monthly: 'Month', yearly: 'Year' }.freeze NETWORK_ERRORS = [ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, SocketError, Timeout::Error ].freeze class << self attr_accessor :sandbox attr_accessor :username attr_accessor :password attr_accessor :signature def configure yield self end def sandbox? sandbox == true end def environment sandbox? ? :sandbox : :production end def api_endpoint ENDPOINTS[environment][:api] end def site_endpoint ENDPOINTS[environment][:site] end end attr_accessor :amount attr_accessor :cancel_url attr_accessor :currency attr_accessor :description attr_accessor :email attr_accessor :failed attr_accessor :frequency attr_accessor :initial_amount attr_accessor :initial_amount_action attr_accessor :ipn_url attr_accessor :locale attr_accessor :outstanding attr_accessor :payer_id attr_accessor :period attr_accessor :profile_id attr_accessor :reference attr_accessor :return_url attr_accessor :start_at attr_accessor :token attr_accessor :trial_amount attr_accessor :trial_frequency attr_accessor :trial_length attr_accessor :trial_period def initialize(options={}) options.each { |name, value| public_send("#{name}=", value) } end def checkout run('SetExpressCheckout', billing_agreement_params.merge( 'RETURNURL' => return_url, 'CANCELURL' => cancel_url, 'LOCALECODE' => build_locale(locale), 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Authorization', 'NOSHIPPING' => 1, 'L_BILLINGTYPE0' => 'RecurringPayments' )) end def request_payment run('DoExpressCheckoutPayment', billing_agreement_params.merge( 'RETURNURL' => return_url, 'CANCELURL' => cancel_url, 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale', 'PAYERID' => payer_id, 'TOKEN' => token, 'PROFILEREFERENCE' => reference, 'PAYMENTREQUEST_0_CUSTOM' => reference, 'PAYMENTREQUEST_0_INVNUM' => reference )) end def create_recurring_profile run('CreateRecurringPaymentsProfile', billing_agreement_params.merge( 'INITAMT' => initial_amount, 'FAILEDINITAMTACTION' => map(INITIAL_AMOUNT_ACTIONS, initial_amount_action), 'PAYERID' => payer_id, 'TOKEN' => token, 'PROFILEREFERENCE' => reference, 'PROFILESTARTDATE' => build_timestamp(start_at), 'MAXFAILEDPAYMENTS' => failed, 'AUTOBILLOUTAMT' => map(OUTSTANDING, outstanding), 'BILLINGFREQUENCY' => frequency, 'BILLINGPERIOD' => map(PERIOD, period), 'EMAIL' => email, 'TRIALTOTALBILLINGCYCLES' => trial_length, 'TRIALBILLINGPERIOD' => map(PERIOD, trial_period), 'TRIALBILLINGFREQUENCY' => trial_frequency, 'TRIALAMT' => trial_amount )) end def cancel run('ManageRecurringPaymentsProfileStatus', { 'ACTION' => map(ACTIONS, :cancel), 'PROFILEID' => profile_id }) end private def billing_agreement_params { 'PAYMENTREQUEST_0_AMT' => amount, 'AMT' => amount, 'PAYMENTREQUEST_0_CURRENCYCODE' => currency, 'CURRENCYCODE' => currency, 'DESC' => description, 'PAYMENTREQUEST_0_DESC' => description, 'L_BILLINGAGREEMENTDESCRIPTION0' => description, 'PAYMENTREQUEST_0_NOTIFYURL' => ipn_url, 'NOTIFYURL' => ipn_url } end def run(method, params) Response.new(post(default_params.merge(params).merge('METHOD' => method))) rescue *NETWORK_ERRORS => e Response.new(nil, error: e) end def post(params) uri = URI(self.class.api_endpoint) request = Net::HTTP::Post.new(uri.request_uri) request['User-Agent'] = USER_AGENT request.set_form_data compact_params(params) http_client(uri).request(request) end def http_client(uri) Net::HTTP.new(uri.host, uri.port).tap do |http| http.use_ssl = uri.scheme == 'https' http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl? http.cert_store = OpenSSL::X509::Store.new if http.use_ssl? http.cert_store&.set_default_paths http.open_timeout = OPEN_TIMEOUT http.read_timeout = READ_TIMEOUT http.write_timeout = WRITE_TIMEOUT if http.respond_to?(:write_timeout=) end end def default_params { 'USER' => self.class.username, 'PWD' => self.class.password, 'SIGNATURE' => self.class.signature, 'VERSION' => API_VERSION } end def compact_params(params) params.reject { |_name, value| value.nil? } end def build_locale(value) value.to_s.upcase if value end def build_timestamp(value) value.respond_to?(:to_time) ? value.to_time.utc.strftime('%Y-%m-%dT%H:%M:%SZ') : value end def map(mapping, value) return nil if value.nil? mapping.fetch(value.to_sym, value) end class Response attr_reader :http_response attr_reader :error def initialize(http_response, error: nil) @http_response = http_response @error = error end def params return {} if http_response.nil? @params ||= URI.decode_www_form(http_response.body.to_s).each_with_object({}) do |(name, value), parsed| parsed[name] = value end end def token params['TOKEN'] end def ack params['ACK'] end def profile_id params['PROFILEID'] end def checkout_url "#{PayPalRecurring.site_endpoint}?#{URI.encode_www_form( cmd: '_express-checkout', token: token, useraction: 'commit' )}" end def completed? params['PAYMENTINFO_0_PAYMENTSTATUS'] == 'Completed' end def approved? params['PAYMENTINFO_0_ACK'] == 'Success' end def success? ack == 'Success' end def valid? !network_error? && errors.empty? && success? end def network_error? !error.nil? end def errors return [{code: 'network_error', messages: [error.message]}] if network_error? @errors ||= begin index = 0 [].tap do |results| while params["L_ERRORCODE#{index}"] results << { code: params["L_ERRORCODE#{index}"], messages: [ params["L_SHORTMESSAGE#{index}"], params["L_LONGMESSAGE#{index}"] ] } index += 1 end end end end end end