From 552090c4b8356ea5572f3196b56f3a2c6751aeff Mon Sep 17 00:00:00 2001 From: kipsang Date: Fri, 27 Mar 2026 17:05:09 +0300 Subject: [PATCH 1/3] Refactor iPay integration for improved logging, user association, and payment processing - Updated logging in gateway callbacks to use Rails.logger for better integration. - Added user_id association to Spree::IpaySource model and updated user_id setter method. - Refactored Spree::PaymentMethod::Ipay to streamline payment processing and improve error handling. - Introduced new form fields for phone number and transaction ID in the iPay payment source form. - Updated Spree initializer to accommodate new attributes for iPay sources. - Added migration to include user_id in spree_ipay_sources table. - Enhanced engine configuration for better autoloading and initialization. - Updated gemspec to reflect new dependencies and Ruby version requirements. --- Gemfile | 7 +- .../spree/api/v1/ipay_controller.rb | 8 +- .../spree/api/v1/ipay_controller_decorator.rb | 2 +- .../spree/checkout_controller_decorator.rb | 92 +- .../spree/gateway_callbacks_controller.rb | 2 +- app/models/spree/ipay_source.rb | 11 +- app/models/spree/payment_method/ipay.rb | 795 +++++------------- .../payments/source_forms/_ipay.html.erb | 36 + config/initializers/spree_ipay.rb | 26 +- ...20000_add_user_id_to_spree_ipay_sources.rb | 17 + lib/spree_ipay/engine.rb | 57 +- spree_ipay.gemspec | 27 +- 12 files changed, 328 insertions(+), 752 deletions(-) create mode 100644 app/views/spree/admin/payments/source_forms/_ipay.html.erb create mode 100644 db/migrate/20260327120000_add_user_id_to_spree_ipay_sources.rb diff --git a/Gemfile b/Gemfile index af4ffbe..13551e7 100644 --- a/Gemfile +++ b/Gemfile @@ -1,16 +1,11 @@ source 'https://rubygems.org' -# Use local gemspec gemspec # Ruby version requirement ruby "~> 3.3.8" -# Spree core dependencies -gem 'deface', '~> 1.9.0' -gem 'spree', '>= 4.5.0', '< 5.0.0' -gem 'spree_backend', '>= 4.5.0', '< 5.0.0' -gem 'spree_extension', '~> 0.1.0' +gem 'spree', '>= 5.0', '< 6.0' group :development, :test do gem 'rubocop', '~> 1.58', require: false diff --git a/app/controllers/spree/api/v1/ipay_controller.rb b/app/controllers/spree/api/v1/ipay_controller.rb index 01d08f8..ffe9864 100644 --- a/app/controllers/spree/api/v1/ipay_controller.rb +++ b/app/controllers/spree/api/v1/ipay_controller.rb @@ -6,16 +6,16 @@ module V1 # Handles API endpoints for iPay payment processing. # Provides endpoints for callbacks from iPay and payment status checks. # Skips authentication for callback endpoints to allow external access. - class IpayController < Spree::Api::V1::BaseController + class IpayController < (defined?(Spree::Api::V1::BaseController) ? Spree::Api::V1::BaseController : Spree::StoreController) # Only load payment for :return, not for :callback (GET/POST) before_action :load_payment, only: [:return] skip_before_action :load_payment, only: [:callback] # SKIP ALL USER-RELATED AUTH FOR CALLBACK (SECURITY BY HASH ONLY) - skip_before_action :authenticate_user, only: %i[callback return] + skip_before_action :authenticate_user, only: %i[callback return], raise: false # skip_before_action :authenticate_spree_user, only: [:callback, :return] - skip_before_action :load_user, only: %i[callback return] # If present in base - skip_before_action :set_locale, only: %i[callback return] # Avoids user-locale issues + skip_before_action :load_user, only: %i[callback return], raise: false # If present in base + skip_before_action :set_locale, only: %i[callback return], raise: false # Avoids user-locale issues # iPay callback endpoint def callback diff --git a/app/controllers/spree/api/v1/ipay_controller_decorator.rb b/app/controllers/spree/api/v1/ipay_controller_decorator.rb index 0d2c95c..853c3fc 100644 --- a/app/controllers/spree/api/v1/ipay_controller_decorator.rb +++ b/app/controllers/spree/api/v1/ipay_controller_decorator.rb @@ -7,7 +7,7 @@ module IpayControllerDecorator def self.prepended(base) base.respond_to :json # Only skip authentication for callbacks and return URLs which need to be publicly accessible - base.skip_before_action :authenticate_user, only: [:callback, :return] + base.skip_before_action :authenticate_user, only: [:callback, :return], raise: false base.before_action :set_headers base.before_action :set_payment_method, only: [:status] base.before_action :authenticate_for_status, only: [:status] diff --git a/app/controllers/spree/checkout_controller_decorator.rb b/app/controllers/spree/checkout_controller_decorator.rb index cf98bf8..3c50474 100644 --- a/app/controllers/spree/checkout_controller_decorator.rb +++ b/app/controllers/spree/checkout_controller_decorator.rb @@ -79,97 +79,7 @@ def handle_ipay_redirect end def generate_ipay_form_html(payment, phone, ipay_method) - # Get required values from payment method preferences - live = ipay_method.preferred_test_mode ? '0' : '1' - oid = payment.order.number - inv = "#{payment.order.number}#{Time.now.to_i}" # unique invoice - ttl = (payment.amount.to_f * 100).to_i.to_s # Amount in cents - eml = payment.order.email - vid = ipay_method.preferred_vendor_id.presence || '' - curr = ipay_method.preferred_currency.presence || 'KES' - p1 = "" - p2 = "" - p3 = "" - p4 = "" - cbk = ipay_method.preferred_callback_url.presence || "https://example.com/ipay/callback" - cst = "1" - crl = "2" - - # Generate the hash with the phone number - hsh = ipay_method.ipay_signature_hash(payment, phone) - - # Prepare iPay parameters - must match the exact order and parameters used in hash generation - ipay_params = { - 'live' => live, - 'oid' => oid, - 'inv' => inv, - 'ttl' => ttl, - 'tel' => phone || '0700000000', - 'eml' => eml, - 'vid' => vid, - 'curr' => curr, - 'p1' => p1, - 'p2' => p2, - 'p3' => p3, - 'p4' => p4, - 'cbk' => cbk, - 'cst' => cst, - 'crl' => crl, - 'hsh' => hsh - } - - # Add channel parameters based on preferences - %i[ - mpesa bonga airtel equity mobilebanking - creditcard unionpay mvisa vooma pesalink autopay - ].each do |channel| - ipay_params[channel.to_s] = ipay_method.preferences["#{channel}"] ? '1' : '0' - end - - # Generate the form HTML with full-page flexible layout and improved button positioning - <<~HTML - - - - - - Redirecting to iPay - - - - -
-
- - - - -
-

Redirecting to iPay

-

Please wait while we securely redirect you to the payment page.

-

If you are not redirected automatically, please click the button below.

-
- #{ipay_params.map { |k, v| "" }.join("\n")} - -
- -
- - - HTML + ipay_method.generate_ipay_form_html(payment, phone) rescue StandardError => e raise "Error generating payment form: #{e.message}" end diff --git a/app/controllers/spree/gateway_callbacks_controller.rb b/app/controllers/spree/gateway_callbacks_controller.rb index 397d487..33f21bb 100644 --- a/app/controllers/spree/gateway_callbacks_controller.rb +++ b/app/controllers/spree/gateway_callbacks_controller.rb @@ -42,7 +42,7 @@ def confirm paid_amount = params['mc'].to_f required_amount = order.total.to_f if paid_amount < required_amount - Spree::Ipay::Logger.error("Amount paid (#{paid_amount}) is less than order total (#{required_amount})", order.number) + Rails.logger.warn("iPay amount mismatch for order #{order.number}") if defined?(Rails.logger) render plain: "Amount paid (#{paid_amount}) is less than required (#{required_amount})", status: :payment_required return diff --git a/app/models/spree/ipay_source.rb b/app/models/spree/ipay_source.rb index 6f5373b..fc45d18 100644 --- a/app/models/spree/ipay_source.rb +++ b/app/models/spree/ipay_source.rb @@ -1,14 +1,21 @@ module Spree class IpaySource < Spree::Base - # Associations + attribute :user_id, :integer + belongs_to :payment_method, class_name: 'Spree::PaymentMethod::Ipay', optional: true + belongs_to :user, class_name: Spree.user_class.to_s, optional: true has_many :payments, as: :source, class_name: 'Spree::Payment', dependent: :destroy # Validations validates :phone, presence: true + def user_id=(value) + super(value.presence) + self.user = Spree.user_class.find_by(id: self[:user_id]) if self[:user_id].present? + end + # Callbacks - before_validation :normalize_phone, if: :phone_changed? + before_validation :normalize_phone, if: :will_save_change_to_phone? private diff --git a/app/models/spree/payment_method/ipay.rb b/app/models/spree/payment_method/ipay.rb index 0fdcfc1..b4abbbc 100644 --- a/app/models/spree/payment_method/ipay.rb +++ b/app/models/spree/payment_method/ipay.rb @@ -1,119 +1,59 @@ # frozen_string_literal: true +require 'erb' require 'httparty' +require 'json' +require 'openssl' +require 'uri' module Spree module Ipay - VERSION = '1.0.9' + VERSION = '1.0.10' end - # iPay payment method integration for Spree Commerce. - # Handles payment processing, callbacks, and communication with the iPay payment gateway. - # Supports various payment channels including M-PESA, Airtel Money, and credit cards. class PaymentMethod::Ipay < ::Spree::PaymentMethod include HTTParty - # Core settings (in display order) + CHANNEL_PREFERENCES = %i[ + mpesa bonga airtel equity mobilebanking + creditcard unionpay mvisa vooma pesalink autopay + ].freeze + preference :vendor_id, :string preference :hash_key, :string preference :test_mode, :boolean, default: true preference :currency, :string, default: 'KES' preference :callback_url, :string, default: '/ipay/confirm' - preference :return_url, :string, default: -> { - "#{Rails.application.routes.url_helpers.root_url.chomp('/')}/ipay/confirm" - } - - # Payment channels (in display order) - preference :mpesa, :boolean, default: true - preference :airtel, :boolean, default: false - preference :equity, :boolean, default: false - preference :mobilebanking, :boolean, default: false - preference :creditcard, :boolean, default: false - preference :unionpay, :boolean, default: false - preference :mvisa, :boolean, default: false - preference :vooma, :boolean, default: false - preference :pesalink, :boolean, default: false - preference :autopay, :boolean, default: false - - # Ensure preferences are sorted in the desired display order + preference :return_url, :string, default: '/ipay/confirm' + + CHANNEL_PREFERENCES.each do |channel| + preference channel, :boolean, default: (channel == :mpesa) + end + def self.preference_order [ :vendor_id, :hash_key, :test_mode, :currency, - :callback_url, :return_url, - :mpesa, :airtel, :equity, :mobilebanking, :creditcard, :unionpay, - :mvisa, :vooma, :pesalink, :autopay + :callback_url, :return_url, *CHANNEL_PREFERENCES ] end - - # Add caching for payment configuration - def payment_config - Rails.cache.fetch("ipay_config_#{id}", expires_in: 1.hour) do - { - vendor_id: preferred_vendor_id, - hash_key: preferred_hash_key, - test_mode: preferred_test_mode, - currency: preferred_currency, - callback_url: preferred_callback_url, - return_url: preferred_return_url, - channels: { - mpesa: preferred_mpesa, - airtel: preferred_airtel, - equity: preferred_equity, - mobilebanking: preferred_mobilebanking, - creditcard: preferred_creditcard, - unionpay: preferred_unionpay, - mvisa: preferred_mvisa, - vooma: preferred_vooma, - pesalink: preferred_pesalink, - autopay: preferred_autopay - } - } - end - end - # Clear cache when preferences change - def preferences=(prefs) - super - Rails.cache.delete("ipay_config_#{id}") - end - - # Override preferences getter to maintain order def self.preferences - @preferences ||= super.slice(*preference_order) + super.slice(*preference_order) end - def initialize(*args) - super - # Initialize with empty preferences - don't use environment variables - @preferences ||= {} - - # Set default values if not already set - self.preferred_test_mode = true if preferred_test_mode.nil? + def partial_name + 'ipay' end - preference :currency, :string, default: 'KES' - preference :callback_url, :string, default: '/ipay/confirm' - preference :return_url, :string, default: '/ipay/confirm' - # Channel preferences - preference :mpesa, :boolean, default: true - preference :bonga, :boolean, default: true - preference :airtel, :boolean, default: true - preference :equity, :boolean, default: true - preference :mobilebanking, :boolean, default: true - preference :creditcard, :boolean, default: true - preference :unionpay, :boolean, default: true - preference :mvisa, :boolean, default: true - preference :vooma, :boolean, default: true - preference :pesalink, :boolean, default: true - preference :autopay, :boolean, default: true + def payment_profiles_supported? + false + end def payment_source_class Spree::IpaySource end def source_required? - # We need to return true here to ensure a payment source is created - # This is required for Spree's payment processing flow true end @@ -130,590 +70,303 @@ def can_capture?(payment) end def supports?(source) - # Return true for both nil source and IpaySource - # This allows the payment to be created without a source initially source.nil? || source.is_a?(Spree::IpaySource) end - def process_payment(payment) - # Create a payment source if one doesn't exist - if payment.source.nil? - payment.source = Spree::IpaySource.create!( - payment_method: self, - user: payment.order.user - ) - payment.save! + def reusable_sources(_order) + [] + end + + def payment_config + Rails.cache.fetch("ipay_config_#{id}", expires_in: 1.hour) do + { + vendor_id: preferred_vendor_id, + hash_key: preferred_hash_key, + test_mode: test_mode?, + currency: preferred_currency, + callback_url: preferred_callback_url, + return_url: preferred_return_url, + channels: CHANNEL_PREFERENCES.index_with { |channel| public_send("preferred_#{channel}") } + } end + end - # Mark payment as processing - payment.started_processing! - - # Return a success response - ActiveMerchant::Billing::Response.new( - true, - 'Payment processing started', - {}, - authorization: "ipay_#{payment.order.number}_#{Time.now.to_i}" - ) - rescue StandardError => e - failure_response("Payment processing failed") + def preferences=(prefs) + super + Rails.cache.delete("ipay_config_#{id}") if id.present? end def authorize(amount, source, options = {}) - options[:originator] - order = payment.order + payment = options[:originator] + return failure_response('Payment is missing') unless payment.is_a?(Spree::Payment) - # Ensure the order is in the correct state - return failure_response("Order is not in a confirmable state") unless order.checkout_steps.include?('confirm') + ipay_source = ensure_source(source, payment) + return failure_response('Phone number is required for iPay payments') unless ipay_source&.phone.present? - # Ensure we have a valid source - return failure_response("Invalid payment source") if source.blank? || !source.is_a?(Spree::IpaySource) + payment.source = ipay_source + payment.payment_method ||= self + payment.amount = amount if amount.present? - # Ensure source is associated with payment method - if source.payment_method_id != id && !source.update(payment_method_id: id) - return failure_response("Failed to update payment source") - end + return failure_response(payment.errors.full_messages.to_sentence) unless payment.save - # Get phone from source - phone = source.phone + store_phone_in_session(options, ipay_source.phone) + process!(phone: ipay_source.phone, payment: payment, amount: amount, options: options) + rescue StandardError => error + failure_response("Authorization failed: #{error.message}") + end - # Store phone number in session if we have a controller context - options[:controller].session[:ipay_phone_number] = phone if options[:controller]&.respond_to?(:session) + def capture(_amount, response_code, _options = {}) + return success_response('Test mode - payment captured successfully', authorization: "TEST-#{SecureRandom.hex(8)}") if test_mode? - # Ensure payment has the source assigned - if payment.source.nil? || !payment.source.is_a?(Spree::IpaySource) - payment.source = source - payment.payment_method_id = id + success_response('Payment captured successfully', authorization: response_code) + rescue StandardError => error + failure_response("Capture failed: #{error.message}") + end - # Save the payment to ensure source is associated - unless payment.save - return failure_response("Failed to save payment: #{payment.errors.full_messages.to_sentence}") - end - else - payment.source.phone = phone - return failure_response("Failed to update payment source") if payment.source.changed? && !payment.source.save - end + def void(response_code, _options = {}) + return success_response('Test mode - payment voided successfully', authorization: "TEST-VOID-#{SecureRandom.hex(4)}") if test_mode? - # Process the payment - process!(phone: phone, payment: payment, amount: amount, options: options) - rescue StandardError => e - failure_response("Authorization failed: #{e.message}") + response = cancel_payment(response_code) + return success_response if response['status'] == 'success' + + failure_response(response['message'] || 'Payment void failed') + rescue StandardError => error + failure_response("Payment void failed: #{error.message}") end - def capture(_amount, response_code, options = {}) - options[:originator] + def process!(phone: nil, payment: nil, amount: nil, options: {}) + return failure_response('Missing required parameters') unless payment&.order && phone.present? && amount.to_f.positive? + return failure_response('Payment configuration error') if preferred_vendor_id.blank? || preferred_hash_key.blank? - # If we're in test mode, just return success - if preferred_test_mode - return ActiveMerchant::Billing::Response.new( - true, - 'Test mode - payment captured successfully', - { test: true, authorization: "TEST-#{SecureRandom.hex(8)}" }, - { test: true } - ) - end + payment.started_processing! if payment.respond_to?(:started_processing!) && payment.checkout? + + form_html = generate_ipay_form_html(payment, phone) + store_phone_in_session(options, phone) + options[:controller]&.session&.[]=(:ipay_form_html, form_html) - # In production, you would implement the actual capture logic here - # For now, we'll simulate a successful capture ActiveMerchant::Billing::Response.new( true, - 'Payment captured successfully', - { authorization: response_code }, - {} + 'iPay payment initiated successfully', + { form_html: form_html }, + authorization: payment.number, + test: test_mode? ) - rescue StandardError => e - failure_response("Capture failed: #{e.message}") + rescue StandardError => error + failure_response("Payment processing failed: #{error.message}") end - def void(response_code, _options = {}) - # If we're in test mode, just return success - if preferred_test_mode - return ActiveMerchant::Billing::Response.new( - true, - 'Test mode - payment voided successfully', - { test: true, authorization: "TEST-VOID-#{SecureRandom.hex(4)}" }, - { test: true } - ) - end + def generate_ipay_form_html(payment, phone = nil) + fields = build_form_fields(payment, phone) - response = cancel_payment(response_code) + form_inputs = fields.map do |key, value| + %() + end.join("\n") - if response['status'] == 'success' - success_response - else - failure_response(response['message'] || 'Payment void failed') - end - rescue StandardError => e - failure_response("Payment void failed: #{e.message}") + <<~HTML +
+ #{form_inputs} + +
+ + HTML end - def process!(phone: nil, payment: nil, amount: nil, options: {}) - # Validate required parameters - unless phone.present? && payment.present? && payment.order.present? && amount.present? - return failure_response("Missing required parameters") - end - - # Validate phone number format - phone_digits = phone.to_s.gsub(/\D/, '') - unless phone_digits.match?(/^\d{10}$/) - return failure_response("Invalid phone number format") - end - - # Validate credentials are set - if preferred_vendor_id.blank? || preferred_hash_key.blank? - return failure_response("Payment configuration error") - end - - # Validate payment amount - unless amount.to_f > 0 - return failure_response('Invalid payment amount') - end - - # Update payment amount if needed - if (payment.amount.to_f - amount.to_f).abs > Float::EPSILON - payment.amount = amount - payment.save! - end + def ipay_signature_hash(payment, phone = nil) + signature_payload(build_form_fields(payment, phone)) + end - # Store phone number in session if we have a controller context - options[:controller].session[:ipay_phone_number] = phone if options[:controller]&.respond_to?(:session) + def confirm(payment, phone: nil) + return success_response if payment.completed? - # Transition payment to processing state - payment.started_processing! if payment.respond_to?(:started_processing!) + response = process!(phone: phone || payment.source&.phone, payment: payment, amount: payment.amount, options: {}) + return response if response.success? - success_response('Payment processing started') - rescue StandardError => e - failure_response("Payment processing failed") + failure_response(response.message) end - # Generate HMAC SHA1 hash for iPay - # Matches PHP's hash_hmac('sha1', $datastring, $hashkey) implementation - # @param payment [Spree::Payment] The payment object - # @param phone [String] The customer's phone number - def ipay_signature_hash(payment, phone = nil) - # Get values from payment method preferences - vendor_id = preferred_vendor_id.to_s - hash_key = preferred_hash_key.to_s + def complete(payment) + return success_response if payment.completed? - # Validate required preferences - if vendor_id.blank? || hash_key.blank? - raise "Missing required iPay credentials" - end + status_response = check_payment_status(payment.response_code) + return failure_response(status_response['message'] || 'Payment completion failed') unless status_response['status'] == 'success' - # Set live mode (0 for test, 1 for live) - live = test_mode? ? "0" : "1" - - # Prepare values - must match exactly what will be sent in the form - oid = payment.order.number.to_s - inv = "#{payment.order.number}#{Time.now.to_i}" # unique invoice - ttl = (payment.amount.to_f * 100).to_i.to_s # Amount in cents - tel = phone.presence || payment.order.bill_address&.phone.to_s.presence || "0700000000" - eml = payment.order.email.to_s - vid = vendor_id - curr = preferred_currency.presence || 'KES' - p1 = "" - p2 = "" - p3 = "" - p4 = "" - cbk = preferred_callback_url.presence || "https://#{base_url}/ipay/confirm" - cst = "1" - crl = "2" - - # Create datastring in the exact order required by iPay - datastring = [ - live, oid, inv, ttl, tel, eml, vid, curr, - p1, p2, p3, p4, cbk, cst, crl - ].join - - # Generate hash using OpenSSL to match PHP's hash_hmac('sha1', ...) - digest = OpenSSL::Digest.new('sha1') - OpenSSL::HMAC.hexdigest(digest, hash_key, datastring) - rescue StandardError => e - raise "Error generating hash" - end - - def generate_ipay_form_html(payment) - # Get required values - live = test_mode? ? "0" : "1" - # Use numeric order ID for transaction code - oid = payment.order.id.to_s - # Use numeric order ID for invoice as well - inv = payment.order.id.to_s - ttl = (payment.amount.to_f * 100).to_i.to_s # Amount in cents - tel = payment.order.bill_address&.phone || session[:ipay_phone_number] || "0700000000" - eml = payment.order.email - vid = preferred_vendor_id - curr = preferred_currency.presence || 'KES' - p1 = "" - p2 = "" - p3 = "" - p4 = "" - # Generate proper callback and return URLs - # Extract host from the return_url preference - return_uri = URI.parse(preferred_return_url.presence || 'https://example.com') - default_host = return_uri.host - default_protocol = return_uri.scheme || 'https' - - # Generate callback URL for iPay to send payment status - begin - if preferred_callback_url.present? - callback_uri = URI.parse(preferred_callback_url) - callback_uri.scheme ||= default_protocol - callback_uri.host ||= default_host - callback_uri.path = '/api/v1/ipay/callback' if callback_uri.path.blank? || callback_uri.path == '/' - else - # In test mode, ensure we're using HTTPS for security - protocol = test_mode? ? 'https' : default_protocol - callback_uri = URI.parse("#{protocol}://#{default_host}/api/v1/ipay/callback") - end - - # Ensure the callback URL is valid - raise URI::InvalidURIError if callback_uri.host.blank? - - # Add test parameter if in test mode - if test_mode? - params = URI.decode_www_form(callback_uri.query || '').to_h - params['test'] = '1' - callback_uri.query = URI.encode_www_form(params) - end - - cbk = callback_uri.to_s - rescue URI::InvalidURIError => e - error_msg = "Invalid callback URL format: #{e.message}" - Spree::Ipay::Logger.error(StandardError.new(error_msg), payment.order.number) - # Fallback to a safe default in case of errors - cbk = "https://#{default_host}/api/v1/ipay/callback" - cbk += '?test=1' if test_mode? - end + payment.complete! if payment.respond_to?(:can_complete?) ? payment.can_complete? : !payment.completed? + success_response + rescue StandardError => error + failure_response("Payment completion failed: #{error.message}") + end - # Generate return URL for customer redirect after payment - # Point to the frontend order confirmation page - order_number = payment.order.number - order_token = payment.order.guest_token - rst = preferred_return_url.presence || "#{default_protocol}://#{default_host}/orders/#{order_number}?order_token=#{order_token}" + def check_payment_status(transaction_id) + return error_hash('Missing transaction reference') if transaction_id.blank? - cst = "1" # Customer email notification flag - crl = "2" # Customer phone notification flag + response = self.class.post(status_endpoint, body: status_request_params(transaction_id)) + JSON.parse(response.body) + rescue StandardError + error_hash('Failed to check payment status') + end - begin - hsh = ipay_signature_hash(payment) - rescue StandardError => e - raise "Error generating payment hash: #{e.message}" - end + def cancel_payment(transaction_id) + return error_hash('Missing transaction reference') if transaction_id.blank? - # Prepare iPay parameters - ipay_params = { - live: live, - oid: oid, - inv: inv, - ttl: ttl, - tel: tel, - eml: eml, - vid: vid, - curr: curr, - p1: p1, - p2: p2, - p3: p3, - p4: p4, - cbk: cbk, - rst: rst, - cst: cst, - crl: crl, - hsh: hsh - } + response = self.class.post(status_endpoint, body: cancel_request_params(transaction_id)) + JSON.parse(response.body) + rescue StandardError + error_hash('Failed to cancel payment') + end - # Add channel parameters based on preferences + def generate_hash(payment) + fields = build_form_fields(payment, payment.source&.phone) + signature_payload(fields.except(:hsh)) + end - channels = %i[ - mpesa bonga airtel equity mobilebanking - creditcard unionpay mvisa vooma pesalink autopay - ] + def generate_status_hash(transaction_id) + OpenSSL::HMAC.hexdigest('sha1', preferred_hash_key.to_s, [live_value, preferred_vendor_id.to_s, transaction_id.to_s].join) + end - channels.each do |channel| - channel_value = send("preferred_#{channel}") ? '1' : '0' - ipay_params[channel] = channel_value - end + def generate_cancel_hash(transaction_id) + generate_status_hash(transaction_id) + end - # Generate form HTML - form_html = "
\n" + def callback_url(_payment = nil) + absolute_url(preferred_callback_url.presence || '/ipay/confirm') + end - # Add all parameters with proper escaping - ipay_params.each do |key, value| - form_html << " \n" - end + def return_url(payment = nil) + fallback = payment ? "/orders/#{payment.order.number}?order_token=#{payment.order.guest_token}" : '/ipay/confirm' + absolute_url(preferred_return_url.presence || fallback) + end - # Add submit button and auto-submit script - form_html << " \n" - form_html << "
\n" - form_html << "\n" + def base_url + default_host = Rails.application.routes.default_url_options[:host] + return '' if default_host.blank? - form_html + protocol = Rails.application.routes.default_url_options[:protocol].presence || 'https' + "#{protocol}://#{default_host}" end - def confirm(payment, phone: nil) - return success_response if payment.completed? + def test_mode? + ActiveModel::Type::Boolean.new.cast(preferred_test_mode) + end - begin - response = initiate_payment(payment, phone: phone) - - if response['status'] == 'success' - payment.update!( - response_code: response.dig('data', 'transaction_id'), - avs_response: response.dig('data', 'checkout_url') - ) - - ActiveMerchant::Billing::Response.new( - true, - 'Payment confirmation initiated', - {}, - { - authorization: response.dig('data', 'transaction_id'), - test: test_mode?, - checkout_url: response.dig('data', 'checkout_url') - } - ) - else - error_msg = response['message'] || 'Payment confirmation failed' - failure_response(error_msg) - end - rescue StandardError => e - failure_response("Payment confirmation failed") - end + def api_endpoint + test_mode? ? 'https://sandbox.ipayafrica.com/v3/ke' : 'https://payments.ipayafrica.com/v3/ke' end - def complete(payment) - return success_response if payment.completed? + def success_response(message = 'Success', authorization: nil) + ActiveMerchant::Billing::Response.new(true, message, {}, authorization: authorization, test: test_mode?) + end - begin - # Check payment status - status = check_payment_status(payment.response_code) - - if status['status'] == 'success' - payment.update!(state: 'completed') - success_response - else - failure_response(status['message'] || 'Payment completion failed') - end - rescue StandardError => e - failure_response("Payment completion failed: #{e.message}") - end + def failure_response(message = 'Failed') + ActiveMerchant::Billing::Response.new(false, message, {}, test: test_mode?) end - def initiate_payment(payment, phone: nil) - # Log the start of payment initiation + private - # Prepare parameters - params = { - live: preferred_test_mode ? '0' : '1', - oid: payment.order.number, - inv: payment.order.number, - ttl: payment.amount.to_f.round(2).to_s, - tel: phone, - eml: payment.order.email, - vid: preferred_vendor_id, + def build_form_fields(payment, phone = nil) + normalized_phone = normalize_phone(phone || payment.source&.phone || payment.order.bill_address&.phone || payment.order.billing_address&.phone) + + fields = { + live: live_value, + oid: payment.order.number.to_s, + inv: payment.order.number.to_s, + ttl: payment_amount_value(payment), + tel: normalized_phone, + eml: payment.order.email.to_s, + vid: preferred_vendor_id.to_s, curr: preferred_currency.presence || 'KES', p1: '', p2: '', p3: '', p4: '', - cbk: preferred_callback_url.presence || "#{Rails.application.routes.url_helpers.root_url.chomp('/')}/ipay/confirm", + cbk: callback_url(payment), + rst: return_url(payment), cst: '1', crl: '2' } - # Log all parameters except sensitive ones - log_params = params.dup - log_params[:tel] = '[FILTERED]' if log_params[:tel].present? - log_params[:eml] = '[FILTERED]' if log_params[:eml].present? + CHANNEL_PREFERENCES.each do |channel| + fields[channel] = public_send("preferred_#{channel}") ? '1' : '0' + end - # Generate and add hash - params[:hsh] = generate_hash(payment) + fields[:hsh] = signature_payload(fields) + fields + end + + def ensure_source(source, payment) + ipay_source = source.presence || payment.source + return ipay_source if ipay_source.is_a?(Spree::IpaySource) && ipay_source.phone.present? - # Add channel parameters - %i[ - mpesa bonga airtel equity mobilebanking - creditcard unionpay mvisa vooma pesalink autopay - ].each do |channel| - next unless respond_to?("preferred_#{channel}") + phone = normalize_phone(payment.source&.phone || payment.order&.bill_address&.phone || payment.order&.billing_address&.phone) + return if phone.blank? - params[channel.to_s] = send("preferred_#{channel}") ? '1' : '0' + Spree::IpaySource.find_or_initialize_by(payment_method: self, phone: phone).tap do |record| + record.save! if record.new_record? || record.changed? end + end - # Use the class-level api_endpoint method - # Parameters prepared for form submission + def store_phone_in_session(options, phone) + controller = options[:controller] + return unless controller&.respond_to?(:session) && phone.present? - # Generate form HTML - use the proper endpoint based on test mode - form_action = api_endpoint + controller.session[:ipay_phone_number] = phone + end - form_html = "
\n" + def normalize_phone(phone) + digits = phone.to_s.gsub(/\D/, '') + return if digits.blank? + return "254#{digits[1..]}" if digits.length == 10 && digits.start_with?('0') + return "254#{digits}" if digits.length == 9 - params.each do |key, value| - escaped_value = ERB::Util.html_escape(value.to_s) - form_html += "\n" - end - - form_html += "
" - form_html += "" + digits + end - # Store form HTML in session - options[:controller].session[:ipay_form_html] = form_html + def payment_amount_value(payment) + (payment.amount.to_f * 100).to_i.to_s + end - # Return success response - ActiveMerchant::Billing::Response.new( - true, - 'iPay payment initiated successfully', - { - form_html: form_html - } - ) - rescue StandardError => e - failure_response("Payment initiation failed") + def signature_payload(fields) + data_string = %i[live oid inv ttl tel eml vid curr p1 p2 p3 p4 cbk cst crl].map { |key| fields[key].to_s }.join + OpenSSL::HMAC.hexdigest('sha1', preferred_hash_key.to_s, data_string) end - def check_payment_status(transaction_id) - # Prepare status check parameters - params = { - live: test_mode? ? '0' : '1', + def status_request_params(transaction_id) + { + live: live_value, vid: preferred_vendor_id, tid: transaction_id, hsh: generate_status_hash(transaction_id) } - - # Make API call to check status - response = HTTParty.post( - preferred_api_endpoint, - body: params - ) - - # Parse and return response - JSON.parse(response.body) - rescue StandardError => e - { - status: 'error', - message: 'Failed to check payment status' - } end - def cancel_payment(transaction_id) - # Prepare cancellation parameters - params = { - live: test_mode? ? '0' : '1', + def cancel_request_params(transaction_id) + { + live: live_value, vid: preferred_vendor_id, tid: transaction_id, hsh: generate_cancel_hash(transaction_id) } - - # Make API call to cancel payment - response = HTTParty.post( - preferred_api_endpoint, - body: params - ) - - # Parse and return response - JSON.parse(response.body) - rescue StandardError => e - { - status: 'error', - message: 'Failed to cancel payment' - } - end - - def generate_hash(payment) - # Prepare all values - live = preferred_test_mode ? '0' : '1' - oid = payment.order.number - inv = payment.order.number - ttl = payment.amount.to_f.round(2).to_s - eml = payment.order.email - vid = preferred_vendor_id - curr = preferred_currency.presence || 'KES' - cbk = preferred_callback_url.presence || '/ipay/confirm' - - - # Create data string in the exact order required by iPay - data_string = [ - live, # live - oid, # order ID - inv, # invoice number - ttl, # total amount - '', # tel (empty as per iPay docs) - eml, # email - vid, # vendor ID - curr, # currency - '', # p1 - '', # p2 - '', # p3 - '', # p4 - cbk, # callback URL - '1', # cst - '2' # crl - ].join - - # Generate the hash - OpenSSL::HMAC.hexdigest('sha1', preferred_hash_key, data_string) - - # Generate and return hash using HMAC SHA1 - OpenSSL::HMAC.hexdigest('sha1', preferred_hash_key, data_string) - end - - def generate_status_hash(transaction_id) - # Generate hash for status check - data_string = [ - preferred_test_mode ? '0' : '1', - preferred_vendor_id, - transaction_id - ].join - - OpenSSL::HMAC.hexdigest('sha1', preferred_hash_key, data_string) end - def generate_cancel_hash(transaction_id) - # Generate hash for payment cancellation - data_string = [ - preferred_test_mode ? '0' : '1', - preferred_vendor_id, - transaction_id - ].join - - OpenSSL::HMAC.hexdigest('sha1', preferred_hash_key, data_string) + def status_endpoint + test_mode? ? 'https://sandbox.ipayafrica.com/ipn/' : 'https://www.ipayafrica.com/ipn/' end - def callback_url(payment) - "#{base_url}/ipay/callback?order=#{payment.order.number}" + def live_value + test_mode? ? '0' : '1' end - def return_url(payment) - "#{base_url}/ipay/return?order=#{payment.order.number}" - end + def absolute_url(value) + uri = URI.parse(value) + return value if uri.host.present? + return value if base_url.blank? - def base_url - Rails.application.routes.url_helpers.root_url.chomp('/') - end - - def test_mode? - preferred_test_mode == true || preferred_test_mode == '1' || preferred_test_mode == 'true' - end - - def api_endpoint - preferred_test_mode ? 'https://sandbox.ipayafrica.com/v3/ke' : 'https://payments.ipayafrica.com/v3/ke' + "#{base_url}#{value.start_with?('/') ? value : "/#{value}"}" + rescue URI::InvalidURIError + value end - def success_response(message = 'Success') - ActiveMerchant::Billing::Response.new( - true, - message, - {}, - test: test_mode? - ) - end - - def failure_response(message = 'Failed') - ActiveMerchant::Billing::Response.new( - false, - message, - {}, - test: test_mode? - ) + def error_hash(message) + { 'status' => 'error', 'message' => message } end end end diff --git a/app/views/spree/admin/payments/source_forms/_ipay.html.erb b/app/views/spree/admin/payments/source_forms/_ipay.html.erb new file mode 100644 index 0000000..e2e1f5e --- /dev/null +++ b/app/views/spree/admin/payments/source_forms/_ipay.html.erb @@ -0,0 +1,36 @@ +<% param_prefix = 'payment[source_attributes]' %> + +
+
+ <%= hidden_field_tag "#{param_prefix}[status]", 'pending' %> + +
+ <%= label_tag "ipay_phone_#{payment_method.id}", raw(Spree.t(:phone_number, default: 'Phone number') + required_span_tag) %> + <%= telephone_field_tag( + "#{param_prefix}[phone]", + @order.bill_address&.phone || @order.billing_address&.phone, + id: "ipay_phone_#{payment_method.id}", + class: 'required form-input', + required: true, + autocomplete: 'tel' + ) %> + + <%= Spree.t(:ipay_admin_phone_help, default: 'Enter the customer phone number used for the iPay payment.') %> + +
+ +
+ <%= label_tag "ipay_transaction_reference_#{payment_method.id}", Spree.t(:transaction_id, default: 'Transaction ID') %> + <%= text_field_tag( + "#{param_prefix}[transaction_id]", + '', + id: "ipay_transaction_reference_#{payment_method.id}", + class: 'form-input', + autocomplete: 'off' + ) %> + + <%= Spree.t(:ipay_admin_transaction_help, default: 'Optional for manual entry; leave blank to let iPay populate it later.') %> + +
+
+
\ No newline at end of file diff --git a/config/initializers/spree_ipay.rb b/config/initializers/spree_ipay.rb index 3d45f1f..fefb788 100644 --- a/config/initializers/spree_ipay.rb +++ b/config/initializers/spree_ipay.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true -# Configure iPay payment method serialization for Spree 4.10.1+ -Rails.application.config.after_initialize do |app| - # For Spree 4.10.1, we'll use the class_eval approach to add the serializer - Spree::Api::V2::Platform::PaymentMethodSerializer.class_eval do - def self.serializer_for(model, *args) - if model.is_a?(Spree::PaymentMethod) && model.type == 'Spree::PaymentMethod::Ipay' - Spree::Api::V2::Platform::IpaySourceSerializer - else - super - end - end - end -end \ No newline at end of file +# Spree 5 loads the extension classes directly via Zeitwerk and the engine. +# This file intentionally stays minimal so older serializer monkey-patches do not +# interfere with payment method serialization in the host application. + +Spree::PermittedAttributes.source_attributes.push( + :phone, + :status, + :transaction_id, + :transaction_reference, + :transaction_amount, + :transaction_timestamp, + :metadata +).uniq! \ No newline at end of file diff --git a/db/migrate/20260327120000_add_user_id_to_spree_ipay_sources.rb b/db/migrate/20260327120000_add_user_id_to_spree_ipay_sources.rb new file mode 100644 index 0000000..c81652d --- /dev/null +++ b/db/migrate/20260327120000_add_user_id_to_spree_ipay_sources.rb @@ -0,0 +1,17 @@ +class AddUserIdToSpreeIpaySources < ActiveRecord::Migration[7.1] + def change + return if column_exists?(:spree_ipay_sources, :user_id) + + add_reference :spree_ipay_sources, + :user, + foreign_key: { to_table: spree_user_table_name }, + index: true, + null: true + end + + private + + def spree_user_table_name + Spree.user_class.table_name + end +end \ No newline at end of file diff --git a/lib/spree_ipay/engine.rb b/lib/spree_ipay/engine.rb index 05b1c39..688faea 100644 --- a/lib/spree_ipay/engine.rb +++ b/lib/spree_ipay/engine.rb @@ -1,37 +1,19 @@ module SpreeIpay class Engine < ::Rails::Engine + require 'spree/core' + engine_name 'spree_ipay' isolate_namespace Spree - # Configure autoload paths - config.autoload_paths += %W[ - #{config.root}/lib - #{config.root}/app/models - #{config.root}/app/serializers - ] + config.autoload_paths << root.join('lib') - # Configure generators config.generators do |g| g.test_framework :rspec end - # Load initializers - initializer 'spree_ipay.config', before: :load_config_initializers do |app| - # Load and inject the default configuration - require_relative '../../config/initializers/spree_ipay' - end - - # Add views to load paths - initializer 'spree_ipay.add_views', after: :load_config_initializers do |app| - # Add gem's views to the main app's view path - app.config.paths['app/views'].unshift(config.root.join('app/views')) - - # Add spree views path - config.paths['app/views'] << 'app/views/spree' - end - - # Configure assets initializer 'spree_ipay.assets' do |app| + next unless app.config.respond_to?(:assets) + app.config.assets.precompile += %w[ spree/frontend/spree_ipay.js spree/frontend/checkout/payment/ipay.js @@ -39,30 +21,19 @@ class Engine < ::Rails::Engine spree/frontend/spree_ipay.css spree/backend/spree_ipay.css ] - - # Add asset paths - app.config.assets.paths << root.join('app', 'assets', 'javascripts') - app.config.assets.paths << root.join('app', 'assets', 'stylesheets') end - # Register the payment method - initializer 'spree_ipay.register_payment_method', after: 'spree.register.payment_methods' do |app| - Rails.application.config.after_initialize do - if defined?(Spree::PaymentMethod) && defined?(Spree::PaymentMethod::Ipay) && - !app.config.spree.payment_methods.include?(Spree::PaymentMethod::Ipay) - app.config.spree.payment_methods << Spree::PaymentMethod::Ipay - end - end + config.after_initialize do |app| + app.config.spree.payment_methods ||= [] + app.config.spree.payment_methods << Spree::PaymentMethod::Ipay unless app.config.spree.payment_methods.include?(Spree::PaymentMethod::Ipay) end - - # Load decorators - config.to_prepare do - Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c| - Rails.configuration.cache_classes ? require(c) : load(c) + + def self.activate + Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')).sort.each do |decorator| + Rails.configuration.cache_classes ? require(decorator) : load(decorator) end end - - # Enable asset debugging in development - config.assets.debug = true if Rails.env.development? + + config.to_prepare(&method(:activate).to_proc) end end diff --git a/spree_ipay.gemspec b/spree_ipay.gemspec index 9e4c447..5d2169f 100644 --- a/spree_ipay.gemspec +++ b/spree_ipay.gemspec @@ -10,28 +10,17 @@ Gem::Specification.new do |spec| spec.homepage = 'https://github.com/yourusername/spree_ipay' spec.license = 'MIT' - spec.required_ruby_version = '>= 2.7.0' - - spec.files = Dir['{app,config,db,lib}/**/*', 'MIT-LICENSE', 'Rakefile', 'README.md'] + spec.files = Dir['{app,config,db,lib}/**/*', 'LICENSE', 'MIT-LICENSE', 'Rakefile', 'README.md'] spec.require_paths = ['lib'] - # Core dependencies - spec.required_ruby_version = '>= 3.0.0' - spec.add_dependency "rails", "~> 7.1.4" - - # Spree dependencies - spec.add_dependency 'spree', '>= 4.5.0', '< 5.0.0' - spec.add_dependency 'spree_backend', '>= 4.5.0', '< 5.0.0' - spec.add_dependency 'spree_extension', '~> 0.1.0' - spec.add_dependency 'deface', '~> 1.9.0' - - # HTTP client for API calls + spec.required_ruby_version = '>= 3.1.0' + + spec.add_dependency 'rails', '>= 7.1', '< 7.3' + spec.add_dependency 'spree', '>= 5.0', '< 6.0' spec.add_dependency 'httparty', '~> 0.16.0' - - # Security and rate limiting spec.add_dependency 'rack-attack', '~> 6.7' + spec.add_dependency 'elastic-apm', '~> 4.8.0' - # Development and test dependencies spec.add_development_dependency 'capybara', '~> 3.38' spec.add_development_dependency 'database_cleaner-active_record', '~> 2.0' spec.add_development_dependency 'factory_bot_rails', '~> 6.2.0' @@ -39,7 +28,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rspec-rails', '~> 6.0.0' spec.add_development_dependency 'sqlite3', '~> 1.4.0' - # Monitoring and logging - spec.add_dependency 'elastic-apm', '~> 4.8.0' -spec.metadata['rubygems_mfa_required'] = 'true' + spec.metadata['rubygems_mfa_required'] = 'true' end \ No newline at end of file From 764147fdbde116290822491944e087c0be914db3 Mon Sep 17 00:00:00 2001 From: kipsang Date: Sat, 28 Mar 2026 09:10:08 +0300 Subject: [PATCH 2/3] Update Ruby and Rails version requirements in gemspec, Gemfile, and .ruby-version --- .ruby-version | 2 +- Gemfile | 4 +- Gemfile.lock | 396 ++++++++++++++++++--------------------------- spree_ipay.gemspec | 7 +- 4 files changed, 166 insertions(+), 243 deletions(-) diff --git a/.ruby-version b/.ruby-version index 37d02a6..9c25013 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.8 +3.3.6 diff --git a/Gemfile b/Gemfile index 13551e7..7672fee 100644 --- a/Gemfile +++ b/Gemfile @@ -2,10 +2,10 @@ source 'https://rubygems.org' gemspec -# Ruby version requirement -ruby "~> 3.3.8" +ruby "~> 3.3.6" gem 'spree', '>= 5.0', '< 6.0' +gem 'rails', '~> 7.2.0' group :development, :test do gem 'rubocop', '~> 1.58', require: false diff --git a/Gemfile.lock b/Gemfile.lock index ee4ef33..2e9b16b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,75 +2,68 @@ PATH remote: . specs: spree_ipay (1.0.10) - deface (~> 1.9.0) elastic-apm (~> 4.8.0) httparty (~> 0.16.0) rack-attack (~> 6.7) - rails (~> 7.1.4) - spree (>= 4.5.0, < 5.0.0) - spree_backend (>= 4.5.0, < 5.0.0) - spree_extension (~> 0.1.0) + rails (~> 7.2.0) + spree (>= 5.3, < 6.0) GEM remote: https://rubygems.org/ specs: - actioncable (7.1.5.1) - actionpack (= 7.1.5.1) - activesupport (= 7.1.5.1) + actioncable (7.2.3.1) + actionpack (= 7.2.3.1) + activesupport (= 7.2.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.5.1) - actionpack (= 7.1.5.1) - activejob (= 7.1.5.1) - activerecord (= 7.1.5.1) - activestorage (= 7.1.5.1) - activesupport (= 7.1.5.1) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.5.1) - actionpack (= 7.1.5.1) - actionview (= 7.1.5.1) - activejob (= 7.1.5.1) - activesupport (= 7.1.5.1) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (7.2.3.1) + actionpack (= 7.2.3.1) + activejob (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) + mail (>= 2.8.0) + actionmailer (7.2.3.1) + actionpack (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activesupport (= 7.2.3.1) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.5.1) - actionview (= 7.1.5.1) - activesupport (= 7.1.5.1) + actionpack (7.2.3.1) + actionview (= 7.2.3.1) + activesupport (= 7.2.3.1) + cgi nokogiri (>= 1.8.5) racc - rack (>= 2.2.4) + rack (>= 2.2.4, < 3.3) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.5.1) - actionpack (= 7.1.5.1) - activerecord (= 7.1.5.1) - activestorage (= 7.1.5.1) - activesupport (= 7.1.5.1) + useragent (~> 0.16) + actiontext (7.2.3.1) + actionpack (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.5.1) - activesupport (= 7.1.5.1) + actionview (7.2.3.1) + activesupport (= 7.2.3.1) builder (~> 3.1) + cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - active_storage_validations (1.4.0) + active_storage_validations (1.3.0) activejob (>= 6.1.4) activemodel (>= 6.1.4) activestorage (>= 6.1.4) activesupport (>= 6.1.4) - marcel (>= 1.0.3) - activejob (7.1.5.1) - activesupport (= 7.1.5.1) + activejob (7.2.3.1) + activesupport (= 7.2.3.1) globalid (>= 0.3.6) activemerchant (1.137.0) activesupport (>= 4.2) @@ -78,56 +71,47 @@ GEM i18n (>= 0.6.9) nokogiri (~> 1.4) rexml (~> 3.3, >= 3.3.4) - activemodel (7.1.5.1) - activesupport (= 7.1.5.1) - activerecord (7.1.5.1) - activemodel (= 7.1.5.1) - activesupport (= 7.1.5.1) + activemodel (7.2.3.1) + activesupport (= 7.2.3.1) + activerecord (7.2.3.1) + activemodel (= 7.2.3.1) + activesupport (= 7.2.3.1) timeout (>= 0.4.0) - activerecord-typedstore (1.6.0) - activerecord (>= 6.1) - activestorage (7.1.5.1) - actionpack (= 7.1.5.1) - activejob (= 7.1.5.1) - activerecord (= 7.1.5.1) - activesupport (= 7.1.5.1) + activestorage (7.2.3.1) + actionpack (= 7.2.3.1) + activejob (= 7.2.3.1) + activerecord (= 7.2.3.1) + activesupport (= 7.2.3.1) marcel (~> 1.0) - activesupport (7.1.5.1) + activesupport (7.2.3.1) base64 benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) logger (>= 1.4.2) - minitest (>= 5.1) - mutex_m + minitest (>= 5.1, < 6) securerandom (>= 0.3) - tzinfo (~> 2.0) - acts_as_list (1.2.4) + tzinfo (~> 2.0, >= 2.0.5) + acts-as-taggable-on (13.0.0) + activerecord (>= 7.1, < 8.2) + zeitwerk (>= 2.4, < 3.0) + acts_as_list (1.2.6) activerecord (>= 6.1) activesupport (>= 6.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + any_ascii (0.3.3) + ar_lazy_preload (2.1.1) ast (2.4.3) - auto_strip_attributes (2.6.0) - activerecord (>= 4.0) - autoprefixer-rails (10.4.21.0) - execjs (~> 2) - awesome_nested_set (3.8.0) - activerecord (>= 4.0.0, < 8.1) - babel-source (5.8.35) - babel-transpiler (0.7.0) - babel-source (>= 4.0, < 6) - execjs (~> 2.0) + awesome_nested_set (3.9.0) + activerecord (>= 4.0.0, < 8.2) base64 (0.3.0) - bcrypt (3.1.20) + bcrypt (3.1.22) benchmark (0.4.1) bigdecimal (3.2.2) - bootstrap (4.6.2.1) - autoprefixer-rails (>= 9.1.0) - popper_js (>= 1.16.1, < 2) builder (3.3.0) byebug (12.0.0) cancancan (3.6.1) @@ -142,27 +126,25 @@ GEM xpath (~> 3.2) carmen (1.1.3) activesupport (>= 3.0.0) + cgi (0.5.1) coderay (1.1.3) concurrent-ruby (1.3.5) connection_pool (2.5.3) + countries (8.1.0) + unaccent (~> 0.3) crack (1.0.0) bigdecimal rexml crass (1.0.6) + csv (3.3.5) database_cleaner-active_record (2.2.1) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) date (3.4.1) - deface (1.9.0) - actionview (>= 5.2) - nokogiri (>= 1.6) - polyglot - railties (>= 5.2) - rainbow (>= 2.1.0) diff-lcs (1.6.2) domain_name (0.6.20240107) - doorkeeper (5.8.2) + doorkeeper (5.9.0) railties (>= 5) drb (2.2.3) elastic-apm (4.8.0) @@ -171,7 +153,6 @@ GEM ruby2_keywords erb (5.0.1) erubi (1.13.1) - execjs (2.10.0) factory_bot (6.2.1) activesupport (>= 5.0.0) factory_bot_rails (6.2.0) @@ -189,17 +170,16 @@ GEM ffi-compiler (1.3.2) ffi (>= 1.15.5) rake - flag-icons-rails (3.4.6.1) - sass-rails - flatpickr (4.6.13.1) - friendly_id (5.4.2) + friendly_id (5.5.1) activerecord (>= 4.0.0) - friendly_id-mobility (1.0.4) - friendly_id (>= 5.0.0, < 5.5) + friendly_id-mobility (1.0.5) + friendly_id (>= 5.0.0, < 5.6) mobility (>= 1.0.1, < 2.0) - globalid (1.2.1) + geocoder (1.8.6) + base64 (>= 0.1.0) + csv (>= 3.0.0) + globalid (1.3.0) activesupport (>= 6.1) - glyphicons (1.0.2) hashdiff (1.2.0) highline (3.1.2) reline @@ -219,39 +199,14 @@ GEM image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) - importmap-rails (2.1.0) - actionpack (>= 6.0.0) - activesupport (>= 6.0.0) - railties (>= 6.0.0) - inline_svg (1.10.0) - activesupport (>= 3.0) - nokogiri (>= 1.6) io-console (0.8.0) irb (1.15.2) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jquery-rails (4.6.0) - rails-dom-testing (>= 1, < 3) - railties (>= 4.2.0) - thor (>= 0.14, < 2.0) - jquery-ui-rails (7.0.0) - railties (>= 3.2.16) json (2.12.2) jsonapi-serializer (2.2.0) activesupport (>= 4.2) - kaminari (1.2.2) - activesupport (>= 4.1.0) - kaminari-actionview (= 1.2.2) - kaminari-activerecord (= 1.2.2) - kaminari-core (= 1.2.2) - kaminari-actionview (1.2.2) - actionview - kaminari-core (= 1.2.2) - kaminari-activerecord (1.2.2) - activerecord - kaminari-core (= 1.2.2) - kaminari-core (1.2.2) language_server-protocol (3.17.0.5) lint_roller (1.1.0) llhttp-ffi (0.5.1) @@ -261,25 +216,29 @@ GEM loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) matrix (0.4.3) method_source (1.1.0) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (3.2025.0708) - mini_magick (5.3.0) + mini_magick (5.3.1) logger mini_mime (1.1.5) minitest (5.25.5) mobility (1.3.2) i18n (>= 0.6.10, < 2) request_store (~> 1.0) + mobility-actiontext (1.1.1) + actiontext (>= 6.0) + mobility (~> 1.2) mobility-ransack (1.2.2) mobility (>= 1.0.1, < 2.0) ransack (>= 1.8.0, < 5.0) @@ -289,8 +248,9 @@ GEM i18n (>= 0.6.4, <= 2) multi_xml (0.7.2) bigdecimal (~> 3.1) - mutex_m (0.3.0) - net-imap (0.5.9) + name_of_person (1.1.3) + activesupport (>= 5.2.0) + net-imap (0.6.3) date net-protocol net-pop (0.1.2) @@ -299,7 +259,7 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) + nio4r (2.7.5) nokogiri (1.18.8-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.8-aarch64-linux-musl) @@ -316,14 +276,18 @@ GEM racc (~> 1.4) nokogiri (1.18.8-x86_64-linux-musl) racc (~> 1.4) + ostruct (0.6.3) + pagy (43.4.4) + json + uri + yaml parallel (1.27.0) - paranoia (3.0.1) - activerecord (>= 6, < 8.1) + paranoia (3.1.0) + activerecord (>= 7, < 8.2) parser (3.3.8.0) ast (~> 2.4.1) racc - polyglot (0.3.5) - popper_js (1.16.1) + phonelib (0.10.17) pp (0.6.2) prettyprint prettyprint (0.2.0) @@ -349,20 +313,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (7.1.5.1) - actioncable (= 7.1.5.1) - actionmailbox (= 7.1.5.1) - actionmailer (= 7.1.5.1) - actionpack (= 7.1.5.1) - actiontext (= 7.1.5.1) - actionview (= 7.1.5.1) - activejob (= 7.1.5.1) - activemodel (= 7.1.5.1) - activerecord (= 7.1.5.1) - activestorage (= 7.1.5.1) - activesupport (= 7.1.5.1) + rails (7.2.3.1) + actioncable (= 7.2.3.1) + actionmailbox (= 7.2.3.1) + actionmailer (= 7.2.3.1) + actionpack (= 7.2.3.1) + actiontext (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activemodel (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) bundler (>= 1.15.0) - railties (= 7.1.5.1) + railties (= 7.2.3.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -370,19 +334,21 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (7.1.5.1) - actionpack (= 7.1.5.1) - activesupport (= 7.1.5.1) - irb + railties (7.2.3.1) + actionpack (= 7.2.3.1) + activesupport (= 7.2.3.1) + cgi + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) - ransack (4.3.0) - activerecord (>= 6.1.5) - activesupport (>= 6.1.5) + ransack (4.4.1) + activerecord (>= 7.2) + activesupport (>= 7.2) i18n rdoc (6.14.2) erb @@ -392,11 +358,6 @@ GEM io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) - requestjs-rails (0.0.13) - railties (>= 7.1.0) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) rexml (3.4.1) rspec (3.13.1) rspec-core (~> 3.13.0) @@ -456,129 +417,93 @@ GEM rubocop-rspec_rails (~> 2.28) rubocop-rspec_rails (2.29.1) rubocop (~> 1.61) + ruby-oembed (0.18.1) ruby-progressbar (1.13.0) - ruby-vips (2.2.4) + ruby-vips (2.3.0) ffi (~> 1.12) logger ruby2_keywords (0.0.5) - sass-rails (6.0.0) - sassc-rails (~> 2.1, >= 2.1.1) - sassc (2.4.0) - ffi (~> 1.9) - sassc-rails (2.1.2) - railties (>= 4.0.0) - sassc (>= 2.0) - sprockets (> 3.0) - sprockets-rails - tilt + safely_block (0.5.0) securerandom (0.4.1) - select2-rails (4.0.13) - spree (4.10.1) - spree_api (= 4.10.1) - spree_cli (= 4.10.1) - spree_core (= 4.10.1) - spree_api (4.10.1) + spree (5.3.5) + spree_api (= 5.3.5) + spree_cli (= 5.3.5) + spree_core (= 5.3.5) + spree_api (5.3.5) bcrypt (~> 3.1) doorkeeper (~> 5.3) jsonapi-serializer (~> 2.1) - spree_core (= 4.10.1) - spree_backend (4.8.4) - babel-transpiler (~> 0.7) - bootstrap (~> 4.0) - flag-icons-rails (~> 3.4) - flatpickr (~> 4.6) - glyphicons (~> 1.0) - importmap-rails - inline_svg (~> 1.5) - jquery-rails (~> 4.3) - jquery-ui-rails (>= 6, < 8) - requestjs-rails - responders - sass-rails (>= 5) - select2-rails (~> 4.0.6) - spree (>= 4.7.0) - sprockets (~> 4.0) - stimulus-rails - tinymce-rails (~> 5.0) - turbo-rails - spree_cli (4.10.1) + pagy (~> 43.0) + spree_core (= 5.3.5) + spree_cli (5.3.5) thor (~> 1.0) - spree_core (4.10.1) - actionpack (>= 6.1, < 8.0) - actionview (>= 6.1, < 8.0) - active_storage_validations (~> 1.1) - activejob (>= 6.1, < 8.0) + spree_core (5.3.5) + active_storage_validations (= 1.3.0) activemerchant (~> 1.67) - activemodel (>= 6.1, < 8.0) - activerecord (>= 6.1, < 8.0) - activerecord-typedstore - activestorage (>= 6.1, < 8.0) - activesupport (>= 6.1, < 8.0) + acts-as-taggable-on acts_as_list (>= 0.8) - auto_strip_attributes (~> 2.6) + any_ascii (~> 0.3.2) + ar_lazy_preload (~> 2.0) awesome_nested_set (~> 3.3, >= 3.3.1) + benchmark cancancan (~> 3.2) carmen (>= 1.0) + countries friendly_id (~> 5.2, >= 5.2.1) friendly_id-mobility (~> 1.0) + geocoder highline (>= 2, < 4) image_processing (~> 1.2) - kaminari (~> 1.2) - mobility (~> 1.2) + mobility (~> 1.3, >= 1.3.2) + mobility-actiontext (~> 1.1) mobility-ransack (~> 1.2) monetize (~> 1.9) money (~> 6.13) + name_of_person (~> 1.1) + ostruct paranoia (>= 2.4) - railties (>= 6.1, < 8.0) + phonelib (~> 0.10) + rails (>= 7.2, < 8.2) ransack (>= 4.1) request_store (~> 1.7) rexml - state_machines-activemodel (~> 0.7) - state_machines-activerecord (~> 0.6) + ruby-oembed (~> 0.18) + safely_block (~> 0.4) + ssrf_filter (~> 1.0) + state_machines-activemodel (~> 0.100) + state_machines-activerecord (~> 0.100) stringex tracking_number validates_zipcode - spree_extension (0.1.0) - activerecord (>= 4.2) - spree_core - sprockets (4.2.2) - concurrent-ruby (~> 1.0) - logger - rack (>= 2.2.4, < 4) - sprockets-rails (3.5.2) - actionpack (>= 6.1) - activesupport (>= 6.1) - sprockets (>= 3.0.0) + wannabe_bool sqlite3 (1.4.4) - state_machines (0.50.0) - state_machines-activemodel (0.31.0) - activemodel (>= 7.1) - state_machines (>= 0.31.0) - state_machines-activerecord (0.31.0) - activerecord (>= 7.1) - state_machines-activemodel (>= 0.10.0) - stimulus-rails (1.3.4) - railties (>= 6.0.0) + ssrf_filter (1.3.0) + state_machines (0.101.0) + state_machines-activemodel (0.102.0) + activemodel (>= 7.2) + state_machines (>= 0.101.0) + state_machines-activerecord (0.103.0) + activerecord (>= 7.2) + state_machines-activemodel (>= 0.102.0) stringex (2.8.6) stringio (3.1.7) thor (1.3.2) - tilt (2.6.1) timeout (0.4.3) - tinymce-rails (5.10.9) - railties (>= 3.1.1) - tracking_number (2.2.0) + tracking_number (2.4.0) activesupport (>= 4.2.5) json (>= 1.8.3) - turbo-rails (2.0.16) - actionpack (>= 7.1.0) - railties (>= 7.1.0) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + unaccent (0.4.0) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) - validates_zipcode (0.5.4) + uri (1.1.1) + useragent (0.16.11) + validates_zipcode (0.6.0) activemodel (>= 4.2.0) + wannabe_bool (0.7.1) webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -589,6 +514,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) + yaml (0.4.0) zeitwerk (2.7.3) PLATFORMS @@ -604,21 +530,19 @@ PLATFORMS DEPENDENCIES capybara (~> 3.38) database_cleaner-active_record (~> 2.0) - deface (~> 1.9.0) elastic-apm (~> 4.8.0) factory_bot_rails (~> 6.2.0) ffaker (~> 2.23) pry (~> 0.14.1) pry-byebug (~> 3.10) + rails (~> 7.2.0) rspec (~> 3.10) rspec-rails (~> 6.0.0) rubocop (~> 1.58) rubocop-performance (~> 1.19) rubocop-rails (~> 2.20) rubocop-rspec (~> 2.25) - spree (>= 4.5.0, < 5.0.0) - spree_backend (>= 4.5.0, < 5.0.0) - spree_extension (~> 0.1.0) + spree (>= 5.3, < 6.0) spree_ipay! sqlite3 (~> 1.4.0) webmock (~> 3.18) diff --git a/spree_ipay.gemspec b/spree_ipay.gemspec index 5d2169f..2a2b8fc 100644 --- a/spree_ipay.gemspec +++ b/spree_ipay.gemspec @@ -13,10 +13,10 @@ Gem::Specification.new do |spec| spec.files = Dir['{app,config,db,lib}/**/*', 'LICENSE', 'MIT-LICENSE', 'Rakefile', 'README.md'] spec.require_paths = ['lib'] - spec.required_ruby_version = '>= 3.1.0' - - spec.add_dependency 'rails', '>= 7.1', '< 7.3' spec.add_dependency 'spree', '>= 5.0', '< 6.0' + spec.required_ruby_version = '~> 3.3.6' + + spec.add_dependency 'rails', '~> 7.2.0' spec.add_dependency 'httparty', '~> 0.16.0' spec.add_dependency 'rack-attack', '~> 6.7' spec.add_dependency 'elastic-apm', '~> 4.8.0' @@ -27,6 +27,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'pry', '~> 0.14.1' spec.add_development_dependency 'rspec-rails', '~> 6.0.0' spec.add_development_dependency 'sqlite3', '~> 1.4.0' - spec.metadata['rubygems_mfa_required'] = 'true' end \ No newline at end of file From 6291fba6df68f2c3dfb436a468ef67470edc1d4b Mon Sep 17 00:00:00 2001 From: kipsang Date: Sat, 28 Mar 2026 09:12:24 +0300 Subject: [PATCH 3/3] Update spree version constraints in Gemfile.lock to allow compatibility with earlier versions --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2e9b16b..74c38c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,7 +6,7 @@ PATH httparty (~> 0.16.0) rack-attack (~> 6.7) rails (~> 7.2.0) - spree (>= 5.3, < 6.0) + spree (>= 5.0, < 6.0) GEM remote: https://rubygems.org/ @@ -542,7 +542,7 @@ DEPENDENCIES rubocop-performance (~> 1.19) rubocop-rails (~> 2.20) rubocop-rspec (~> 2.25) - spree (>= 5.3, < 6.0) + spree (>= 5.0, < 6.0) spree_ipay! sqlite3 (~> 1.4.0) webmock (~> 3.18)