mirror of
https://github.com/neocities/neocities.git
synced 2026-05-26 05:34:53 +00:00
replace obsolete paypal-recurring
This commit is contained in:
@@ -21,9 +21,7 @@ gem 'filesize'
|
||||
gem 'thread'
|
||||
gem 'rest-client', require: 'rest_client'
|
||||
gem 'addressable', '>= 2.8.0', require: 'addressable/uri'
|
||||
gem 'paypal-recurring', require: 'paypal/recurring'
|
||||
gem 'cgi' # paypal-recurring depends on CGI.parse which was removed in ruby 3.5, this adds it back
|
||||
gem 'ostruct'
|
||||
gem 'ostruct' # stripe-ruby-mock depends on this default gem, which Ruby 4 no longer loads by default
|
||||
gem 'geoip'
|
||||
gem 'io-extra', require: 'io/extra'
|
||||
#gem 'rye'
|
||||
|
||||
@@ -40,7 +40,6 @@ GEM
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
certified (1.0.0)
|
||||
cgi (0.5.1)
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.6)
|
||||
@@ -246,7 +245,6 @@ GEM
|
||||
nokogiri (1.19.3-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
ostruct (0.6.3)
|
||||
paypal-recurring (1.1.0)
|
||||
pg (1.6.3)
|
||||
pg (1.6.3-aarch64-linux)
|
||||
pg (1.6.3-aarch64-linux-musl)
|
||||
@@ -413,7 +411,6 @@ DEPENDENCIES
|
||||
bcrypt
|
||||
capybara
|
||||
certified
|
||||
cgi
|
||||
coveralls_reborn
|
||||
csv
|
||||
dnsbl-client
|
||||
@@ -449,7 +446,6 @@ DEPENDENCIES
|
||||
msgpack
|
||||
nokogiri
|
||||
ostruct
|
||||
paypal-recurring
|
||||
pg
|
||||
phonelib
|
||||
pry
|
||||
|
||||
+3
-3
@@ -119,7 +119,7 @@ get '/supporter/paypal' do
|
||||
hash.merge! token: parent_site.paypal_token
|
||||
end
|
||||
|
||||
ppr = PayPal::Recurring.new hash
|
||||
ppr = PayPalRecurring.new hash
|
||||
|
||||
paypal_response = ppr.checkout
|
||||
|
||||
@@ -138,7 +138,7 @@ get '/supporter/paypal/return' do
|
||||
flash[:error] = 'Unknown error, could not complete the request. Please contact Neocities support.'
|
||||
end
|
||||
|
||||
ppr = PayPal::Recurring.new(paypal_recurring_hash.merge(
|
||||
ppr = PayPalRecurring.new(paypal_recurring_hash.merge(
|
||||
token: params[:token],
|
||||
payer_id: params[:PayerID]
|
||||
))
|
||||
@@ -151,7 +151,7 @@ get '/supporter/paypal/return' do
|
||||
|
||||
site = current_site.parent || current_site
|
||||
|
||||
ppr = PayPal::Recurring.new(paypal_recurring_authorization_hash.merge(
|
||||
ppr = PayPalRecurring.new(paypal_recurring_authorization_hash.merge(
|
||||
frequency: 1,
|
||||
token: params[:token],
|
||||
period: :monthly,
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ if ENV['RACK_ENV'] != 'development'
|
||||
Sass::Plugin.options[:full_exception] = false
|
||||
end
|
||||
|
||||
PayPal::Recurring.configure do |config|
|
||||
PayPalRecurring.configure do |config|
|
||||
config.sandbox = false
|
||||
config.username = $config['paypal_api_username']
|
||||
config.password = $config['paypal_api_password']
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'net/https'
|
||||
require 'uri'
|
||||
|
||||
class PayPalRecurring
|
||||
API_VERSION = '72.0'
|
||||
USER_AGENT = 'Neocities PayPalRecurring'
|
||||
|
||||
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
|
||||
|
||||
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)))
|
||||
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
|
||||
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
|
||||
|
||||
def initialize(http_response)
|
||||
@http_response = http_response
|
||||
end
|
||||
|
||||
def params
|
||||
@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?
|
||||
errors.empty? && success?
|
||||
end
|
||||
|
||||
def errors
|
||||
@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
|
||||
+1
-1
@@ -1437,7 +1437,7 @@ class Site < Sequel::Model
|
||||
self.stripe_subscription_id = nil
|
||||
self.plan_ended = true
|
||||
elsif paypal_paying_supporter?
|
||||
ppr = PayPal::Recurring.new profile_id: paypal_profile_id
|
||||
ppr = PayPalRecurring.new profile_id: paypal_profile_id
|
||||
ppr.cancel
|
||||
|
||||
self.plan_type = nil
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative './environment.rb'
|
||||
|
||||
describe PayPalRecurring do
|
||||
before do
|
||||
@paypal_config = {
|
||||
sandbox: PayPalRecurring.sandbox,
|
||||
username: PayPalRecurring.username,
|
||||
password: PayPalRecurring.password,
|
||||
signature: PayPalRecurring.signature
|
||||
}
|
||||
|
||||
PayPalRecurring.configure do |config|
|
||||
config.sandbox = false
|
||||
config.username = 'api-user'
|
||||
config.password = 'api-pass'
|
||||
config.signature = 'api-signature'
|
||||
end
|
||||
end
|
||||
|
||||
after do
|
||||
PayPalRecurring.configure do |config|
|
||||
config.sandbox = @paypal_config[:sandbox]
|
||||
config.username = @paypal_config[:username]
|
||||
config.password = @paypal_config[:password]
|
||||
config.signature = @paypal_config[:signature]
|
||||
end
|
||||
end
|
||||
|
||||
it 'starts an express checkout recurring billing agreement' do
|
||||
stub_paypal_response({
|
||||
'METHOD' => 'SetExpressCheckout',
|
||||
'USER' => 'api-user',
|
||||
'PWD' => 'api-pass',
|
||||
'SIGNATURE' => 'api-signature',
|
||||
'VERSION' => PayPalRecurring::API_VERSION,
|
||||
'PAYMENTREQUEST_0_AMT' => '5.0',
|
||||
'AMT' => '5.0',
|
||||
'PAYMENTREQUEST_0_CURRENCYCODE' => 'USD',
|
||||
'CURRENCYCODE' => 'USD',
|
||||
'DESC' => 'Neocities Supporter - Monthly',
|
||||
'L_BILLINGAGREEMENTDESCRIPTION0' => 'Neocities Supporter - Monthly',
|
||||
'PAYMENTREQUEST_0_NOTIFYURL' => 'https://neocities.org/webhooks/paypal',
|
||||
'NOTIFYURL' => 'https://neocities.org/webhooks/paypal',
|
||||
'RETURNURL' => 'https://neocities.org/supporter/paypal/return',
|
||||
'CANCELURL' => 'https://neocities.org/supporter',
|
||||
'PAYMENTREQUEST_0_PAYMENTACTION' => 'Authorization',
|
||||
'NOSHIPPING' => '1',
|
||||
'L_BILLINGTYPE0' => 'RecurringPayments'
|
||||
}, 'ACK=Success&TOKEN=EC-123')
|
||||
|
||||
response = PayPalRecurring.new(authorization_params).checkout
|
||||
|
||||
_(response.valid?).must_equal true
|
||||
_(response.token).must_equal 'EC-123'
|
||||
_(response.checkout_url).must_equal 'https://www.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=EC-123&useraction=commit'
|
||||
end
|
||||
|
||||
it 'requests the initial payment after PayPal redirects back' do
|
||||
stub_paypal_response({
|
||||
'METHOD' => 'DoExpressCheckoutPayment',
|
||||
'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale',
|
||||
'TOKEN' => 'EC-123',
|
||||
'PAYERID' => 'PAYER-123',
|
||||
'PAYMENTREQUEST_0_AMT' => '5.0',
|
||||
'AMT' => '5.0'
|
||||
}, 'ACK=Success&PAYMENTINFO_0_ACK=Success&PAYMENTINFO_0_PAYMENTSTATUS=Completed')
|
||||
|
||||
response = PayPalRecurring.new(recurring_params.merge(
|
||||
token: 'EC-123',
|
||||
payer_id: 'PAYER-123'
|
||||
)).request_payment
|
||||
|
||||
_(response.approved?).must_equal true
|
||||
_(response.completed?).must_equal true
|
||||
end
|
||||
|
||||
it 'creates the recurring payments profile' do
|
||||
stub_paypal_response({
|
||||
'METHOD' => 'CreateRecurringPaymentsProfile',
|
||||
'TOKEN' => 'EC-123',
|
||||
'PAYERID' => 'PAYER-123',
|
||||
'PROFILEREFERENCE' => '42',
|
||||
'PROFILESTARTDATE' => '2026-01-02T03:04:05Z',
|
||||
'MAXFAILEDPAYMENTS' => '3',
|
||||
'AUTOBILLOUTAMT' => 'AddToNextBilling',
|
||||
'BILLINGFREQUENCY' => '1',
|
||||
'BILLINGPERIOD' => 'Month'
|
||||
}, 'ACK=Success&PROFILEID=I-123&PROFILESTATUS=ActiveProfile')
|
||||
|
||||
response = PayPalRecurring.new(authorization_params.merge(
|
||||
frequency: 1,
|
||||
token: 'EC-123',
|
||||
period: :monthly,
|
||||
reference: '42',
|
||||
payer_id: 'PAYER-123',
|
||||
start_at: Time.utc(2026, 1, 2, 3, 4, 5),
|
||||
failed: 3,
|
||||
outstanding: :next_billing
|
||||
)).create_recurring_profile
|
||||
|
||||
_(response.valid?).must_equal true
|
||||
_(response.profile_id).must_equal 'I-123'
|
||||
end
|
||||
|
||||
it 'cancels a recurring payments profile' do
|
||||
stub_paypal_response({
|
||||
'METHOD' => 'ManageRecurringPaymentsProfileStatus',
|
||||
'ACTION' => 'Cancel',
|
||||
'PROFILEID' => 'I-123'
|
||||
}, 'ACK=Success&PROFILEID=I-123&PROFILESTATUS=Cancelled')
|
||||
|
||||
response = PayPalRecurring.new(profile_id: 'I-123').cancel
|
||||
|
||||
_(response.valid?).must_equal true
|
||||
_(response.profile_id).must_equal 'I-123'
|
||||
end
|
||||
|
||||
it 'collects PayPal NVP errors' do
|
||||
stub_paypal_response({
|
||||
'METHOD' => 'SetExpressCheckout'
|
||||
}, 'ACK=Failure&L_ERRORCODE0=10001&L_SHORTMESSAGE0=Internal%20Error&L_LONGMESSAGE0=Try%20again')
|
||||
|
||||
response = PayPalRecurring.new(authorization_params).checkout
|
||||
|
||||
_(response.valid?).must_equal false
|
||||
_(response.errors).must_equal [{
|
||||
code: '10001',
|
||||
messages: ['Internal Error', 'Try again']
|
||||
}]
|
||||
end
|
||||
|
||||
it 'uses peer verification with the system certificate store' do
|
||||
client = PayPalRecurring.new.send(:http_client, URI(PayPalRecurring.api_endpoint))
|
||||
|
||||
_(client.use_ssl?).must_equal true
|
||||
_(client.verify_mode).must_equal OpenSSL::SSL::VERIFY_PEER
|
||||
_(client.ca_file).must_be_nil
|
||||
_(client.cert_store).must_be_instance_of OpenSSL::X509::Store
|
||||
end
|
||||
|
||||
def stub_paypal_response(expected_params, body)
|
||||
stub_request(:post, PayPalRecurring.api_endpoint).with do |request|
|
||||
params = URI.decode_www_form(request.body).to_h
|
||||
expected_params.all? { |name, value| params[name] == value }
|
||||
end.to_return(status: 200, body: body)
|
||||
end
|
||||
|
||||
def recurring_params
|
||||
{
|
||||
ipn_url: 'https://neocities.org/webhooks/paypal',
|
||||
description: 'Neocities Supporter - Monthly',
|
||||
amount: '5.0',
|
||||
currency: 'USD'
|
||||
}
|
||||
end
|
||||
|
||||
def authorization_params
|
||||
recurring_params.merge(
|
||||
return_url: 'https://neocities.org/supporter/paypal/return',
|
||||
cancel_url: 'https://neocities.org/supporter'
|
||||
)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user