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 af4ffbe..7672fee 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,16 +1,11 @@
source 'https://rubygems.org'
-# Use local gemspec
gemspec
-# Ruby version requirement
-ruby "~> 3.3.8"
+ruby "~> 3.3.6"
-# 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'
+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..74c38c1 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.0, < 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.0, < 6.0)
spree_ipay!
sqlite3 (~> 1.4.0)
webmock (~> 3.18)
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
-
-
-
-
-
-
-
-
Please wait while we securely redirect you to the payment page.
-
If you are not redirected automatically, please click the button below.
-
-
-
-
-
- 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
+
+
+ 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"
- 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 = ""
- 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]' %>
+
+
\ 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..2a2b8fc 100644
--- a/spree_ipay.gemspec
+++ b/spree_ipay.gemspec
@@ -10,36 +10,22 @@ 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.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'
-
- # 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'
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'
-
- # 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