diff --git a/.rubocop.yml b/.rubocop.yml
index 1c755ec..97f1752 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -18,6 +18,10 @@ Style/Documentation:
Metrics/ParameterLists:
Max: 8
+Lint/MissingSuper:
+ Exclude:
+ - app/services/**/*
+
Metrics/BlockLength:
Exclude:
- spec/**/*
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index f600565..6e462d5 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -1,23 +1,44 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
-# on 2026-01-20 23:38:03 UTC using RuboCop version 1.81.7.
+# on 2026-01-27 12:23:34 UTC using RuboCop version 1.84.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Offense count: 2
+# This cop supports safe autocorrection (--autocorrect).
+Lint/RedundantCopDisableDirective:
+ Exclude:
+ - 'app/jobs/update_sparta_state_job.rb'
+ - 'app/services/spl/sparta_loyalty_service.rb'
+
+# Offense count: 3
+# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
+Metrics/AbcSize:
+ Max: 18
+
+# Offense count: 2
+# Configuration parameters: AllowedMethods, AllowedPatterns.
+Metrics/CyclomaticComplexity:
+ Max: 8
+
+# Offense count: 13
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
- Max: 11
+ Max: 14
# Offense count: 1
-# Configuration parameters: Mode, AllowedMethods, AllowedPatterns, AllowBangMethods, WaywardPredicates.
-# AllowedMethods: call
-# WaywardPredicates: nonzero?
-Naming/PredicateMethod:
+# Configuration parameters: CountComments, CountAsOne.
+Metrics/ModuleLength:
+ Max: 156
+
+# Offense count: 2
+# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
+# AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
+Naming/MethodParameterName:
Exclude:
- - 'app/services/assign_sparta_card_number_service.rb'
+ - 'app/services/spl/error_payload_parser.rb'
# Offense count: 1
# This cop supports unsafe autocorrection (--autocorrect-all).
@@ -39,12 +60,20 @@ Rails/SkipsModelValidations:
- 'app/models/spree/adjustable/adjustments_updater_decorator.rb'
- 'app/services/remove_sparta_discount_service.rb'
-# Offense count: 1
+# Offense count: 2
# This cop supports unsafe autocorrection (--autocorrect-all).
Rails/StrongParametersExpect:
Exclude:
- 'app/controllers/spl/spree/storefront/profile_controller_decorator.rb'
+# Offense count: 1
+# This cop supports unsafe autocorrection (--autocorrect-all).
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: exists, where
+Rails/WhereExists:
+ Exclude:
+ - 'app/services/assign_sparta_card_number_service.rb'
+
# Offense count: 2
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
@@ -52,3 +81,20 @@ Style/GuardClause:
Exclude:
- 'app/controllers/spl/spree/admin/payments_controller_decorator.rb'
- 'app/controllers/spl/spree/storefront/checkout_controller_decorator.rb'
+
+# Offense count: 8
+# This cop supports safe autocorrection (--autocorrect).
+Style/IfUnlessModifier:
+ Exclude:
+ - 'app/controllers/spl/spree/admin/payments_controller_decorator.rb'
+ - 'app/controllers/spl/spree/storefront/checkout_controller_decorator.rb'
+ - 'app/models/spree/promotion/rules/user_from_club.rb'
+ - 'app/services/assign_sparta_card_number_service.rb'
+ - 'app/services/spl/validate_card_service.rb'
+
+# Offense count: 5
+# This cop supports safe autocorrection (--autocorrect).
+# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
+# URISchemes: http, https
+Layout/LineLength:
+ Max: 181
diff --git a/Gemfile.lock b/Gemfile.lock
index a9c29e4..236cfd7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -10,8 +10,8 @@ PATH
rspec-rails
rspec-retry
rubocop-rspec
- spree (~> 5.1, >= 5.1.4)
- spree_api (~> 5.1, >= 5.1.4)
+ spree (~> 5.2, >= 5.2.4)
+ spree_api (~> 5.2, >= 5.2.4)
spree_auth_devise
spree_dev_tools
@@ -19,29 +19,31 @@ GEM
remote: https://rubygems.org/
specs:
abbrev (0.1.2)
- actioncable (8.0.4)
- actionpack (= 8.0.4)
- activesupport (= 8.0.4)
+ action_text-trix (2.1.16)
+ railties
+ actioncable (8.1.2)
+ actionpack (= 8.1.2)
+ activesupport (= 8.1.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (8.0.4)
- actionpack (= 8.0.4)
- activejob (= 8.0.4)
- activerecord (= 8.0.4)
- activestorage (= 8.0.4)
- activesupport (= 8.0.4)
+ actionmailbox (8.1.2)
+ actionpack (= 8.1.2)
+ activejob (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
mail (>= 2.8.0)
- actionmailer (8.0.4)
- actionpack (= 8.0.4)
- actionview (= 8.0.4)
- activejob (= 8.0.4)
- activesupport (= 8.0.4)
+ actionmailer (8.1.2)
+ actionpack (= 8.1.2)
+ actionview (= 8.1.2)
+ activejob (= 8.1.2)
+ activesupport (= 8.1.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
- actionpack (8.0.4)
- actionview (= 8.0.4)
- activesupport (= 8.0.4)
+ actionpack (8.1.2)
+ actionview (= 8.1.2)
+ activesupport (= 8.1.2)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -49,15 +51,16 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
- actiontext (8.0.4)
- actionpack (= 8.0.4)
- activerecord (= 8.0.4)
- activestorage (= 8.0.4)
- activesupport (= 8.0.4)
+ actiontext (8.1.2)
+ action_text-trix (~> 2.1.15)
+ actionpack (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (8.0.4)
- activesupport (= 8.0.4)
+ actionview (8.1.2)
+ activesupport (= 8.1.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
@@ -70,8 +73,8 @@ GEM
activemodel (>= 6.1.4)
activestorage (>= 6.1.4)
activesupport (>= 6.1.4)
- activejob (8.0.4)
- activesupport (= 8.0.4)
+ activejob (8.1.2)
+ activesupport (= 8.1.2)
globalid (>= 0.3.6)
activemerchant (1.137.0)
activesupport (>= 4.2)
@@ -79,26 +82,26 @@ GEM
i18n (>= 0.6.9)
nokogiri (~> 1.4)
rexml (~> 3.3, >= 3.3.4)
- activemodel (8.0.4)
- activesupport (= 8.0.4)
- activerecord (8.0.4)
- activemodel (= 8.0.4)
- activesupport (= 8.0.4)
+ activemodel (8.1.2)
+ activesupport (= 8.1.2)
+ activerecord (8.1.2)
+ activemodel (= 8.1.2)
+ activesupport (= 8.1.2)
timeout (>= 0.4.0)
- activestorage (8.0.4)
- actionpack (= 8.0.4)
- activejob (= 8.0.4)
- activerecord (= 8.0.4)
- activesupport (= 8.0.4)
+ activestorage (8.1.2)
+ actionpack (= 8.1.2)
+ activejob (= 8.1.2)
+ activerecord (= 8.1.2)
+ activesupport (= 8.1.2)
marcel (~> 1.0)
- activesupport (8.0.4)
+ activesupport (8.1.2)
base64
- benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
+ json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
@@ -113,21 +116,22 @@ GEM
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
any_ascii (0.3.3)
+ ar_lazy_preload (2.1.0)
ast (2.4.3)
- async (2.35.0)
+ async (2.36.0)
console (~> 1.29)
fiber-annotation
io-event (~> 1.11)
metrics (~> 0.12)
traces (~> 0.18)
- async-http (0.92.1)
+ async-http (0.94.2)
async (>= 2.10.2)
async-pool (~> 0.11)
io-endpoint (~> 0.14)
io-stream (~> 0.6)
metrics (~> 0.12)
- protocol-http (~> 0.49)
- protocol-http1 (~> 0.30)
+ protocol-http (~> 0.58)
+ protocol-http1 (~> 0.36)
protocol-http2 (~> 0.22)
protocol-url (~> 0.2)
traces (~> 0.10)
@@ -136,29 +140,20 @@ GEM
faraday
async-pool (0.11.1)
async (>= 2.0)
- 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)
+ awesome_nested_set (3.9.0)
+ activerecord (>= 4.0.0, < 8.2)
awesome_print (1.9.2)
base64 (0.3.0)
- bcrypt (3.1.20)
- benchmark (0.5.0)
- bigdecimal (3.3.1)
- bootstrap (4.6.2.1)
- autoprefixer-rails (>= 9.1.0)
- popper_js (>= 1.16.1, < 2)
- brakeman (7.1.1)
+ bcrypt (3.1.21)
+ bigdecimal (4.0.1)
+ brakeman (7.1.2)
racc
breadcrumbs_on_rails (4.1.0)
railties (>= 5.0)
builder (3.3.0)
- byebug (12.0.0)
+ byebug (13.0.0)
+ reline (>= 0.6.0)
cancancan (3.6.1)
- canonical-rails (0.2.17)
- actionview (>= 4.1, < 8.1)
capybara (3.40.0)
addressable
matrix
@@ -177,29 +172,26 @@ GEM
childprocess (5.1.0)
logger (~> 1.5)
coderay (1.1.3)
- concurrent-ruby (1.3.5)
+ concurrent-ruby (1.3.6)
connection_pool (3.0.2)
console (1.34.2)
fiber-annotation
fiber-local (~> 1.1)
json
- countries (8.0.4)
+ countries (8.1.0)
unaccent (~> 0.3)
- crack (1.0.0)
+ crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
csv (3.3.5)
- dartsass-rails (0.5.1)
- railties (>= 6.0.0)
- sass-embedded (~> 1.63)
database_cleaner (2.1.0)
database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.2.2)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
- date (3.5.0)
+ date (3.5.1)
deface (1.9.0)
actionview (>= 5.2)
nokogiri (>= 1.6)
@@ -220,9 +212,8 @@ GEM
railties (>= 5)
dotenv (3.2.0)
drb (2.2.3)
- erb (6.0.0)
+ erb (6.0.1)
erubi (1.13.1)
- execjs (2.10.0)
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.1)
@@ -232,27 +223,27 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
- faraday-http-cache (2.5.1)
+ faraday-http-cache (2.6.1)
faraday (>= 0.8)
faraday-net_http (3.4.2)
net-http (~> 0.5)
ffaker (2.25.0)
- ffi (1.17.2-aarch64-linux-gnu)
- ffi (1.17.2-aarch64-linux-musl)
- ffi (1.17.2-arm-linux-gnu)
- ffi (1.17.2-arm-linux-musl)
- ffi (1.17.2-arm64-darwin)
- ffi (1.17.2-x86_64-darwin)
- ffi (1.17.2-x86_64-linux-gnu)
- ffi (1.17.2-x86_64-linux-musl)
+ ffi (1.17.3-aarch64-linux-gnu)
+ ffi (1.17.3-aarch64-linux-musl)
+ ffi (1.17.3-arm-linux-gnu)
+ ffi (1.17.3-arm-linux-musl)
+ ffi (1.17.3-arm64-darwin)
+ ffi (1.17.3-x86_64-darwin)
+ ffi (1.17.3-x86_64-linux-gnu)
+ ffi (1.17.3-x86_64-linux-musl)
fiber-annotation (0.2.0)
fiber-local (1.1.0)
fiber-storage
fiber-storage (1.0.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)
gem-release (2.2.4)
geocoder (1.8.6)
@@ -269,37 +260,16 @@ GEM
rake (>= 10.0)
globalid (1.3.0)
activesupport (>= 6.1)
- google-protobuf (4.33.4)
- bigdecimal
- rake (>= 13)
- google-protobuf (4.33.4-aarch64-linux-gnu)
- bigdecimal
- rake (>= 13)
- google-protobuf (4.33.4-aarch64-linux-musl)
- bigdecimal
- rake (>= 13)
- google-protobuf (4.33.4-arm64-darwin)
- bigdecimal
- rake (>= 13)
- google-protobuf (4.33.4-x86_64-darwin)
- bigdecimal
- rake (>= 13)
- google-protobuf (4.33.4-x86_64-linux-gnu)
- bigdecimal
- rake (>= 13)
- google-protobuf (4.33.4-x86_64-linux-musl)
- bigdecimal
- rake (>= 13)
groupdate (6.7.0)
activesupport (>= 7.1)
- hashdiff (1.1.2)
+ hashdiff (1.2.1)
heroicon (1.0.0)
rails (>= 5.2)
highline (3.1.2)
reline
hightop (0.6.0)
activesupport (>= 7.1)
- i18n (1.14.7)
+ i18n (1.14.8)
concurrent-ruby (~> 1.0)
i18n-tasks (1.1.2)
activesupport (>= 4.0.2)
@@ -316,39 +286,27 @@ GEM
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
- importmap-rails (2.2.2)
+ importmap-rails (2.2.3)
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.1)
- io-endpoint (0.16.0)
+ io-console (0.8.2)
+ io-endpoint (0.17.1)
io-event (1.14.2)
io-stream (0.11.1)
- irb (1.15.3)
+ irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
- json (2.17.1)
+ json (2.18.0)
jsonapi-rspec (0.0.11)
rspec-core
rspec-expectations
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)
launchy (3.1.1)
addressable (~> 2.8)
@@ -357,7 +315,7 @@ GEM
lint_roller (1.1.0)
local_time (3.0.3)
logger (1.7.0)
- loofah (2.24.1)
+ loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
@@ -377,7 +335,8 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
- minitest (5.26.2)
+ minitest (6.0.1)
+ prism (~> 1.5)
mobility (1.3.2)
i18n (>= 0.6.10, < 2)
request_store (~> 1.0)
@@ -391,72 +350,76 @@ GEM
money (~> 6.12)
money (6.19.0)
i18n (>= 0.6.4, <= 2)
- multi_json (1.18.0)
+ multi_json (1.19.1)
name_of_person (1.1.3)
activesupport (>= 5.2.0)
- net-http (0.8.0)
+ net-http (0.9.1)
uri (>= 0.11.1)
- net-imap (0.5.12)
+ net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
+ net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
- nokogiri (1.18.10-aarch64-linux-gnu)
+ nokogiri (1.19.0-aarch64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.18.10-aarch64-linux-musl)
+ nokogiri (1.19.0-aarch64-linux-musl)
racc (~> 1.4)
- nokogiri (1.18.10-arm-linux-gnu)
+ nokogiri (1.19.0-arm-linux-gnu)
racc (~> 1.4)
- nokogiri (1.18.10-arm-linux-musl)
+ nokogiri (1.19.0-arm-linux-musl)
racc (~> 1.4)
- nokogiri (1.18.10-arm64-darwin)
+ nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.18.10-x86_64-darwin)
+ nokogiri (1.19.0-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.18.10-x86_64-linux-gnu)
+ nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.18.10-x86_64-linux-musl)
+ nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
observer (0.1.2)
octokit (4.25.1)
faraday (>= 1, < 3)
sawyer (~> 0.9)
- openssl (3.3.0)
+ openssl (4.0.0)
orm_adapter (0.5.0)
ostruct (0.6.3)
+ pagy (43.2.8)
+ json
+ yaml
parallel (1.27.0)
paranoia (3.1.0)
activerecord (>= 7, < 8.2)
- parser (3.3.10.0)
+ parser (3.3.10.1)
ast (~> 2.4.1)
racc
- phonelib (0.10.14)
+ phonelib (0.10.15)
polyglot (0.3.5)
- popper_js (1.16.1)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
- prism (1.6.0)
+ prism (1.8.0)
protocol-hpack (1.5.1)
- protocol-http (0.55.0)
- protocol-http1 (0.35.2)
- protocol-http (~> 0.22)
- protocol-http2 (0.23.0)
+ protocol-http (0.58.1)
+ protocol-http1 (0.37.0)
+ protocol-http (~> 0.58)
+ protocol-http2 (0.24.0)
protocol-hpack (~> 1.4)
protocol-http (~> 0.47)
protocol-url (0.4.0)
- pry (0.15.2)
+ pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
- psych (5.2.6)
+ reline (>= 0.6.0)
+ psych (5.3.1)
date
stringio
- public_suffix (7.0.0)
- puma (7.1.0)
+ public_suffix (7.0.2)
+ puma (7.2.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.4)
@@ -467,20 +430,20 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
- rails (8.0.4)
- actioncable (= 8.0.4)
- actionmailbox (= 8.0.4)
- actionmailer (= 8.0.4)
- actionpack (= 8.0.4)
- actiontext (= 8.0.4)
- actionview (= 8.0.4)
- activejob (= 8.0.4)
- activemodel (= 8.0.4)
- activerecord (= 8.0.4)
- activestorage (= 8.0.4)
- activesupport (= 8.0.4)
+ rails (8.1.2)
+ actioncable (= 8.1.2)
+ actionmailbox (= 8.1.2)
+ actionmailer (= 8.1.2)
+ actionpack (= 8.1.2)
+ actiontext (= 8.1.2)
+ actionview (= 8.1.2)
+ activejob (= 8.1.2)
+ activemodel (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
bundler (>= 1.15.0)
- railties (= 8.0.4)
+ railties (= 8.1.2)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
@@ -495,9 +458,9 @@ GEM
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
- railties (8.0.4)
- actionpack (= 8.0.4)
- activesupport (= 8.0.4)
+ railties (8.1.2)
+ actionpack (= 8.1.2)
+ activesupport (= 8.1.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -510,9 +473,9 @@ GEM
activerecord (>= 7.2)
activesupport (>= 7.2)
i18n
- rbs (3.9.5)
+ rbs (3.10.2)
logger
- rdoc (6.17.0)
+ rdoc (7.1.0)
erb
psych (>= 4.0.0)
tsort
@@ -550,7 +513,7 @@ GEM
rspec-support (3.13.6)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
- rubocop (1.81.7)
+ rubocop (1.84.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -558,17 +521,17 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
- rubocop-ast (>= 1.47.1, < 2.0)
+ rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
- rubocop-ast (1.48.0)
+ rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
- prism (~> 1.4)
+ prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
- rubocop-rails (2.34.2)
+ rubocop-rails (2.34.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -578,36 +541,20 @@ GEM
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
- rubocop-rspec (3.8.0)
+ rubocop-rspec (3.9.0)
lint_roller (~> 1.1)
rubocop (~> 1.81)
- ruby-lsp (0.26.4)
+ ruby-lsp (0.26.5)
language_server-protocol (~> 3.17.0)
prism (>= 1.2, < 2.0)
rbs (>= 3, < 5)
ruby-oembed (0.18.1)
ruby-progressbar (1.13.0)
- ruby-vips (2.2.5)
+ ruby-vips (2.3.0)
ffi (~> 1.12)
logger
rubyzip (2.4.1)
safely_block (0.5.0)
- sass-embedded (1.97.2-aarch64-linux-gnu)
- google-protobuf (~> 4.31)
- sass-embedded (1.97.2-aarch64-linux-musl)
- google-protobuf (~> 4.31)
- sass-embedded (1.97.2-arm-linux-gnueabihf)
- google-protobuf (~> 4.31)
- sass-embedded (1.97.2-arm-linux-musleabihf)
- google-protobuf (~> 4.31)
- sass-embedded (1.97.2-arm64-darwin)
- google-protobuf (~> 4.31)
- sass-embedded (1.97.2-x86_64-darwin)
- google-protobuf (~> 4.31)
- sass-embedded (1.97.2-x86_64-linux-gnu)
- google-protobuf (~> 4.31)
- sass-embedded (1.97.2-x86_64-linux-musl)
- google-protobuf (~> 4.31)
sawyer (0.9.3)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
@@ -622,46 +569,47 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
- spree (5.2.5)
- spree_api (= 5.2.5)
- spree_cli (= 5.2.5)
- spree_core (= 5.2.5)
- spree_admin (5.2.5)
+ spree (5.3.0)
+ spree_api (= 5.3.0)
+ spree_cli (= 5.3.0)
+ spree_core (= 5.3.0)
+ spree_admin (5.3.0)
active_link_to
- bootstrap (~> 4.6, >= 4.6.2.1)
breadcrumbs_on_rails (~> 4.1)
chartkick (~> 5.0)
- dartsass-rails (~> 0.5)
groupdate (~> 6.2)
hightop (~> 0.3)
importmap-rails
inline_svg (~> 1.10)
local_time (~> 3.0)
mapkick-rb (~> 0.1)
- spree_api (>= 5.2.5)
- spree_core (>= 5.2.5)
+ pagy (~> 43.0)
+ spree (>= 5.3.0)
stimulus-rails
+ tailwindcss-rails (>= 4.0)
+ tailwindcss-ruby (>= 4.0)
tinymce-rails (~> 6.8.5)
turbo-rails
- spree_api (5.2.5)
+ spree_api (5.3.0)
bcrypt (~> 3.1)
doorkeeper (~> 5.3)
jsonapi-serializer (~> 2.1)
- spree_core (= 5.2.5)
+ pagy (~> 43.0)
+ spree_core (= 5.3.0)
spree_auth_devise (4.6.3)
devise (~> 4.7)
devise-encryptable (= 0.2.0)
spree_core (>= 4.5.0)
spree_extension
- spree_cli (5.2.5)
+ spree_cli (5.3.0)
thor (~> 1.0)
- spree_core (5.2.5)
+ spree_core (5.3.0)
active_storage_validations (= 1.3.0)
activemerchant (~> 1.67)
acts-as-taggable-on
acts_as_list (>= 0.8)
any_ascii (~> 0.3.2)
- auto_strip_attributes (~> 2.6)
+ ar_lazy_preload (~> 2.0)
awesome_nested_set (~> 3.3, >= 3.3.1)
cancancan (~> 3.2)
carmen (>= 1.0)
@@ -671,7 +619,6 @@ GEM
geocoder
highline (>= 2, < 4)
image_processing (~> 1.2)
- kaminari (~> 1.2)
mobility (~> 1.3, >= 1.3.2)
mobility-actiontext (~> 1.1)
mobility-ransack (~> 1.2)
@@ -681,7 +628,7 @@ GEM
ostruct
paranoia (>= 2.4)
phonelib (~> 0.10)
- rails (>= 7.2, < 8.1)
+ rails (>= 7.2, < 8.2)
ransack (>= 4.1)
request_store (~> 1.7)
rexml
@@ -723,15 +670,19 @@ GEM
spree_extension (0.1.0)
activerecord (>= 4.2)
spree_core
- spree_storefront (5.2.5)
+ spree_page_builder (5.3.0)
+ spree (>= 5.3.0)
+ spree_admin (>= 5.3.0)
+ spree_storefront (5.3.0)
active_link_to
- canonical-rails (~> 0.2.14)
heroicon
importmap-rails
inline_svg (~> 1.10)
local_time (~> 3.0)
mail_form
- spree_core (>= 5.2.5)
+ pagy (~> 43.0)
+ spree (>= 5.3.0)
+ spree_page_builder (>= 5.3.0)
stimulus-rails
tailwindcss-rails
tailwindcss-ruby
@@ -744,14 +695,14 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
- sqlite3 (2.8.1-aarch64-linux-gnu)
- sqlite3 (2.8.1-aarch64-linux-musl)
- sqlite3 (2.8.1-arm-linux-gnu)
- sqlite3 (2.8.1-arm-linux-musl)
- sqlite3 (2.8.1-arm64-darwin)
- sqlite3 (2.8.1-x86_64-darwin)
- sqlite3 (2.8.1-x86_64-linux-gnu)
- sqlite3 (2.8.1-x86_64-linux-musl)
+ sqlite3 (2.9.0-aarch64-linux-gnu)
+ sqlite3 (2.9.0-aarch64-linux-musl)
+ sqlite3 (2.9.0-arm-linux-gnu)
+ sqlite3 (2.9.0-arm-linux-musl)
+ sqlite3 (2.9.0-arm64-darwin)
+ sqlite3 (2.9.0-x86_64-darwin)
+ sqlite3 (2.9.0-x86_64-linux-gnu)
+ sqlite3 (2.9.0-x86_64-linux-musl)
state_machines (0.100.4)
state_machines-activemodel (0.101.0)
activemodel (>= 7.2)
@@ -762,22 +713,22 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringex (2.8.6)
- stringio (3.1.9)
+ stringio (3.2.0)
tailwindcss-rails (4.4.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
- tailwindcss-ruby (4.1.16)
- tailwindcss-ruby (4.1.16-aarch64-linux-gnu)
- tailwindcss-ruby (4.1.16-aarch64-linux-musl)
- tailwindcss-ruby (4.1.16-arm64-darwin)
- tailwindcss-ruby (4.1.16-x86_64-darwin)
- tailwindcss-ruby (4.1.16-x86_64-linux-gnu)
- tailwindcss-ruby (4.1.16-x86_64-linux-musl)
+ tailwindcss-ruby (4.1.18)
+ tailwindcss-ruby (4.1.18-aarch64-linux-gnu)
+ tailwindcss-ruby (4.1.18-aarch64-linux-musl)
+ tailwindcss-ruby (4.1.18-arm64-darwin)
+ tailwindcss-ruby (4.1.18-x86_64-darwin)
+ tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
+ tailwindcss-ruby (4.1.18-x86_64-linux-musl)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
- thor (1.4.0)
+ thor (1.5.0)
timecop (0.9.10)
- timeout (0.5.0)
+ timeout (0.6.0)
tinymce-rails (6.8.6.1)
railties (>= 3.1.1)
traces (0.18.2)
@@ -785,7 +736,7 @@ GEM
activesupport (>= 4.2.5)
json (>= 1.8.3)
tsort (0.2.0)
- turbo-rails (2.0.20)
+ turbo-rails (2.0.21)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@@ -793,7 +744,7 @@ GEM
unaccent (0.4.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
- unicode-emoji (4.1.0)
+ unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
validates_zipcode (0.5.4)
@@ -805,7 +756,7 @@ GEM
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (~> 4.0, < 4.11)
- webmock (3.25.0)
+ webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -816,7 +767,8 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
- zeitwerk (2.7.3)
+ yaml (0.4.0)
+ zeitwerk (2.7.4)
PLATFORMS
aarch64-linux-gnu
@@ -848,5 +800,279 @@ DEPENDENCIES
sqlite3 (>= 2.0)
webmock
+CHECKSUMS
+ abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
+ action_text-trix (2.1.16) sha256=f645a2c21821b8449fd1d6770708f4031c91a2eedf9ef476e9be93c64e703a8a
+ actioncable (8.1.2) sha256=dc31efc34cca9cdefc5c691ddb8b4b214c0ea5cd1372108cbc1377767fb91969
+ actionmailbox (8.1.2) sha256=058b2fb1980e5d5a894f675475fcfa45c62631103d5a2596d9610ec81581889b
+ actionmailer (8.1.2) sha256=f4c1d2060f653bfe908aa7fdc5a61c0e5279670de992146582f2e36f8b9175e9
+ actionpack (8.1.2) sha256=ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423
+ actiontext (8.1.2) sha256=0bf57da22a9c19d970779c3ce24a56be31b51c7640f2763ec64aa72e358d2d2d
+ actionview (8.1.2) sha256=80455b2588911c9b72cec22d240edacb7c150e800ef2234821269b2b2c3e2e5b
+ active_link_to (1.0.5) sha256=4830847b3d14589df1e9fc62038ceec015257fce975ec1c2a77836c461b139ba
+ active_storage_validations (1.3.0) sha256=788e4189d51c7b55049bf2825ede964baa2fe531e22a31c59f67a962ebf8d71e
+ activejob (8.1.2) sha256=908dab3713b101859536375819f4156b07bdf4c232cc645e7538adb9e302f825
+ activemerchant (1.137.0) sha256=fdf5e33f5c94d1981bd632d72e282c0a5afaf06bac6df3f933c08a95e98bd5ce
+ activemodel (8.1.2) sha256=e21358c11ce68aed3f9838b7e464977bc007b4446c6e4059781e1d5c03bcf33e
+ activerecord (8.1.2) sha256=acfbe0cadfcc50fa208011fe6f4eb01cae682ebae0ef57145ba45380c74bcc44
+ activestorage (8.1.2) sha256=8a63a48c3999caeee26a59441f813f94681fc35cc41aba7ce1f836add04fba76
+ activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae
+ acts-as-taggable-on (13.0.0) sha256=dca776c6ddebc458d175b57554ad692488e283f27047117e01ed871f1cba6135
+ acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8
+ addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057
+ any_ascii (0.3.3) sha256=9bba37e4aadc7c57bc29e4bae520418416d1b69ea167d04f2aeaf9c07166f724
+ ar_lazy_preload (2.1.0) sha256=03effc2aac5aef2a1be8ff3ed4bb60f4e92e57f192dde61750bd8ea2131ad517
+ ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
+ async (2.36.0) sha256=090623f4c65706664355c9efa6c7bfb86771a513e65cd681c51cb27747530550
+ async-http (0.94.2) sha256=c5ca94b337976578904a373833abe5b8dfb466a2946af75c4ae38c409c5c78b2
+ async-http-faraday (0.22.1) sha256=414c3197f32157bc0a7a187b1fb4c669097a8ef71832613eff2371c9808e9923
+ async-pool (0.11.1) sha256=98e1583e199a75f7dc70f8e65fc8d0d3b28636c3f256595d43e206642ad8fbda
+ awesome_nested_set (3.9.0) sha256=3ce99e816550f97f4de118e621630070aacf24928b920fe4a68846578a8daaed
+ awesome_print (1.9.2) sha256=e99b32b704acff16d768b3468680793ced40bfdc4537eb07e06a4be11133786e
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+ bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf
+ bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
+ brakeman (7.1.2) sha256=6b04927710a2e7d13a72248b5d404c633188e02417f28f3d853e4b6370d26dce
+ breadcrumbs_on_rails (4.1.0) sha256=6146ecf669f6251507231028719d51d92b8f42f1f982b83a2481fa1465cb6cee
+ builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
+ byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d
+ cancancan (3.6.1) sha256=975c1d5cbf58d5df48a9452a7f61ae3d254608cd87570402f5925a8864c56b62
+ capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
+ capybara-screenshot (1.0.26) sha256=816b9370a07752097c82a05f568aaf5d3b7f45c3db5d3aab2014071e1b3c0c77
+ carmen (1.1.3) sha256=970dbcb6a1743f0c7d765ebf4f0e5b59e99c4f96460baaa22e7306d5a2952e68
+ chartkick (5.2.1) sha256=2848d7de87189f30f28d077eb0bbdebc8a1f0f6f81de1ded95008fe564369949
+ childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec
+ coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
+ concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
+ connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
+ console (1.34.2) sha256=1c036abf606ccec83f9dc28f0c31710fe5936ffe7ba5d235ae2865590a482d58
+ countries (8.1.0) sha256=4d6b318b8e906f1f769d5c021c13a418d33e917dc96ceb625a91d8e7ab2d192e
+ crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
+ crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
+ csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
+ database_cleaner (2.1.0) sha256=1dcba26e3b1576da692fc6bac10136a4744da5bcc293d248aae19640c65d89cd
+ database_cleaner-active_record (2.2.2) sha256=88296b9f3088c31f7c0d4fcec10f68e4b71c96698043916de59b04debec10388
+ database_cleaner-core (2.0.1) sha256=8646574c32162e59ed7b5258a97a208d3c44551b854e510994f24683865d846c
+ date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
+ deface (1.9.0) sha256=b6da77da3618efa4ec33474debc7e95f8b4d84775f34b9d13799d97f6aef3806
+ devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8
+ devise-encryptable (0.2.0) sha256=9860caed9484030c438cfaf05831355c16c6a9612dcd1e12e84805850f7d09b6
+ diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
+ docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
+ doorkeeper (5.8.2) sha256=a73d07aeaf590b1e7e2a35390446f23131c9f37bc0561653e514d3973f4d50d3
+ dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
+ drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
+ erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
+ erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
+ factory_bot (6.5.6) sha256=12beb373214dccc086a7a63763d6718c49769d5606f0501e0a4442676917e077
+ factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68
+ faraday (2.14.0) sha256=8699cfe5d97e55268f2596f9a9d5a43736808a943714e3d9a53e6110593941cd
+ faraday-http-cache (2.6.1) sha256=7772ae61c5fe1c0243452dec1f069b15bcc3304e9436e4f8545ccdb5963c4285
+ faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c
+ ffaker (2.25.0) sha256=e485c5adf8195aac55662875b7f515469bca46d77b60d0e7d08db6861bcbec40
+ ffi (1.17.3-aarch64-linux-gnu) sha256=28ad573df26560f0aedd8a90c3371279a0b2bd0b4e834b16a2baa10bd7a97068
+ ffi (1.17.3-aarch64-linux-musl) sha256=020b33b76775b1abacc3b7d86b287cef3251f66d747092deec592c7f5df764b2
+ ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668
+ ffi (1.17.3-arm-linux-musl) sha256=0d7626bb96265f9af78afa33e267d71cfef9d9a8eb8f5525344f8da6c7d76053
+ ffi (1.17.3-arm64-darwin) sha256=0c690555d4cee17a7f07c04d59df39b2fba74ec440b19da1f685c6579bb0717f
+ ffi (1.17.3-x86_64-darwin) sha256=1f211811eb5cfaa25998322cdd92ab104bfbd26d1c4c08471599c511f2c00bb5
+ ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
+ ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56
+ fiber-annotation (0.2.0) sha256=7abfadf1d119f508867d4103bf231c0354d019cc39a5738945dec2edadaf6c03
+ fiber-local (1.1.0) sha256=c885f94f210fb9b05737de65d511136ea602e00c5105953748aa0f8793489f06
+ fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1
+ friendly_id (5.5.1) sha256=e018f2b89bfc143276fee6d378a64792385cd4fddd3d4fce501f59ec19c06207
+ friendly_id-mobility (1.0.5) sha256=8c0cf4d0a1358311db1b2dcc0e4c33077ae929c7e34749485268d066adbaf2e4
+ gem-release (2.2.4) sha256=2f11124c1580c811507c3b47e875e420cf3ed792a98105b49df11971e6e94db3
+ geocoder (1.8.6) sha256=e0ca1554b499f466de9b003f7dff70f89a5888761c2ca68ed9f86b6e5e24e74c
+ github_changelog_generator (1.16.4) sha256=21d2dfe41d50cf3a7ffe896774779d9d61da6aaf08d390f8ddff0dacbdf8a611
+ globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
+ groupdate (6.7.0) sha256=beaa8d5bf3856814681914a1d4a20e77436a2214b85d0017dc2ea5c355fb6777
+ hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
+ heroicon (1.0.0) sha256=e63a535a630d6fd3d99c0179d722753c8cd3478df1277eca7a4215c135b6e9cb
+ highline (3.1.2) sha256=67cbd34d19f6ef11a7ee1d82ffab5d36dfd5b3be861f450fc1716c7125f4bb4a
+ hightop (0.6.0) sha256=d3799d2232e4d0bc69bfd940e49832d66bcf18b7098be252a4fd65f1eb29b778
+ i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
+ i18n-tasks (1.1.2) sha256=4dcfba49e52a623f30661cb316cb80d84fbba5cb8c6d88ef5e02545fffa3637a
+ image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
+ importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
+ inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f
+ io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
+ io-endpoint (0.17.1) sha256=e7bf943d89fdf83472b3fe3c06d7d290735b7e58bb968dcfa8c687a06ad11ee7
+ io-event (1.14.2) sha256=b0a069190eafe86005c22f7464f744971b5bd82f153740d34e6ab49548d4f613
+ io-stream (0.11.1) sha256=fa5f551fcff99581c1757b9d1cee2c37b124f07d2ca4f40b756a05ab9bd21b87
+ irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806
+ json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
+ jsonapi-rspec (0.0.11) sha256=995f1adb59b386cc36e3f6823c828df575167586937e3e42601914c4ee650082
+ jsonapi-serializer (2.2.0) sha256=f8141ac6f0c1e17e8513df68f8341afe2d7bffc285841d7090bc07f07efb0029
+ language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
+ launchy (3.1.1) sha256=72b847b5cc961589dde2c395af0108c86ff0119f42d4648d25b5440ebb10059e
+ lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
+ local_time (3.0.3) sha256=c69a8974d993fdf6e60db02977ed23c070f203dcb3a1ff0de52ad3d2393f8303
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6
+ mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
+ mail_form (1.11.0) sha256=54aaaf11b69e7e5bd4be220dfc80f2469bb809d6ed1f893b8d940f0b67232c5a
+ mapkick-rb (0.2.0) sha256=c70da2e1edc56da290ccab51fd932ee5b1c562fe9f7877de0333b5562937e28e
+ marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee
+ matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
+ method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
+ metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072
+ mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4
+ mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
+ minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
+ mobility (1.3.2) sha256=32fbbb0e53118ef42de20daa6ac94dbb758c628874092eba311b968a1e1d757b
+ mobility-actiontext (1.1.1) sha256=98f8fb9f2d8aa49fce1814eefb2ad8060efdfb89167bc5bd864da0bdd858127d
+ mobility-ransack (1.2.2) sha256=8b480ce5bc4937abc37c17b9922d7e41b8a7064a3207b3a64e5aa71e0cb3332b
+ monetize (1.13.0) sha256=0c1f8ae28aa21bbf1a6d014138d9bb0a5ce756c1a99b8b7f7d409c258b01d3b3
+ money (6.19.0) sha256=ec936fa1e42f2783719241ed9fd52725d0efa628f928feea1eb5c37d5de7daf3
+ multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7
+ name_of_person (1.1.3) sha256=e12291250987ef79c413314f698f15d53e7a602c42231de99c276ef9585bc18c
+ net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
+ net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6
+ net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
+ net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
+ net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
+ nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
+ nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767
+ nokogiri (1.19.0-aarch64-linux-musl) sha256=eb70507f5e01bc23dad9b8dbec2b36ad0e61d227b42d292835020ff754fb7ba9
+ nokogiri (1.19.0-arm-linux-gnu) sha256=572a259026b2c8b7c161fdb6469fa2d0edd2b61cd599db4bbda93289abefbfe5
+ nokogiri (1.19.0-arm-linux-musl) sha256=23ed90922f1a38aed555d3de4d058e90850c731c5b756d191b3dc8055948e73c
+ nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810
+ nokogiri (1.19.0-x86_64-darwin) sha256=1dad56220b603a8edb9750cd95798bffa2b8dd9dd9aa47f664009ee5b43e3067
+ nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c
+ nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4
+ observer (0.1.2) sha256=d8a3107131ba661138d748e7be3dbafc0d82e732fffba9fccb3d7829880950ac
+ octokit (4.25.1) sha256=c02092ee82dcdfe84db0e0ea630a70d32becc54245a4f0bacfd21c010df09b96
+ openssl (4.0.0) sha256=185711ed93d4e9c9a9db6efea7edb202dfe04f7d3692fbab988e3d84e498ee91
+ orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9
+ ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
+ pagy (43.2.8) sha256=3d38376df4a9a7893361bb68825643896371707c4c49ef73acf1bd3b50af7562
+ parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
+ paranoia (3.1.0) sha256=36574a46e8df663d0da7d9008e987b72a6a9eccee13c058b7094acc810e39f52
+ parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
+ phonelib (0.10.15) sha256=8e0c06acef2867632691d80b63d38438524cb0941b6c7da2551e00f91a5787b8
+ polyglot (0.3.5) sha256=59d66ef5e3c166431c39cb8b7c1d02af419051352f27912f6a43981b3def16af
+ pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
+ prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
+ prism (1.8.0) sha256=84453a16ef5530ea62c5f03ec16b52a459575ad4e7b9c2b360fd8ce2c39c1254
+ protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91
+ protocol-http (0.58.1) sha256=6d0694aa07cd51ff589c6219f9613c6827aebd5ffdf8fb8a0169254feb0f13fd
+ protocol-http1 (0.37.0) sha256=5bdd739e28792b341134596f6f5ab21a9d4b395f67bae69e153743eb0e69d123
+ protocol-http2 (0.24.0) sha256=65327a019b7e36d2774e94050bf57a43bb60212775d2fcf02ae1d2ed4f01ef28
+ protocol-url (0.4.0) sha256=64d4c03b6b51ad815ac6fdaf77a1d91e5baf9220d26becb846c5459dacdea9e1
+ pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e
+ psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
+ public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857
+ puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
+ racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
+ rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
+ rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
+ rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
+ rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
+ rails (8.1.2) sha256=5069061b23dfa8706b9f0159ae8b9d35727359103178a26962b868a680ba7d95
+ rails-controller-testing (1.0.5) sha256=741448db59366073e86fc965ba403f881c636b79a2c39a48d0486f2607182e94
+ rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d
+ rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560
+ rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3
+ railties (8.1.2) sha256=1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055
+ rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
+ rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
+ ransack (4.4.1) sha256=6aeaac36fc19088570e10da1044e6cfd88c740e20f871b84566fd30e32b7a63d
+ rbs (3.10.2) sha256=bd8a5dc4c62f229f020146b61844a31f9c79e649449d212904a474eb79c846fc
+ rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363
+ regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
+ reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
+ request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb
+ responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a
+ rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
+ rspec-activemodel-mocks (1.3.0) sha256=b51b111b726370a20a2103a8a6851296783a70d2b0bd0287bfa24d293f58d76e
+ rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
+ rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
+ rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c
+ rspec-rails (8.0.2) sha256=113139a53f5d068d4f48d1c29ad5f982013ed9b0daa69d7f7b266eda5d433ace
+ rspec-retry (0.6.2) sha256=6101ba23a38809811ae3484acde4ab481c54d846ac66d5037ccb40131a60d858
+ rspec-support (3.13.6) sha256=2e8de3702427eab064c9352fe74488cc12a1bfae887ad8b91cba480ec9f8afb2
+ rspec_junit_formatter (0.6.0) sha256=40dde674e6ae4e6cc0ff560da25497677e34fefd2338cc467a8972f602b62b15
+ rubocop (1.84.0) sha256=88dec310153bb685a879f5a7cdb601f6287b8f0ee675d9dc63a17c7204c4190a
+ rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
+ rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
+ rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2
+ rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
+ rubocop-rspec (3.9.0) sha256=8fa70a3619408237d789aeecfb9beef40576acc855173e60939d63332fdb55e2
+ ruby-lsp (0.26.5) sha256=19272659139b292a81a700d78e1b4d8988c4812e96b54fd6c30a21ce5e82b189
+ ruby-oembed (0.18.1) sha256=877b87b3d98e8137599044277fdb82a5536973bd9df68a93129163b781ae3a5f
+ ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
+ ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
+ rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
+ safely_block (0.5.0) sha256=782c342bc400a79c1233c40cf8c379da6116c6cfd1d7c1f17f0ef2ef11090103
+ sawyer (0.9.3) sha256=0d0f19298408047037638639fe62f4794483fb04320269169bd41af2bdcf5e41
+ securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
+ selenium-webdriver (4.10.0) sha256=a322af38f9a8966a6c3ab85c365b08c5556cdef72f577905b2b42469faee9d43
+ simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
+ simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
+ simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
+ spree (5.3.0) sha256=5455d28c79e96b76c597a5787d301132b6ee7b830c95de77c9b15a36af2c9fb1
+ spree-spl (2.0.0)
+ spree_admin (5.3.0) sha256=86d2492c3c98efcc9d6ec099594eff23251f4aff1154eb266a3d2510524d4559
+ spree_api (5.3.0) sha256=b3ef192c1f2453770d20ef5e1dcb38568b7b0a128ddde38bdca7c2a138dc5756
+ spree_auth_devise (4.6.3) sha256=5eeada77570f5ab335a9c6c150d7cdaee39203ce43b70ac24d2ffb195952ed6e
+ spree_cli (5.3.0) sha256=a5cfd2d5bde7f120f6631bb59c8d337d1a29a93d3523c02ab0febf93511d1dac
+ spree_core (5.3.0) sha256=e96ad7492b758fe15261d4fe9a15e1149034bf1b34844acad0c0383e06d42889
+ spree_dev_tools (0.5.2) sha256=faa8bda07eb785e066032e5527663292bbe0dfab653fc06c61d56798dd4deea7
+ spree_extension (0.1.0) sha256=b648327f4787295e051be4ea0c0a47baec47c2e245bca9c93ecf73605fc3a051
+ spree_page_builder (5.3.0) sha256=1501377b720c29ffd2adfd8b195221580d8023b22552cc34d99aa899112bae8e
+ spree_storefront (5.3.0) sha256=f7db814f8ac43390660bc6d987bf67e97d48ca27c0502fd93b9115bd7d2b63cd
+ sprockets (4.2.2) sha256=761e5a49f1c288704763f73139763564c845a8f856d52fba013458f8af1b59b1
+ sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e
+ sqlite3 (2.9.0-aarch64-linux-gnu) sha256=cfe1e0216f46d7483839719bf827129151e6c680317b99d7b8fc1597a3e13473
+ sqlite3 (2.9.0-aarch64-linux-musl) sha256=56a35cb2d70779afc2ac191baf2c2148242285ecfed72f9b021218c5c4917913
+ sqlite3 (2.9.0-arm-linux-gnu) sha256=a19a21504b0d7c8c825fbbf37b358ae316b6bd0d0134c619874060b2eef05435
+ sqlite3 (2.9.0-arm-linux-musl) sha256=fca5b26197c70e3363115d3faaea34d7b2ad9c7f5fa8d8312e31b64e7556ee07
+ sqlite3 (2.9.0-arm64-darwin) sha256=a917bd9b84285766ff3300b7d79cd583f5a067594c8c1263e6441618c04a6ed3
+ sqlite3 (2.9.0-x86_64-darwin) sha256=59fe51baa3cb33c36d27ce78b4ed9360cd33ccca09498c2ae63850c97c0a6026
+ sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5
+ sqlite3 (2.9.0-x86_64-linux-musl) sha256=ef716ba7a66d7deb1ccc402ac3a6d7343da17fac862793b7f0be3d2917253c90
+ state_machines (0.100.4) sha256=dd4e555ebb061569dd8fe18a5f39b51d10be6306b89a0c315a9697671d752565
+ state_machines-activemodel (0.101.0) sha256=6798c6bf5aa4be2b1a00ca633949c999ebee7a148bd4ea8f6858b189e2cdf72d
+ state_machines-activerecord (0.100.0) sha256=b2213f74f3f32c6d92de5139e8898bc64a0ffb3f048df7d950ce27e28384beb5
+ stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
+ stringex (2.8.6) sha256=c7b382d2b2a47a1e1646f256df201c48d487d6296fbb289d76802f67f5e929c4
+ stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
+ tailwindcss-rails (4.4.0) sha256=efa2961351a52acebe616e645a81a30bb4f27fde46cc06ce7688d1cd1131e916
+ tailwindcss-ruby (4.1.18) sha256=b62fad5b00494e92987ee319dfb5c5ad272f0ed93649963d62f08d2ba0f03fa7
+ tailwindcss-ruby (4.1.18-aarch64-linux-gnu) sha256=e10f9560bccddbb4955fd535b3bcc8c7071a7df07404dd473a23fa791ec4e46b
+ tailwindcss-ruby (4.1.18-aarch64-linux-musl) sha256=3c8426674718a2c98a0649c825ac0b3286ff52acd0b4052d7d19126cd74904f3
+ tailwindcss-ruby (4.1.18-arm64-darwin) sha256=f940531d5a030c566d3d616004235bcd4c361abdd328f7d6c7e3a953a32e0155
+ tailwindcss-ruby (4.1.18-x86_64-darwin) sha256=6a82115b606a6f748c600c666a19c16ee28f5736217af8d0c20cee5abe1ce2f7
+ tailwindcss-ruby (4.1.18-x86_64-linux-gnu) sha256=e0a2220163246fe0126c5c5bafb95bc6206e7d21fce2a2878fd9c9a359137534
+ tailwindcss-ruby (4.1.18-x86_64-linux-musl) sha256=d957cf545b09d2db7eb6267450cc1fc589e126524066537a0c4d5b99d701f4b2
+ terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
+ thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
+ timecop (0.9.10) sha256=12ba45ce57cdcf6b1043cb6cdffa6381fd89ce10d369c28a7f6f04dc1b0cd8eb
+ timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
+ tinymce-rails (6.8.6.1) sha256=b249fccfb300d8cdb361a443e859d6b45ec6d7bbd1466817008ee0af2a3d516c
+ traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214
+ tracking_number (2.4.0) sha256=2a4c6b39cbdfc8326c234626556da1e269c8dfe33478786bea93461331c247ff
+ tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+ turbo-rails (2.0.21) sha256=02070ea29fd11d8c1a07d9d7be980729a20e94e39b8c6c819f690f7959216bc7
+ tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
+ unaccent (0.4.0) sha256=d9278230016edc5317c053f8e84c1e1dea6827dec028e5481e2294a4e11e7333
+ unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
+ unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
+ uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+ useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
+ validates_zipcode (0.5.4) sha256=fc2a064bed9303fa2fda36f730367a4d5d95a8f4c74baa1aae2b80119da8cc0f
+ wannabe_bool (0.7.1) sha256=838ef671066868424de888fb22eeae57c7012709aabd5d17141ea6fea964c61f
+ warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0
+ webdrivers (5.3.1) sha256=d7d5531fbc3c44b6a2a7710e3e6c5edc54f35c68af20017309e3eccf15ec4aa5
+ webmock (3.26.1) sha256=4f696fb57c90a827c20aadb2d4f9058bbff10f7f043bd0d4c3f58791143b1cd7
+ websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737
+ websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
+ websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
+ xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e
+ yaml (0.4.0) sha256=240e69d1e6ce3584d6085978719a0faa6218ae426e034d8f9b02fb54d3471942
+ zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b
+
BUNDLED WITH
- 2.6.2
+ 2.7.2
diff --git a/README.md b/README.md
index 009c6bc..8d0e19d 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,13 @@ _______
_______
_______
+To add JavaScript to your application, enter the following in the terminal:
+
+```sh
+bin/rails g spl:install
+```
+
+
After installation, add the following line to `Spree::Adjustable::AdjustmentsUpdaterDecorator#persist_totals`:
```sh
diff --git a/app/controllers/spl/spree/api/account_controller_decorator.rb b/app/controllers/spl/spree/api/account_controller_decorator.rb
index 5b1f1fb..cb9f0d6 100644
--- a/app/controllers/spl/spree/api/account_controller_decorator.rb
+++ b/app/controllers/spl/spree/api/account_controller_decorator.rb
@@ -5,6 +5,8 @@ module Spree
module Api
# Account decorator to validate spl card no
module AccountControllerDecorator
+ include ErrorHandlingHelper
+
def self.prepended(base)
base.before_action :validate_spl_no_card, only: :update
end
@@ -16,7 +18,8 @@ def connect_loyalty_account
render_serialized_payload { serialize_resource(spree_current_user) }
rescue Spl::LoginAccountService::SplLoginAccountError, AssignSpartaCardNumberService::AssignSpartaCardNumberError,
Spl::MeService::SplMeError => e
- render json: { error: e }, status: :bad_request
+ handle_spl_error(e, spree_current_user)
+ render json: { error: spree_current_user.errors }, status: :bad_request
end
def register_loyalty_account
@@ -25,14 +28,16 @@ def register_loyalty_account
spree_current_user.reload
render_serialized_payload { serialize_resource(spree_current_user) }
rescue Spl::RegisterAccountService::SplRegisterAccountError, Spl::OauthTokenService::OauthTokenError => e
- render json: { error: e }, status: :bad_request
+ handle_spl_error(e, spree_current_user)
+ render json: { error: spree_current_user.errors }, status: :bad_request
end
def registration_code
Spl::RequestOtpService.new(DateTime.current, current_store, params).call
head :no_content
rescue Spl::RequestOtpService::SplRequestOtpError, Spl::OauthTokenService::OauthTokenError => e
- render json: { error: e }, status: :bad_request
+ handle_spl_error(e, spree_current_user)
+ render json: { error: spree_current_user.errors }, status: :bad_request
end
def login_code
@@ -40,7 +45,8 @@ def login_code
Spl::SendOtpService.new(DateTime.current, params[:mobile_country], params[:phone_number], current_store).call
head :no_content
rescue Spl::SendOtpService::SplSendOtpError => e
- render json: { error: e }, status: :bad_request
+ handle_spl_error(e, spree_current_user)
+ render json: { error: spree_current_user.errors }, status: :bad_request
end
private
diff --git a/app/controllers/spl/spree/api/cart_controller_decorator.rb b/app/controllers/spl/spree/api/cart_controller_decorator.rb
index dfb2678..0a6bed9 100644
--- a/app/controllers/spl/spree/api/cart_controller_decorator.rb
+++ b/app/controllers/spl/spree/api/cart_controller_decorator.rb
@@ -18,7 +18,7 @@ def show
super
end
- def update_spl_card_activate # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
+ def update_spl_card_activate # rubocop:disable Metrics/AbcSize
spree_authorize! :update, spree_current_order, order_token
if params.dig('public_metadata', 'spl_card_active').nil?
diff --git a/app/controllers/spl/spree/storefront/checkout_controller_decorator.rb b/app/controllers/spl/spree/storefront/checkout_controller_decorator.rb
index 886312e..624a9d8 100644
--- a/app/controllers/spl/spree/storefront/checkout_controller_decorator.rb
+++ b/app/controllers/spl/spree/storefront/checkout_controller_decorator.rb
@@ -4,13 +4,43 @@ module Spl
module Spree
module Storefront
module CheckoutControllerDecorator
+ include ErrorHandlingHelper
include BooleanHelper
def self.prepended(base)
base.before_action :promotion_switcher
+ base.before_action :load_user_coupons, except: %i[activate_coupon deactivate_coupon]
base.after_action :perform_update_sparta_state_job, only: %i[confirm complete]
end
+ def activate_coupon
+ Spl::Coupons::ActivateCouponService.new(@order.user, @order.store, params[:coupon_code]).call
+ load_user_coupons
+ rescue StandardError => e
+ handle_spl_error(e, @order.user)
+ raise e
+ ensure
+ respond_to do |format|
+ format.turbo_stream
+ format.html { redirect_to checkout_path }
+ end
+ end
+
+ def deactivate_coupon
+ Spl::Coupons::DeactivateCouponService
+ .new(@order.user, @order.store, params[:coupon_code])
+ .call
+ load_user_coupons
+ rescue StandardError => e
+ handle_spl_error(e, @order.user)
+ raise e
+ ensure
+ respond_to do |format|
+ format.turbo_stream
+ format.html { redirect_to checkout_path }
+ end
+ end
+
private
def promotion_switcher
@@ -29,6 +59,13 @@ def perform_update_sparta_state_job
UpdateSpartaStateJob.perform_later(@order.token, 'C', @order.number, @order.store)
end
end
+
+ def load_user_coupons
+ @coupons = Spl::Coupons::GetCouponsService.new(@order.user, @order.store).call
+ rescue StandardError => e
+ handle_spl_error(e, @order.user)
+ raise e
+ end
end
end
end
diff --git a/app/controllers/spl/spree/storefront/profile_controller_decorator.rb b/app/controllers/spl/spree/storefront/profile_controller_decorator.rb
index 04afa69..e060517 100644
--- a/app/controllers/spl/spree/storefront/profile_controller_decorator.rb
+++ b/app/controllers/spl/spree/storefront/profile_controller_decorator.rb
@@ -5,18 +5,54 @@ module Spree
module Storefront
module ProfileControllerDecorator
include BooleanHelper
+ include ProfileControllerHelper
+ include ErrorHandlingHelper
def self.prepended(base)
- base.after_action :validate_spl_no_card, only: :update
+ base.before_action :validate_spl_no_card, only: :update
+ base.before_action :validate_login_code_request, only: %i[login_code registration_code]
end
- private
+ def login_code
+ send_otp(phone_parser, current_store)
+ update_user_after_otp_request
+ render_login_code_success(try_spree_current_user, phone_parser, 'otp_code_form')
+ rescue Spl::SendOtpService::SplSendOtpError => e
+ handle_spl_error(e, try_spree_current_user)
+ render_login_code_error(try_spree_current_user)
+ end
- def user_params
- params.require(:user).permit(:first_name, :last_name, :phone, :email,
- public_metadata: %i[spl_card_active spl_no_card])
+ def connect_loyalty_account
+ assign_card_number(try_spree_current_user, current_store, params)
+ redirect_to spree.edit_account_profile_path,
+ notice: ::Spree.t(:successfully_updated, resource: ::Spree.t(:account))
+ rescue Spl::LoginAccountService::SplLoginAccountError, AssignSpartaCardNumberService::AssignSpartaCardNumberError,
+ Spl::MeService::SplMeError => e
+ handle_spl_error(e, try_spree_current_user)
+ render_connect_loyalty_account_error(try_spree_current_user, try_spree_current_user.phone, 'otp_code_form')
end
+ def registration_code
+ request_otp(phone_parser, current_store, params['user'])
+ update_user_after_otp_request
+ render_login_code_success(try_spree_current_user, phone_parser, 'otp_registration_form')
+ rescue Spl::RequestOtpService::SplRequestOtpError, Spl::OauthTokenService::OauthTokenError => e
+ handle_spl_error(e, try_spree_current_user)
+ render_login_code_error(try_spree_current_user)
+ end
+
+ def register_loyalty_account
+ Spl::RegisterAccountService.new(try_spree_current_user, current_store, params['user']['spl_auth_code']).call
+ redirect_to spree.edit_account_profile_path,
+ notice: ::Spree.t(:successfully_updated, resource: ::Spree.t(:account))
+ rescue Spl::RegisterAccountService::SplRegisterAccountError, Spl::OauthTokenService::OauthTokenError => e
+ handle_spl_error(e, try_spree_current_user)
+ user = try_spree_current_user
+ render_connect_loyalty_account_error(user, user.phone, 'otp_registration_form')
+ end
+
+ private
+
def validate_spl_no_card
metadata = user_params[:public_metadata]
return unless metadata&.[](:spl_no_card)
@@ -27,10 +63,23 @@ def validate_spl_no_card
handle_validation_error(e)
end
+ def validate_login_code_request
+ clear_errors(try_spree_current_user)
+ validate_yc_terms
+ validate_phone
+
+ render_login_code_error(try_spree_current_user) if try_spree_current_user.errors.any?
+ end
+
+ def request_otp(phone, store, params)
+ params.merge!(mobile_country: phone.country_code, phone_number: phone.national_number)
+ Spl::RequestOtpService.new(DateTime.current, store, params).call
+ end
+
def validate_card(metadata)
return unless disactivated_card?
- ::Spl::ValidateCardService.new(metadata[:spl_no_card], spree_current_user, current_store).call
+ ::Spl::ValidateCardService.new(metadata[:spl_no_card], try_spree_current_user, current_store).call
end
def handle_validation_error(error)
@@ -38,7 +87,8 @@ def handle_validation_error(error)
end
def update_order(spl_card, active)
- current_order = spree_current_user.orders.last
+ current_order = try_spree_current_user.orders.last
+ return if current_order.nil?
return unless %w[cart address delivery payment].include?(current_order.state)
current_order.update(
@@ -55,6 +105,51 @@ def disactivated_card?
value = user_params.dig(:public_metadata, :spl_card_active)
cast_boolean(value)
end
+
+ def validate_yc_terms
+ return if yc_terms_accepted?
+
+ try_spree_current_user.errors.add(:base, I18n.t('spl.user.errors.must_accept_yc_terms'))
+ end
+
+ def validate_phone
+ return if phone_parser.valid?
+
+ try_spree_current_user.errors.add(:phone, I18n.t('spl.user.errors.invalid_phone'))
+ end
+
+ def yc_terms_accepted?
+ cast_boolean(login_code_params[:accept_yc_terms])
+ end
+
+ def phone_parser
+ @phone_parser ||= PhoneParserService.new(login_code_params[:phone])
+ end
+
+ def send_otp(phone, store)
+ Spl::SendOtpService.new(DateTime.current, phone.country_code, phone.national_number, store).call
+ end
+
+ def update_user_after_otp_request
+ try_spree_current_user.update(
+ phone: login_code_params[:phone],
+ public_metadata: (try_spree_current_user.public_metadata || {}).merge('accept_yc_terms' => true)
+ )
+ end
+
+ def assign_card_number(user, store, params)
+ Spl::LoginAccountService.new(user, store, params).call
+ AssignSpartaCardNumberService.new(user, store).call
+ end
+
+ def login_code_params
+ params.require(:user).permit(:phone, :accept_yc_terms)
+ end
+
+ def user_params
+ params.require(:user).permit(:first_name, :last_name, :phone, :email,
+ public_metadata: %i[spl_card_active spl_no_card])
+ end
end
end
end
diff --git a/app/helpers/error_handling_helper.rb b/app/helpers/error_handling_helper.rb
new file mode 100644
index 0000000..683dda6
--- /dev/null
+++ b/app/helpers/error_handling_helper.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module ErrorHandlingHelper
+ # Parses and translate occurred error, then adds it to user errors.
+ # Dedicated for storefront controllers.
+ # @param [StandardError]
+ def handle_spl_error(error, model)
+ payload = Spl::ErrorPayloadParser.parse(error.message) || error
+ msg = Spl::ErrorTranslator.translate(payload)
+
+ clear_errors(model)
+ model.errors.add(:base, msg)
+ end
+
+ def clear_errors(model)
+ model.errors.clear
+ end
+
+ def token_expired?(err_msg)
+ err_msg == 'TOKEN_EXPIRED'
+ end
+end
diff --git a/app/helpers/login_check_helper.rb b/app/helpers/login_check_helper.rb
new file mode 100644
index 0000000..7c23d5f
--- /dev/null
+++ b/app/helpers/login_check_helper.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module LoginCheckHelper
+ # Checks if user is logged to SPL basing on local information
+ # @param user [Spree::User]
+ # @return [true, false]
+ def logged_user?(user)
+ user&.private_metadata&.fetch('spl_access_token', nil).present?
+ end
+end
diff --git a/app/helpers/profile_controller_helper.rb b/app/helpers/profile_controller_helper.rb
new file mode 100644
index 0000000..0308bfc
--- /dev/null
+++ b/app/helpers/profile_controller_helper.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module ProfileControllerHelper
+ def render_login_code_error(user)
+ render turbo_stream: turbo_stream.replace(
+ 'loyalty_connect_form',
+ partial: 'spl/loyalty_connect_form',
+ locals: { user: user }
+ ),
+ status: :unprocessable_content
+ end
+
+ def render_login_code_success(user, phone, partial)
+ render turbo_stream: turbo_stream.replace(
+ 'loyalty_connect_form',
+ partial: "spl/#{partial}",
+ locals: {
+ user: user,
+ phone_e164: phone.respond_to?(:e164) ? phone.e164 : nil
+ }
+ ),
+ status: :ok
+ end
+
+ def render_connect_loyalty_account_error(user, phone, partial)
+ render turbo_stream: turbo_stream.replace(
+ partial,
+ partial: "spl/#{partial}",
+ locals: {
+ user: user,
+ phone_e164: phone.respond_to?(:e164) ? phone.e164 : nil
+ }
+ ),
+ status: :unprocessable_content
+ end
+end
diff --git a/app/helpers/spl_service_helper.rb b/app/helpers/spl_service_helper.rb
new file mode 100644
index 0000000..30f7e56
--- /dev/null
+++ b/app/helpers/spl_service_helper.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module SplServiceHelper
+ def send_request(url, body)
+ Spl::SendRequestService.new(url, body).call
+ end
+
+ # Refreshes user private token to keep possible
+ # using customer oriented endpoints
+ # @param [user: Spree::User]
+ def refresh_user_token(user, store)
+ return if user.private_metadata.nil?
+ return if user.private_metadata['spl_refresh_token'].nil?
+
+ response = Spl::OauthTokenService.new(DateTime.current, store).refresh_token(user.private_metadata['spl_refresh_token'])
+
+ user.update!(private_metadata: { spl_access_token: response['access_token'],
+ spl_refresh_token: response['refresh_token'] })
+ end
+
+ def token_refresh_needed(response_body, retry_counter, user, store)
+ token_expired?(response_body['errorCode']) && retry_counter < 1 && refresh_user_token(user, store)
+ end
+end
diff --git a/app/models/order_updater_decorator.rb b/app/models/order_updater_decorator.rb
index 9015dcf..2340679 100644
--- a/app/models/order_updater_decorator.rb
+++ b/app/models/order_updater_decorator.rb
@@ -7,4 +7,19 @@ def perform_update_sparta_state_job # rubocop:disable Metrics/AbcSize
UpdateSpartaStateJob.perform_later(order.token, 'D', order.number, order.store) if order.payment_state == 'paid'
UpdateSpartaStateJob.perform_later(order.token, 'C', order.number, order.store) if order.state == 'canceled'
end
+
+ def check_spl_adjustments
+ if order.public_metadata['spl_card_active'] == true
+ updated_any_adjustment = false
+ order.adjustments.each do |adjustment|
+ if adjustment.source_type != 'SPL' && adjustment.eligible?
+ adjustment.update(eligible: false)
+ updated_any_adjustment = true
+ end
+ end
+ updated_any_adjustment
+ else
+ false
+ end
+ end
end
diff --git a/app/overrides/add_coupons_in_checkout.rb b/app/overrides/add_coupons_in_checkout.rb
new file mode 100644
index 0000000..382f65e
--- /dev/null
+++ b/app/overrides/add_coupons_in_checkout.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class AddCouponsInCheckout
+ Deface::Override.new(
+ virtual_path: 'spree/checkout/_sidebar',
+ name: 'add_coupons_in_checkout',
+ insert_before: '.summary-content',
+ text: <<-ERB
+ <%= render partial: 'spree/checkout/spl_coupons' %>
+ ERB
+ )
+end
diff --git a/app/services/apply_sparta_discount_service.rb b/app/services/apply_sparta_discount_service.rb
index e70ef5f..83bf018 100644
--- a/app/services/apply_sparta_discount_service.rb
+++ b/app/services/apply_sparta_discount_service.rb
@@ -10,7 +10,7 @@ def initialize(response, order)
@response = response
end
- def call # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength
+ def call # rubocop:disable Metrics/AbcSize
return unless response_valid?
line_items.each do |line_item|
diff --git a/app/services/assign_sparta_card_number_service.rb b/app/services/assign_sparta_card_number_service.rb
index 66a4014..659d4b2 100644
--- a/app/services/assign_sparta_card_number_service.rb
+++ b/app/services/assign_sparta_card_number_service.rb
@@ -23,7 +23,7 @@ def add_card_number_to_user(card_data)
if card_data['status'] != 'A'
raise AssignSpartaCardNumberError, I18n.t('spl.card_validation.errors.card_not_active')
end
- unless cards_assigned_user(card_data['no'])
+ if card_assigned_to_other_user?(card_data['no'])
raise AssignSpartaCardNumberError, I18n.t('spl.card_validation.errors.wrong_owner')
end
@@ -31,7 +31,11 @@ def add_card_number_to_user(card_data)
spl_card_active: true))
end
- def cards_assigned_user(card_number)
- Spree::User.exists?(["id = ? AND public_metadata ->> 'spl_no_card' = ?", @user.id, card_number])
+ def card_assigned_to_other_user?(card_number)
+ Spree::User.where(
+ "id != ? AND public_metadata ->> 'spl_no_card' = ?",
+ @user.id,
+ card_number
+ ).exists?
end
end
diff --git a/app/services/phone_parser_service.rb b/app/services/phone_parser_service.rb
new file mode 100644
index 0000000..e36da21
--- /dev/null
+++ b/app/services/phone_parser_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class PhoneParserService
+ COUNTRY_CODE_REGEX = /\A\+\d{1,3}\d+\z/
+
+ attr_reader :raw, :phone
+
+ def initialize(raw_phone)
+ @raw = raw_phone.to_s.strip
+ @phone = Phonelib.parse(@raw)
+ end
+
+ def valid?
+ country_code? && phone.valid?
+ end
+
+ def country_code?
+ raw.match?(COUNTRY_CODE_REGEX)
+ end
+
+ def country_code
+ return unless valid?
+
+ "+#{phone.country_code}"
+ end
+
+ def national_number
+ return unless valid?
+
+ phone.national_number
+ end
+
+ def e164
+ return unless valid?
+
+ phone.e164
+ end
+
+ def to_h
+ return {} unless valid?
+
+ {
+ country_code: country_code,
+ national_number: national_number,
+ e164: e164
+ }
+ end
+end
diff --git a/app/services/spl/coupons/activate_coupon_service.rb b/app/services/spl/coupons/activate_coupon_service.rb
new file mode 100644
index 0000000..5d2f2c7
--- /dev/null
+++ b/app/services/spl/coupons/activate_coupon_service.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'json'
+
+module Spl
+ module Coupons
+ class ActivateCouponService
+ class ActivateCouponServiceError < StandardError; end
+ include SplServiceHelper
+ include ErrorHandlingHelper
+ include LoginCheckHelper
+
+ def initialize(user, store, coupon_code)
+ @store = store
+ @activate_coupons_url = URI.parse(Spl::UrlCreatorService.new(store.private_metadata['spl_url']).coupon_activate)
+ @user = user
+ @coupon_code = coupon_code
+ @retry_counter = 0
+ end
+
+ def call
+ return unless @user.present? && @user.private_metadata.present?
+ return unless logged_user?(@user)
+
+ response = send_request(@activate_coupons_url, body)
+ response_body = JSON.parse(response.body)
+ Rails.logger.debug response_body
+ raise ActivateCouponServiceError, response_body['msg'] if response_body['errorCode'] != '0'
+
+ response_body['response']
+ rescue ActivateCouponServiceError => e
+ raise e unless token_refresh_needed(response_body, @retry_counter, @user, @store)
+
+ @retry_counter += 1
+ retry
+ end
+
+ private
+
+ def body
+ {
+ context: {
+ prgCode: @store.private_metadata['spl_prg_code'],
+ oauthToken: @user.private_metadata['spl_access_token']
+ },
+ couponCode: @coupon_code
+ }
+ end
+ end
+ end
+end
diff --git a/app/services/spl/coupons/deactivate_coupon_service.rb b/app/services/spl/coupons/deactivate_coupon_service.rb
new file mode 100644
index 0000000..d5ee195
--- /dev/null
+++ b/app/services/spl/coupons/deactivate_coupon_service.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'json'
+
+module Spl
+ module Coupons
+ class DeactivateCouponService
+ class DeactivateCouponServiceError < StandardError; end
+ include SplServiceHelper
+ include ErrorHandlingHelper
+ include LoginCheckHelper
+
+ def initialize(user, store, coupon_code)
+ @store = store
+ @deactivate_coupons_url = URI.parse(Spl::UrlCreatorService.new(store.private_metadata['spl_url']).coupon_deactivate)
+ @user = user
+ @coupon_code = coupon_code
+ @retry_counter = 0
+ end
+
+ def call
+ return unless @user.present? && @user.private_metadata.present?
+ return unless logged_user?(@user)
+
+ response = send_request(@deactivate_coupons_url, body)
+ response_body = JSON.parse(response.body)
+ Rails.logger.debug response_body
+ raise DeactivateCouponServiceError, response_body['msg'] if response_body['errorCode'] != '0'
+
+ response_body['response']
+ rescue DeactivateCouponServiceError => e
+ raise e unless token_refresh_needed(response_body, @retry_counter, @user, @store)
+
+ @retry_counter += 1
+ retry
+ end
+
+ private
+
+ def body
+ {
+ context: {
+ prgCode: @store.private_metadata['spl_prg_code'],
+ oauthToken: @user.private_metadata['spl_access_token']
+ },
+ couponCode: @coupon_code
+ }
+ end
+ end
+ end
+end
diff --git a/app/services/spl/coupons/get_coupons_service.rb b/app/services/spl/coupons/get_coupons_service.rb
new file mode 100644
index 0000000..1d7193a
--- /dev/null
+++ b/app/services/spl/coupons/get_coupons_service.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'json'
+
+module Spl
+ module Coupons
+ class GetCouponsService
+ class SplGetCouponError < StandardError; end
+ include SplServiceHelper
+ include ErrorHandlingHelper
+ include LoginCheckHelper
+
+ def initialize(user, store)
+ @store = store
+ @find_coupons_url = URI.parse(Spl::UrlCreatorService.new(store.private_metadata['spl_url']).coupon_find)
+ @user = user
+ @retry_counter = 0
+ end
+
+ def call
+ return unless @user.present? && @user.private_metadata.present?
+ return unless logged_user?(@user)
+
+ response = send_request(@find_coupons_url, body)
+ response_body = JSON.parse(response.body)
+ Rails.logger.debug response_body
+ raise SplGetCouponError, response_body['msg'] if response_body['errorCode'] != '0'
+
+ filtered_coupons(response_body)
+ rescue SplGetCouponError => e
+ raise e unless token_refresh_needed(response_body, @retry_counter, @user, @store)
+
+ @retry_counter += 1
+ retry
+ end
+
+ private
+
+ def body
+ {
+ context: {
+ prgCode: @store.private_metadata['spl_prg_code'],
+ oauthToken: @user.private_metadata['spl_access_token']
+ },
+ withArchival: true
+ }
+ end
+
+ def filtered_coupons(response_body)
+ response_body['response']&.filter do |coupon|
+ active?(coupon)
+ end
+ end
+
+ def active?(coupon)
+ coupon['used'] != true && coupon['usageTemporaryBlocked'] != true && correct_time?(coupon['expirationDate']) &&
+ coupon['usageDisabled'] != true
+ end
+
+ def correct_time?(date)
+ return Time.zone.parse(date).future? unless date.nil?
+
+ true
+ end
+ end
+ end
+end
diff --git a/app/services/spl/error_payload_parser.rb b/app/services/spl/error_payload_parser.rb
new file mode 100644
index 0000000..5172685
--- /dev/null
+++ b/app/services/spl/error_payload_parser.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Spl
+ class ErrorPayloadParser
+ def self.parse(message)
+ str = message.to_s.strip
+ return nil unless str.start_with?('{') && str.end_with?('}')
+
+ inner = str[1..-2]
+ result = {}
+ inner.split(/,\s+/).each do |pair|
+ k, v = pair.split(/\s*=>\s*/, 2)
+ next unless k && v
+
+ key = unquote(k.strip)
+ value = parse_value(v.strip)
+ result[key] = value
+ end
+ result
+ rescue StandardError
+ nil
+ end
+
+ def self.parse_value(v)
+ return nil if v == 'nil'
+ return unquote(v) if v.start_with?('"') && v.end_with?('"')
+
+ v
+ end
+
+ def self.unquote(s)
+ s = s.strip
+ s = s[1..-2] if s.start_with?('"') && s.end_with?('"')
+ s.gsub('\"', '"').gsub('\\\\', '\\')
+ end
+
+ private_class_method :parse_value, :unquote
+ end
+end
diff --git a/app/services/spl/error_translator.rb b/app/services/spl/error_translator.rb
new file mode 100644
index 0000000..cb99160
--- /dev/null
+++ b/app/services/spl/error_translator.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Spl
+ class ErrorTranslator
+ I18N_BASE = 'spl.errors'
+
+ def self.translate(payload, locale: I18n.locale)
+ return translate_hash_payload(payload, locale) if payload.is_a?(Hash)
+
+ translate_error_payload(payload)
+ end
+
+ def self.translate_hash_payload(payload, locale)
+ code = payload['errorCode'].to_s.presence
+ key = "#{I18N_BASE}.#{code&.downcase}"
+ if code && I18n.exists?(key, locale)
+ I18n.t(key, locale:)
+ else
+ ::Spree::Spl.report_error(
+ 'Unknown Sparta error code',
+ error_code: code,
+ raw_msg: payload['msg'],
+ payload: payload
+ )
+
+ I18n.t("#{I18N_BASE}.generic", locale:)
+ end
+ end
+
+ def self.translate_error_payload(payload)
+ payload.message if payload.respond_to?(:message)
+ end
+
+ private_class_method :translate_hash_payload, :translate_error_payload
+ end
+end
diff --git a/app/services/spl/login_account_service.rb b/app/services/spl/login_account_service.rb
index 998e116..5308f10 100644
--- a/app/services/spl/login_account_service.rb
+++ b/app/services/spl/login_account_service.rb
@@ -5,15 +5,16 @@
module Spl
class LoginAccountService
class SplLoginAccountError < StandardError; end
+ include SplServiceHelper
def initialize(user, store, params)
@login_url = URI.parse(Spl::UrlCreatorService.new(store.private_metadata['spl_url']).login)
@user = user
@store = store
- @mobile_country = params.dig('user', 'public_metadata', 'mobile_country')
- @phone_number = params.dig('user', 'public_metadata', 'phone_number')
- @card_number = params.dig('user', 'public_metadata', 'card_number')
- @otp_code = params.dig('user', 'public_metadata', 'spl_auth_code')
+ @phone_number = user.phone
+ @card_number = params.dig('user', 'card_number')
+ @otp_code = params.dig('user', 'spl_auth_code')
+ @env = Spl::StorePrivateMetadataService.all(store)
end
def call
@@ -21,7 +22,7 @@ def call
response = send_request(@login_url, body)
response_body = JSON.parse(response.body)
Rails.logger.debug response_body
- raise SplLoginAccountError, response_body['msg'] if response_body['errorCode'] != '0'
+ raise SplLoginAccountError, response_body if response_body['errorCode'] != '0'
access_token = response_body.dig('response', 'oauthCode')
get_access_token(access_token)
@@ -29,16 +30,12 @@ def call
private
- def send_request(url, body)
- Spl::SendRequestService.new(url, body).call
- end
-
def prepare_login_body
{
context: {
- prgCode: @store.private_metadata['spl_prg_code']
+ prgCode: @env['spl_prg_code']
},
- apiUser: @store.private_metadata['spl_api_user'],
+ apiUser: @env['spl_api_user'],
scope: ['spl_cwp'],
responseType: 'code',
login: generate_login,
@@ -52,18 +49,18 @@ def encrypted_otp
end
def generate_login
- login = "#{@mobile_country}#{@phone_number}" if @card_number.nil?
- login = @card_number if @mobile_country.nil? || @phone_number.nil?
+ login = @phone_number if @card_number.nil?
+ login = @card_number if @phone_number.nil?
login
end
def get_access_token(access_token)
token_body = Spl::OauthTokenService.new(DateTime.current, @store).authorization_code_token(access_token)
- add_loyalty_tokents_to_user(token_body['accessToken'], token_body['refreshToken'])
+ add_loyalty_tokens_to_user(token_body['accessToken'], token_body['refreshToken'])
end
- def add_loyalty_tokents_to_user(access_token, refresh_token)
+ def add_loyalty_tokens_to_user(access_token, refresh_token)
@user.update!(private_metadata: { spl_access_token: access_token,
spl_refresh_token: refresh_token })
end
diff --git a/app/services/spl/me_service.rb b/app/services/spl/me_service.rb
index b38a6d0..7807320 100644
--- a/app/services/spl/me_service.rb
+++ b/app/services/spl/me_service.rb
@@ -5,33 +5,40 @@
module Spl
class MeService
class SplMeError < StandardError; end
+ include SplServiceHelper
+ include ErrorHandlingHelper
+ include LoginCheckHelper
def initialize(user, store)
@me_url = URI.parse(Spl::UrlCreatorService.new(store.private_metadata['spl_url']).me)
@user = user
- @store = store
+ @env = Spl::StorePrivateMetadataService.all(store)
end
def call
- body = prepare_me_body
+ return unless @user.present? && @user.private_metadata.present?
+ return unless logged_user?(@user)
+
+ retry_counter ||= 0
response = send_request(@me_url, body)
response_body = JSON.parse(response.body)
Rails.logger.debug response_body
- raise SplMeError, response_body['msg'] if response_body['errorCode'] != '0'
+ raise SplMeError, response_body if response_body['errorCode'] != '0'
response_body
+ rescue SplMeError => e
+ raise e unless token_refresh_needed(response_body, @retry_counter, @user, @store)
+
+ retry_counter += 1
+ retry
end
private
- def send_request(url, body)
- Spl::SendRequestService.new(url, body).call
- end
-
- def prepare_me_body
+ def body
{
context: {
- prgCode: @store.private_metadata['spl_prg_code'],
+ prgCode: @env['spl_prg_code'],
oauthToken: @user.private_metadata['spl_access_token']
}
}
diff --git a/app/services/spl/oauth_token_service.rb b/app/services/spl/oauth_token_service.rb
index d36b983..45cfaa3 100644
--- a/app/services/spl/oauth_token_service.rb
+++ b/app/services/spl/oauth_token_service.rb
@@ -5,10 +5,11 @@
module Spl
class OauthTokenService
class OauthTokenError < StandardError; end
+ include SplServiceHelper
def initialize(date, store)
@date = date.to_i * 1000
- @store = store
+ @env = Spl::StorePrivateMetadataService.all(store)
@token_url = URI.parse(Spl::UrlCreatorService.new(store.private_metadata['spl_url']).oauth_token)
end
@@ -17,7 +18,7 @@ def annonymus_token
response = send_request(@token_url, body)
response_body = JSON.parse(response.body)
- raise OauthTokenError, response_body['msg'] if response_body['errorCode'] != '0'
+ raise OauthTokenError, response_body if response_body['errorCode'] != '0'
response_body
end
@@ -27,24 +28,30 @@ def authorization_code_token(auth_code)
response = send_request(@token_url, body)
response_body = JSON.parse(response.body)
- raise OauthTokenError, response_body['msg'] if response_body['errorCode'] != '0'
+ raise OauthTokenError, response_body if response_body['errorCode'] != '0'
response_body['response']
end
- private
+ def refresh_token(token)
+ body = prepare_refresh_token_body(token)
+ response = send_request(@token_url, body)
+ response_body = JSON.parse(response.body)
+
+ raise OauthTokenError, response_body if response_body['errorCode'] != '0'
- def send_request(url, body)
- Spl::SendRequestService.new(url, body).call
+ response_body['response']
end
+ private
+
def prepare_oauth_token_body_with_signature
{
context: {
- prgCode: @store.private_metadata['spl_prg_code']
+ prgCode: @env['spl_prg_code']
},
- apiUser: @store.private_metadata['spl_api_user'],
- apiToken: @store.private_metadata['spl_api_token'],
+ apiUser: @env['spl_api_user'],
+ apiToken: @env['spl_api_token'],
signature: generate_signature,
date: @date,
grantType: 'signature'
@@ -54,19 +61,31 @@ def prepare_oauth_token_body_with_signature
def prepare_oauth_token_body_with_oauth_code(auth_code)
{
context: {
- prgCode: @store.private_metadata['spl_prg_code']
+ prgCode: @env['spl_prg_code']
},
- apiUser: @store.private_metadata['spl_api_user'],
- apiToken: @store.private_metadata['spl_api_token'],
+ apiUser: @env['spl_api_user'],
+ apiToken: @env['spl_api_token'],
oauthCode: auth_code,
grantType: 'authorization_code'
}
end
+ def prepare_refresh_token_body(token)
+ {
+ context: {
+ prgCode: @env['spl_prg_code']
+ },
+ apiUser: @env['spl_api_user'],
+ apiToken: @env['spl_api_token'],
+ refreshToken: token,
+ grantType: 'refresh_token'
+ }
+ end
+
def generate_signature
Spl::ClientSignatureService.new(@date,
- @store.private_metadata['spl_api_token'],
- @store.private_metadata['spl_signature_seed']).call
+ @env['spl_api_token'],
+ @env['spl_signature_seed']).call
end
end
end
diff --git a/app/services/spl/register_account_service.rb b/app/services/spl/register_account_service.rb
index bea3e1a..e71c4fc 100644
--- a/app/services/spl/register_account_service.rb
+++ b/app/services/spl/register_account_service.rb
@@ -5,12 +5,14 @@
module Spl
class RegisterAccountService
class SplRegisterAccountError < StandardError; end
+ include SplServiceHelper
def initialize(user, store, params)
@register_url = URI.parse(Spl::UrlCreatorService.new(store.private_metadata['spl_url']).register)
@user = user
@store = store
@params = params
+ @phone = PhoneParserService.new(user.phone)
end
def call
@@ -21,19 +23,16 @@ def call
register_response = send_request(@register_url, register_body)
register_response_body = JSON.parse(register_response.body)
Rails.logger.debug register_response_body
- raise SplRegisterAccountError, register_response_body['msg'] if register_response_body['errorCode'] != '0'
+ raise SplRegisterAccountError, register_response_body if register_response_body['errorCode'] != '0'
spl_card = register_response_body.dig('response', 'cardNo')
- update_account(spl_card, @params)
+ update_account(spl_card, oauth_response_body)
end
private
- def send_request(url, body)
- Spl::SendRequestService.new(url, body).call
- end
-
def prepare_registration_body(access_token) # rubocop:disable Metrics/MethodLength
+ accept_yc_terms = @user.public_metadata['accept_yc_terms']
{
context: {
oauthToken: access_token,
@@ -41,28 +40,28 @@ def prepare_registration_body(access_token) # rubocop:disable Metrics/MethodLeng
},
person: {
firstName: @user[:first_name],
- lastName: @user[:first_name],
+ lastName: @user[:last_name],
email: @user[:email],
- mobileCountry: @params.dig('user', 'public_metadata', 'mobileCountry'),
- mobile: @params.dig('user', 'public_metadata', 'phone_number'),
+ mobileCountry: @phone.country_code,
+ mobile: @phone.national_number,
permissions: {
- processData: @params.dig('user', 'public_metadata', 'splProcessData'),
- operationalSms: @params.dig('user', 'public_metadata', 'operationalSms')
+ processData: accept_yc_terms,
+ operationalSms: accept_yc_terms
}
},
- authCode: @params.dig('user', 'public_metadata', 'splAuthCode'),
+ authCode: @params,
partnerCode: @store.private_metadata['spl_partner_code'],
placeCode: @store.private_metadata['spl_place_code']
}
end
- def update_account(card_number, params)
- mobile_country = params.dig('user', 'public_metadata', 'mobileCountry')
- mobile_phone = params.dig('user', 'public_metadata', 'phone_number')
- @user.update(public_metadata: @user.public_metadata.merge(spl_no_card: card_number,
- spl_card_active: true,
- mobile_country: mobile_country,
- phone_number: mobile_phone))
+ def update_account(card_number, oauth_response_body)
+ @user.private_metadata ||= {} if @user.private_metadata.blank?
+ @user.update(public_metadata: @user.public_metadata.merge(spl_no_card: card_number, spl_card_active: true),
+ private_metadata: @user.private_metadata.merge(
+ spl_access_token: oauth_response_body.dig('response', 'accessToken'),
+ spl_refresh_token: oauth_response_body.dig('response', 'refreshToken')
+ ))
end
end
end
diff --git a/app/services/spl/request_otp_service.rb b/app/services/spl/request_otp_service.rb
index c41303f..f0a9ca1 100644
--- a/app/services/spl/request_otp_service.rb
+++ b/app/services/spl/request_otp_service.rb
@@ -5,6 +5,7 @@
module Spl
class RequestOtpService
class SplRequestOtpError < StandardError; end
+ include SplServiceHelper
def initialize(date, store, params)
@date = date.to_i * 1000
@@ -23,17 +24,13 @@ def call
request_otp_response = send_request(@request_otp_url, request_otp_body)
request_otp_response_body = JSON.parse(request_otp_response.body)
Rails.logger.debug request_otp_response_body
- raise SplSendOtpCodeError, request_otp_response_body['msg'] if request_otp_response_body['errorCode'] != '0'
+ raise SplRequestOtpError, request_otp_response_body if request_otp_response_body['errorCode'] != '0'
request_otp_response_body
end
private
- def send_request(url, body)
- Spl::SendRequestService.new(url, body).call
- end
-
def prepare_sms_otp_body(access_token)
{
context: {
diff --git a/app/services/spl/send_otp_service.rb b/app/services/spl/send_otp_service.rb
index a3be995..b1e77e7 100644
--- a/app/services/spl/send_otp_service.rb
+++ b/app/services/spl/send_otp_service.rb
@@ -5,13 +5,14 @@
module Spl
class SendOtpService
class SplSendOtpError < StandardError; end
+ include SplServiceHelper
def initialize(date, mobile_country, phone_number, store)
@date = date.to_i * 1000
@send_otp_url = URI.parse(Spl::UrlCreatorService.new(store.private_metadata['spl_url']).send_otp)
@mobile_country = mobile_country
@phone_number = phone_number
- @store = store
+ @env = Spl::StorePrivateMetadataService.all(store)
end
def call
@@ -19,25 +20,21 @@ def call
response = send_request(@send_otp_url, body)
response_body = JSON.parse(response.body)
Rails.logger.debug response_body
- raise SplSendOtpError, response_body['msg'] if response_body['errorCode'] != '0'
+ raise SplSendOtpError, response_body if response_body['errorCode'] != '0'
response_body
end
private
- def send_request(url, body)
- Spl::SendRequestService.new(url, body).call
- end
-
- def prepare_sms_otp_body # rubocop:disable Metrics/MethodLength
+ def prepare_sms_otp_body
{
context: {
- prgCode: @store.private_metadata['spl_prg_code']
+ prgCode: @env['spl_prg_code']
},
- apiUser: @store.private_metadata['spl_api_user'],
- apiToken: @store.private_metadata['spl_api_token'],
- signatureSeed: @store.private_metadata['spl_signature_seed'],
+ apiUser: @env['spl_api_user'],
+ apiToken: @env['spl_api_token'],
+ signatureSeed: @env['spl_signature_seed'],
date: @date,
mobileCountry: @mobile_country,
mobile: @phone_number,
@@ -47,8 +44,8 @@ def prepare_sms_otp_body # rubocop:disable Metrics/MethodLength
def generate_signature
Spl::ClientSignatureService.new(@date,
- @store.private_metadata['spl_api_token'],
- @store.private_metadata['spl_signature_seed']).call
+ @env['spl_api_token'],
+ @env['spl_signature_seed']).call
end
end
end
diff --git a/app/services/spl/sparta_loyalty_service.rb b/app/services/spl/sparta_loyalty_service.rb
index a765a15..5989cd5 100644
--- a/app/services/spl/sparta_loyalty_service.rb
+++ b/app/services/spl/sparta_loyalty_service.rb
@@ -7,6 +7,7 @@
module Spl
class SpartaLoyaltyService
class SplSendRequestError < StandardError; end
+ include SplServiceHelper
def initialize(order_token, card_number, line_items, date, products, check_only, store)
@order_token = order_token
@@ -22,7 +23,7 @@ def initialize(order_token, card_number, line_items, date, products, check_only,
def call
Rails.logger.debug 'SPL LOYALTY SERVICE START'
basket_body = prepare_basket_body
- response = send_request(basket_body)
+ response = send_request(@url, basket_body)
return unless response.is_a?(Net::HTTPSuccess)
response_body = JSON.parse(response.body)
@@ -35,10 +36,6 @@ def call
private
- def send_request(body)
- Spl::SendRequestService.new(@url, body).call
- end
-
def prepare_basket_body # rubocop:disable Metrics/MethodLength
{
ver: 4,
@@ -46,7 +43,7 @@ def prepare_basket_body # rubocop:disable Metrics/MethodLength
apiToken: @store.private_metadata['spl_api_token'],
partnerCode: @store.private_metadata['spl_partner_code'],
placeCode: @store.private_metadata['spl_place_code'],
- mode: @store.private_metadata['spl_mode'],
+ # mode: @store.private_metadata['spl_mode'],
date: @date,
no: @order_token,
orderNo: @order_token,
diff --git a/app/services/spl/store_private_metadata_service.rb b/app/services/spl/store_private_metadata_service.rb
new file mode 100644
index 0000000..13a0efb
--- /dev/null
+++ b/app/services/spl/store_private_metadata_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Spl
+ module StorePrivateMetadataService
+ module_function
+
+ def all(store)
+ (store&.private_metadata || {}).deep_dup
+ end
+
+ def fetch(store, key, default: nil)
+ all(store).fetch(key.to_s, default)
+ end
+ end
+end
diff --git a/app/services/spl/url_creator_service.rb b/app/services/spl/url_creator_service.rb
index ff96a48..6f0195d 100644
--- a/app/services/spl/url_creator_service.rb
+++ b/app/services/spl/url_creator_service.rb
@@ -6,10 +6,12 @@ def initialize(url)
@url = url
end
- def check_card
- "#{eshop_base}/checkCard"
+ ## Client data
+ def me
+ "#{client_base}/me"
end
+ ## Promotions data
def find
"#{eshop_base}/find"
end
@@ -22,8 +24,24 @@ def sale_refund
"#{eshop_base}/saleRefund"
end
- def me
- "#{client_base}/me"
+ ## Coupons
+
+ def coupon_find
+ "#{coupon_base}/find"
+ end
+
+ def coupon_activate
+ "#{coupon_base}/activate"
+ end
+
+ def coupon_deactivate
+ "#{coupon_base}/deactivate"
+ end
+
+ ## Authentication and Authorization
+
+ def check_card
+ "#{eshop_base}/checkCard"
end
def register
@@ -59,5 +77,9 @@ def eshop_base
def oauth_base
"#{@url}/api/oauth"
end
+
+ def coupon_base
+ "#{@url}/api/cwp/coupon"
+ end
end
end
diff --git a/app/services/spl/validate_card_service.rb b/app/services/spl/validate_card_service.rb
index 0652350..fcdea46 100644
--- a/app/services/spl/validate_card_service.rb
+++ b/app/services/spl/validate_card_service.rb
@@ -47,6 +47,7 @@ def check_for_errors(response_body)
'status') != 'A'
end
+ # @todo: refactor this and build_post_request method usages to use Spl::SendRequestService instead
def verify_card_request
url = URI.parse(Spl::UrlCreatorService.new(@store.private_metadata['spl_url']).check_card)
http = Net::HTTP.new(url.host, url.port)
@@ -63,7 +64,7 @@ def build_post_request(url, body)
request
end
- def body(card_number, date) # rubocop: disable Metrics/MethodLength
+ def body(card_number, date)
date_in_ms = date.to_i * 1000
uuid = SecureRandom.uuid
{
@@ -81,7 +82,7 @@ def body(card_number, date) # rubocop: disable Metrics/MethodLength
end
def signature(date, card_number)
- data = "#{@store.private_metadata['spl_partner_code']}#{@store.private_metadata['spl_place_code']}#{date}#{card_number}" # rubocop:disable Layout/LineLength
+ data = "#{@store.private_metadata['spl_partner_code']}#{@store.private_metadata['spl_place_code']}#{date}#{card_number}"
Rails.logger.debug data.inspect
signature_base = Digest::SHA256.hexdigest(data)
Digest::SHA256.hexdigest(signature_base + @store.private_metadata['spl_pos_key'])
diff --git a/app/views/spl/_loyalty_connect_button.html.erb b/app/views/spl/_loyalty_connect_button.html.erb
new file mode 100644
index 0000000..cf3f4d2
--- /dev/null
+++ b/app/views/spl/_loyalty_connect_button.html.erb
@@ -0,0 +1,21 @@
+
+
+
+ <%= render 'spl/loyalty_connect_form' %>
+
+
+
diff --git a/app/views/spl/_loyalty_connect_form.html.erb b/app/views/spl/_loyalty_connect_form.html.erb
new file mode 100644
index 0000000..9aee054
--- /dev/null
+++ b/app/views/spl/_loyalty_connect_form.html.erb
@@ -0,0 +1,69 @@
+<%= turbo_frame_tag "loyalty_connect_form" do %>
+ <%= render partial: 'spree/shared/error_messages', locals: { target: @user } %>
+
+
+
+ <%= check_box_tag :has_account, '1', true, id: 'has_account_checkbox', class: 'mt-1',
+ data: { login_spl_target: "checkbox", action: "login-spl#toggle" }%>
+ <%= label_tag :has_account, I18n.t('spl.user.have_account'), class: 'text-sm text-neutral-700' %>
+
+
+
+
+
+
+<% end %>
diff --git a/app/views/spl/_otp_code_form.html.erb b/app/views/spl/_otp_code_form.html.erb
new file mode 100644
index 0000000..5a7e44a
--- /dev/null
+++ b/app/views/spl/_otp_code_form.html.erb
@@ -0,0 +1,16 @@
+<%= turbo_frame_tag "otp_code_form" do %>
+ <%= render partial: "spree/shared/error_messages", locals: { target: user } %>
+ <%= form_with scope: :user, url: connect_loyalty_account_account_profile_path, method: :post, data: { turbo_frame: "otp_code_form" } do |f| %>
+
+
+ <%= I18n.t("spl.user.sent_to", phone: phone_e164) if phone_e164.present? %>
+
+
+
+ <%= f.label :spl_auth_code, I18n.t("spl.user.code_label"), class: "text-xs text-neutral-600 block mb-2" %>
+ <%= f.text_field :spl_auth_code, class: "text-input w-full" %>
+
+
+ <%= f.submit I18n.t("buttons.verify_button"), class: "btn-primary w-full" %>
+ <% end %>
+<% end %>
diff --git a/app/views/spl/_otp_registration_form.html.erb b/app/views/spl/_otp_registration_form.html.erb
new file mode 100644
index 0000000..6ac44ee
--- /dev/null
+++ b/app/views/spl/_otp_registration_form.html.erb
@@ -0,0 +1,18 @@
+<%= turbo_frame_tag "otp_registration_form" do %>
+ <%= render partial: "spree/shared/error_messages", locals: { target: user } %>
+ <%= form_with scope: :user, url: register_loyalty_account_account_profile_path, method: :post, data: { turbo_frame: "otp_registration_form" } do |f| %>
+
+
+ <%= I18n.t("spl.user.sent_to", phone: phone_e164) if phone_e164.present? %>
+
+
+
+ <%= f.label :spl_auth_code, I18n.t("spl.user.code_label"), class: "text-xs text-neutral-600 block mb-2" %>
+ <%= f.text_field :spl_auth_code, class: "text-input w-full" %>
+
+
+ <%= hidden_field_tag user.phone %>
+
+ <%= f.submit I18n.t("buttons.verify_button"), class: "btn-primary w-full" %>
+ <% end %>
+<% end %>
diff --git a/app/views/spree/checkout/_spl_coupons.html.erb b/app/views/spree/checkout/_spl_coupons.html.erb
new file mode 100644
index 0000000..f435677
--- /dev/null
+++ b/app/views/spree/checkout/_spl_coupons.html.erb
@@ -0,0 +1,32 @@
+
+
+
<%= I18n.t('spl.coupons.find.checkout_list') if @coupons.present? %>
+
+ <% @coupons&.each do |coupon| %>
+ <%= coupon["typeCustomerName"] %>
+ <% if coupon.dig("couponType", "autoLoad") == "A" %>
+ <% if coupon["autoLoad"] %>
+ <%= button_to I18n.t('spl.coupons.deactivate'),
+ deactivate_coupon_coupons_path(
+ order: @order,
+ token: @order.token,
+ coupon_code: coupon["code"]
+ ),
+ method: :post,
+ data: { turbo: true },
+ class: "min-h-full ml-3 btn-primary !px-4 !py-3" %>
+ <% else %>
+ <%= button_to I18n.t('spl.coupons.activate'),
+ activate_coupon_coupons_path(
+ order: @order,
+ token: @order.token,
+ coupon_code: coupon["code"]
+ ),
+ method: :post,
+ data: { turbo: true },
+ class: "min-h-full ml-3 btn-primary !px-4 !py-3" %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+
diff --git a/app/views/spree/checkout/activate_coupon.turbo_stream.erb b/app/views/spree/checkout/activate_coupon.turbo_stream.erb
new file mode 100644
index 0000000..aa53e8b
--- /dev/null
+++ b/app/views/spree/checkout/activate_coupon.turbo_stream.erb
@@ -0,0 +1,4 @@
+<%= turbo_stream.replace "checkout_coupons" do %>
+ <%= render partial: "spree/checkout/spl_coupons",
+ locals: { coupons: @coupons, order: @order } %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/spree/checkout/deactivate_coupon.turbo_stream.erb b/app/views/spree/checkout/deactivate_coupon.turbo_stream.erb
new file mode 100644
index 0000000..03de1fa
--- /dev/null
+++ b/app/views/spree/checkout/deactivate_coupon.turbo_stream.erb
@@ -0,0 +1,4 @@
+<%= turbo_stream.replace "checkout_coupons" do %>
+ <%= render partial: "spree/checkout/spl_coupons",
+ locals: { coupons: @coupons, order: @order } %>
+<% end %>
diff --git a/config/initializers/spree_spl.rb b/config/initializers/spree_spl.rb
index f1f4273..f1919c3 100644
--- a/config/initializers/spree_spl.rb
+++ b/config/initializers/spree_spl.rb
@@ -17,6 +17,10 @@
Spl::Spree::Api::AccountControllerDecorator
)
+ ::Spree::CheckoutController.prepend(
+ Spl::Spree::Storefront::CheckoutControllerDecorator
+ )
+
::Spree::Adjustable::AdjustmentsUpdater.prepend(
Spree::Adjustable::AdjustmentsUpdaterDecorator
)
diff --git a/config/locales/cs.yml b/config/locales/cs.yml
index 739c2ab..00fd0bc 100644
--- a/config/locales/cs.yml
+++ b/config/locales/cs.yml
@@ -2,8 +2,46 @@ cs:
order:
loyalty_card_missing: K objednávce není přiřazena žádná věrnostní karta
spl:
+ user:
+ card_number: The club card number has been assigned to the account
+ card_active: SPL card activation
+ have_account: Do you have loyalty club account?
+ accept_yc_terms_label: I accept the terms and conditions
+ sent_to: The code was sent to %{phone}
+ code_label: One-time code
+ errors:
+ must_accept_yc_terms: You must accept the loyalty club terms and conditions to continue
+ invalid_phone: Invalid phone number
card_validation:
errors:
card_not_active: Karta není aktivní
card_not_match: Karta neodpovídá číslu karty uživatele
wrong_owner: Karta je přiřazena jinému uživateli
+ coupons:
+ find:
+ checkout_list: Your SPL coupons
+ activate: Activate
+ deactivate: Deactivate
+ profile:
+ connect_button: Connect to your loyalty account
+ errors:
+ generic: We couldn't connect your loyalty account. Please try again.
+ auth_temporary_blocked: Too many failed attempts. Please try again later.
+ auth_failed: Authentication failed.
+ token_expired: Your session has expired. Please sign in again.
+ not_authorized: You are not authorized to perform this action.
+ coupon_cannot_be_modified: This coupon cannot be modified.
+ coupon_expired: This coupon has expired.
+ coupon_is_not_valid: This coupon is not valid.
+ coupon_already_used: This coupon has already been used.
+ temporary_blocked: Temporarily blocked due to too many attempts. Please try again later.
+ person_not_found: Person not found.
+ unknow_error: NEZNÁMÁ_CHYBA
+ buttons:
+ connect_account: Connect loyalty account
+ send_log_in_code: Send one-time code for log in
+ send_registration_code: Send one-time code for registration
+ verify_button: Verify phone number
+ user:
+ card_number: The club card number has been assigned to the account
+ card_active: SPL card activation
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6438221..3c3759d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2,11 +2,43 @@ en:
order:
loyalty_card_missing: There is no loyalty card assigned to order
spl:
+ user:
+ card_number: The club card number has been assigned to the account
+ card_active: SPL card activation
+ have_account: Do you have loyalty club account?
+ accept_yc_terms_label: I accept the terms and conditions
+ sent_to: The code was sent to %{phone}
+ code_label: One-time code
+ errors:
+ must_accept_yc_terms: You must accept the loyalty club terms and conditions to continue
+ invalid_phone: Invalid phone number
card_validation:
errors:
card_not_active: Card is not active
card_not_match: Card doesnt match user's card number
wrong_owner: Card assigned to a different user
- user:
- card_number: The club card number has been assigned to the account
- card_active: SPL card activation
+ coupons:
+ find:
+ checkout_list: Your SPL coupons
+ activate: Activate
+ deactivate: Deactivate
+ profile:
+ connect_button: Connect to your loyalty account
+ errors:
+ generic: We couldn't connect your loyalty account. Please try again.
+ auth_temporary_blocked: Too many failed attempts. Please try again later.
+ auth_failed: Authentication failed.
+ token_expired: Your session has expired. Please sign in again.
+ not_authorized: You are not authorized to perform this action.
+ coupon_cannot_be_modified: This coupon cannot be modified.
+ coupon_expired: This coupon has expired.
+ coupon_is_not_valid: This coupon is not valid.
+ coupon_already_used: This coupon has already been used.
+ temporary_blocked: Temporarily blocked due to too many attempts. Please try again later.
+ person_not_found: Person not found.
+ unknow_error: UNKNOWN_ERROR
+ buttons:
+ connect_account: Connect loyalty account
+ send_log_in_code: Send one-time code for log in
+ send_registration_code: Send one-time code for registration
+ verify_button: Verify phone number
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index e3012c0..4cd854e 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -2,11 +2,45 @@ pl:
order:
loyalty_card_missing: Do zamówienia nie przypisano karty lojalnościowej
spl:
+ user:
+ card_number: Numer karty klubowej został przypisany do konta
+ card_active: Aktywacja karty SPL
+ have_account: Czy masz konto w klubie lojalnościowym?
+ accept_yc_terms_label: Akceptuje regulamin
+ sent_to: Wysłano kod na numer %{phone}
+ code_label: Jednorazowy kod
+ errors:
+ must_accept_yc_terms: Musisz zaakceptować regulamin klubu lojalnościowego
+ invalid_phone: Podaj poprawny numer telefonu
+ coupons:
+ find:
+ checkout_list: Twoje kupony lojalnościowe
card_validation:
errors:
card_not_active: Karta nie jest aktywna
card_not_match: Karta nie jest zgodna z numerem karty użytkownika
wrong_owner: Karta przypisana innemu użytkownikowi
- user:
- card_number: Numer karty klubowej został przypisany do konta
- card_active: Aktywacja karty SPL
+ coupons:
+ find:
+ checkout_list: Twoje kupony lojalnościowe
+ activate: Aktywuj
+ deactivate: Dezaktywuj
+ errors:
+ generic: Nie udało się wykonać akcji. Spróbuj ponownie.
+ auth_temporary_blocked: Zbyt wiele nieudanych prób. Spróbuj ponownie później.
+ auth_failed: Uwierzytelnienie nie powiodło się.
+ token_expired: Twoja sesja wygasła. Zaloguj się ponownie.
+ not_authorized: Nie masz uprawnień do wykonania tej operacji.
+ coupon_cannot_be_modified: Ten kupon nie może zostać zmodyfikowany.
+ coupon_expired: Ten kupon wygasł.
+ coupon_is_not_valid: Ten kupon jest nieprawidłowy.
+ coupon_already_used: Ten kupon został już wykorzystany.
+ temporary_blocked: Tymczasowo zablokowano z powodu zbyt wielu prób. Spróbuj ponownie później.
+ person_not_found: Nie znaleziono użytkownika
+ unknow_error: NIEZNANY_BŁĄD
+ buttons:
+ connect_account: Połącz z kontem lojalnościowym
+ send_log_in_code: Wyślij jednorazowy kod do logowania
+ send_registration_code: Wyślij jednorazowy kod do rejestracji
+ verify_button: Zweryfikuj numer telefonu
+
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index dd62daf..2d0ea11 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -2,8 +2,46 @@ sk:
order:
loyalty_card_missing: K objednávke nie je priradená žiadna vernostná karta
spl:
+ user:
+ card_number: The club card number has been assigned to the account
+ card_active: SPL card activation
+ have_account: Do you have loyalty club account?
+ accept_yc_terms_label: I accept the terms and conditions
+ sent_to: The code was sent to %{phone}
+ code_label: One-time code
+ errors:
+ must_accept_yc_terms: You must accept the loyalty club terms and conditions to continue
+ invalid_phone: Invalid phone number
card_validation:
errors:
card_not_active: Karta nie je aktívna
card_not_match: Karta nezodpovedá číslu karty používateľa
wrong_owner: Karta je priradená inému používateľovi
+ coupons:
+ find:
+ checkout_list: Your SPL coupons
+ activate: Activate
+ deactivate: Deactivate
+ profile:
+ connect_button: Connect to your loyalty account
+ errors:
+ generic: We couldn't connect your loyalty account. Please try again.
+ auth_temporary_blocked: Too many failed attempts. Please try again later.
+ auth_failed: Authentication failed.
+ token_expired: Your session has expired. Please sign in again.
+ not_authorized: You are not authorized to perform this action.
+ coupon_cannot_be_modified: This coupon cannot be modified.
+ coupon_expired: This coupon has expired.
+ coupon_is_not_valid: This coupon is not valid.
+ coupon_already_used: This coupon has already been used.
+ temporary_blocked: Temporarily blocked due to too many attempts. Please try again later.
+ person_not_found: Person not found.
+ unknow_error: NEZNÁMA_CHYBA
+ buttons:
+ connect_account: Connect loyalty account
+ send_log_in_code: Send one-time code for log in
+ send_registration_code: Send one-time code for registration
+ verify_button: Verify phone number
+ user:
+ card_number: The club card number has been assigned to the account
+ card_active: SPL card activation
diff --git a/config/routes.rb b/config/routes.rb
index 1f06ae8..7be4eff 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -16,4 +16,16 @@
end
end
end
+ resource :checkout, as: 'coupons', controller: :checkout, only: %i[show create update] do
+ post :activate_coupon, to: 'checkout#activate_coupon'
+ post :deactivate_coupon, to: 'checkout#deactivate_coupon'
+ end
+ namespace :account do
+ resource :profile, controller: :profile, only: [:edit, :update] do
+ patch :registration_code
+ post :register_loyalty_account
+ patch :login_code
+ post :connect_loyalty_account
+ end
+ end
end
diff --git a/lib/generators/spl/install/install_generator.rb b/lib/generators/spl/install/install_generator.rb
new file mode 100644
index 0000000..ebaa5e3
--- /dev/null
+++ b/lib/generators/spl/install/install_generator.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Spl
+ module Generators
+ class InstallGenerator < Rails::Generators::Base
+ source_root File.expand_path('templates', __dir__)
+
+ desc 'Install SPL Stimulus controller'
+
+ def copy_stimulus_controller
+ destination = 'app/javascript/controllers/login_spl_controller.js'
+
+ if File.exist?(destination)
+ say_status :skip, "#{destination} already exists", :yellow
+ else
+ copy_file 'login_spl_controller.js', destination
+ say_status :create, destination, :green
+ end
+ end
+
+ def ensure_controllers_autoload
+ controllers_index = 'app/javascript/controllers/index.js'
+
+ unless File.exist?(controllers_index)
+ say_status :warning, 'Stimulus controllers index.js not found', :yellow
+ return
+ end
+
+ say_status :info, 'Stimulus controllers are auto-loaded', :blue
+ end
+ end
+ end
+end
diff --git a/lib/generators/spl/install/templates/login_spl_controller.js b/lib/generators/spl/install/templates/login_spl_controller.js
new file mode 100644
index 0000000..8e233ab
--- /dev/null
+++ b/lib/generators/spl/install/templates/login_spl_controller.js
@@ -0,0 +1,22 @@
+// This file is generated using the spree-spl gem.
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["checkbox", "loginForm", "registrationForm"]
+
+ connect() {
+ this.toggle()
+ }
+
+ toggle() {
+ if (!this.hasCheckboxTarget) return
+
+ if (this.checkboxTarget.checked) {
+ this.loginFormTarget.classList.remove("hidden")
+ this.registrationFormTarget.classList.add("hidden")
+ } else {
+ this.loginFormTarget.classList.add("hidden")
+ this.registrationFormTarget.classList.remove("hidden")
+ }
+ }
+}
diff --git a/lib/spree/spl/configuration.rb b/lib/spree/spl/configuration.rb
new file mode 100644
index 0000000..4b58aa0
--- /dev/null
+++ b/lib/spree/spl/configuration.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Spree
+ module Spl
+ class Configuration
+ attr_accessor :error_reporter
+
+ def initialize
+ @error_reporter = ->(message, extra = {}) { Rails.logger.warn("[SPL] #{message} #{extra.inspect}") }
+ end
+ end
+
+ def self.config = (@config ||= Configuration.new)
+ def self.configure = yield(config)
+
+ def self.report_error(message, extra = {})
+ config.error_reporter&.call(message, extra)
+ rescue StandardError => e
+ Rails.logger.error("[SPL] error_reporter failed: #{e.class}: #{e.message}")
+ end
+ end
+end
diff --git a/spec/controllers/checkout_controller_decorator_spec.rb b/spec/controllers/checkout_controller_decorator_spec.rb
index 8dd7f41..ad55ddd 100644
--- a/spec/controllers/checkout_controller_decorator_spec.rb
+++ b/spec/controllers/checkout_controller_decorator_spec.rb
@@ -2,127 +2,184 @@
require 'rails_helper'
-describe Spree::CheckoutController, type: :controller do
- let(:store) do
- create(:store, private_metadata: {
- 'spl_url' => 'https://fake-spl.example.com',
- 'spl_mode' => 'test',
- 'spl_pos_key' => 'fake-pos-key',
- 'spl_api_user' => 'fake-api-user',
- 'spl_prg_code' => 'fake-prg-code',
- 'spl_sale_url' => 'https://fake-spl.example.com/sale',
- 'spl_api_token' => 'fake-token',
- 'spl_place_code' => 'fake-place',
- 'spl_partner_code' => 'fake-partner',
- 'spl_signature_seed' => 'fake-seed'
- })
- end
+RSpec.describe Spl::Spree::Storefront::CheckoutControllerDecorator, type: :controller do
+ controller(ActionController::Base) do
+ before_action :set_order
+
+ def checkout_path
+ '/checkout'
+ end
+
+ def show
+ render plain: 'ok'
+ end
+
+ # Provided by decorator; defined here so Rails can dispatch
+
+ private
- let!(:state) { create(:state, country: create(:country_us), name: 'Gdansk', abbr: 'GDA') }
- let(:user) { create(:user, public_metadata: { 'spl_no_card' => '1234567890123', 'spl_card_active' => 'true' }) }
-
- let(:order) do
- create(
- :order_with_totals,
- store: store,
- user: user,
- email: 'test@example.com',
- public_metadata: {
- 'spl_no_card' => '1234567890123',
- 'spl_card_active' => 'true'
- }
- )
+ def set_order
+ @order = Spree::Order.first
+ end
end
before do
- allow(controller).to receive_messages(current_store: store, try_spree_current_user: user, spree_current_user: user,
- spree_signup_path: '/signup', spree_login_path: '/login')
-
- stub_request(:any, /fake-spl\.example\.com/).to_return(
- status: 200,
- body: {
- 'errorCode' => '0',
- 'response' => {
- 'basket' => order.line_items.map do |li|
- {
- 'pos' => li.id,
- 'discounts' => [
- {
- 'name' => 'TEST'
- }
- ],
- 'discountGross' => 5.0
- }
- end
- }
- }.to_json,
- headers: { 'Content-Type' => 'application/json' }
- )
-
- allow_any_instance_of(Spree::Order).to receive(:recalculate).and_return(true)
+ controller.class.prepend described_class
+ routes.draw do
+ get '/checkout', to: 'anonymous#show'
+ post '/checkout/activate_coupon', to: 'anonymous#activate_coupon'
+ post '/checkout/deactivate_coupon', to: 'anonymous#deactivate_coupon'
+ end
+ allow(request).to receive(:url).and_return('http://test.host/checkout')
+ end
+
+ let(:country) { create(:country) }
+ let!(:store) { create(:store, default_country: country) }
+
+ let!(:user) { create(:user) }
+ let!(:order) do
+ create(:order,
+ store: store,
+ user: user)
end
describe '#promotion_switcher' do
- context 'creates a new SPL adjustment' do
- it 'with SPL source_type' do
- post :update, params: { state: 'address', token: order.token }
+ it "calls PromotionSwitcherService with confirm=false when URL does not include 'confirm'" do
+ switcher = instance_double('PromotionSwitcherService', call: true)
+ expect(PromotionSwitcherService).to receive(:new).with(order, false).and_return(switcher)
- expect(order.line_items.last.adjustments.last.source_type).to eq('SPL')
- end
+ allow(Spl::Coupons::GetCouponsService)
+ .to receive(:new).with(user, store)
+ .and_return(instance_double('Spl::Coupons::GetCouponsService', call: []))
- it 'with amount from discountGross' do
- post :update, params: { state: 'address', token: order.token }
+ get :show
+ expect(response).to have_http_status(:ok)
+ end
- expect(order.line_items.last.adjustments.last.amount).to eq(-5.0)
- end
+ it "calls PromotionSwitcherService with confirm=true when URL includes 'confirm'" do
+ allow_any_instance_of(ActionDispatch::Request)
+ .to receive(:url)
+ .and_return('http://test.host/checkout/confirm')
+
+ switcher = instance_double('PromotionSwitcherService', call: true)
+ expect(PromotionSwitcherService).to receive(:new).with(order, true).and_return(switcher)
+
+ allow(Spl::Coupons::GetCouponsService)
+ .to receive(:new).with(user, store)
+ .and_return(instance_double('Spl::Coupons::GetCouponsService', call: []))
+
+ get :show
+ expect(response).to have_http_status(:ok)
end
+ end
- context 'when SPL API response has no line_items (unhappy path)' do
- before do
- stub_request(:any, /fake-spl\.example\.com/).to_return(
- status: 200,
- body: {
- 'errorCode' => '0',
- 'response' => {
- 'basket' => []
- }
- }.to_json,
- headers: { 'Content-Type' => 'application/json' }
- )
- end
+ describe '#load_user_coupons' do
+ it 'loads coupons for user/store and assigns @coupons' do
+ allow(PromotionSwitcherService)
+ .to receive(:new)
+ .and_return(instance_double('PromotionSwitcherService', call: true))
- it 'does NOT create SPL adjustments' do
- expect do
- post :update, params: { state: 'address', token: order.token }
- end.not_to(change { order.line_items.first.adjustments.count })
- end
+ coupons_service = instance_double('Spl::Coupons::GetCouponsService', call: %w[c1 c2])
+ expect(Spl::Coupons::GetCouponsService)
+ .to receive(:new).with(user, store).and_return(coupons_service)
+
+ get :show
+
+ expect(response).to have_http_status(:ok)
+ expect(assigns(:coupons)).to eq(%w[c1 c2])
end
- context 'when SPL API response has no discounts (unhappy path)' do
- before do
- stub_request(:any, /fake-spl\.example\.com/).to_return(
- status: 200,
- body: {
- 'errorCode' => '0',
- 'response' => {
- 'basket' => order.line_items.map do |li|
- {
- 'pos' => li.id,
- 'discounts' => [],
- 'discountGross' => 0.0
- }
- end
- }
- }.to_json,
- headers: { 'Content-Type' => 'application/json' }
- )
+ it 'does not run load_user_coupons as a before_action for activate_coupon' do
+ allow(PromotionSwitcherService)
+ .to receive(:new)
+ .and_return(instance_double('PromotionSwitcherService', call: true))
+
+ # This will still be called (inside #activate_coupon), but we can ensure it is not called twice.
+ coupons_service = instance_double('Spl::Coupons::GetCouponsService', call: [])
+ expect(Spl::Coupons::GetCouponsService).to receive(:new).with(user, store).once.and_return(coupons_service)
+
+ allow(Spl::Coupons::ActivateCouponService)
+ .to receive(:new)
+ .and_return(instance_double('Spl::Coupons::ActivateCouponService', call: true))
+
+ post :activate_coupon, params: { coupon_code: 'YOLO_90' }, format: :html
+ end
+ end
+
+ describe '#activate_coupon' do
+ let(:activate) { instance_double(Spl::Coupons::ActivateCouponService, call: true) }
+
+ before do
+ allow(controller).to receive(:try_spree_current_user).and_return(user)
+ end
+
+ context 'when coupon is valid' do
+ it 'calls ActivateCouponService, reloads coupons, and redirects (HTML)' do
+ expect(Spl::Coupons::ActivateCouponService)
+ .to receive(:new).with(user, store, 'YOLO_90').and_return(activate)
+
+ coupons_service = instance_double('Spl::Coupons::GetCouponsService', call: %w[YOLO_90])
+ expect(Spl::Coupons::GetCouponsService)
+ .to receive(:new).with(user, store).and_return(coupons_service)
+
+ post :activate_coupon, params: { coupon_code: 'YOLO_90' }, format: :html
+
+ expect(assigns(:coupons)).to eq(%w[YOLO_90])
+ expect(response).to have_http_status(:found)
+ expect(response.location).to include('/checkout')
end
+ end
+
+ context 'when coupon is invalid' do
+ it 'still redirects even if ActivateCouponService raises' do
+ expect(Spl::Coupons::ActivateCouponService).to receive(:new).with(user, store, 'YOLO_90').and_return(activate)
+ expect(activate).to receive(:call).and_raise(Spl::Coupons::ActivateCouponService::ActivateCouponServiceError,
+ 'some error')
- it 'does NOT create SPL adjustments' do
expect do
- post :update, params: { state: 'address', token: order.token }
- end.not_to(change { order.line_items.first.adjustments.count })
+ post :activate_coupon, params: { coupon_code: 'YOLO_90' }, format: :html
+ end.to raise_error('some error')
+
+ expect(response).to have_http_status(:found)
+ expect(response.location).to include('/checkout')
end
end
end
+
+ describe '#deactivate_coupon' do
+ before do
+ allow(controller).to receive(:try_spree_current_user).and_return(user)
+ end
+
+ it 'calls DeactivateCouponService, reloads coupons, and redirects (HTML)' do
+ deactivate = instance_double('Spl::Coupons::DeactivateCouponService', call: true)
+ expect(Spl::Coupons::DeactivateCouponService)
+ .to receive(:new).with(user, store, 'YOLO_90').and_return(deactivate)
+
+ coupons_service = instance_double('Spl::Coupons::GetCouponsService', call: %w[YOLO_90])
+ expect(Spl::Coupons::GetCouponsService)
+ .to receive(:new).with(user, store).and_return(coupons_service)
+
+ post :deactivate_coupon, params: { coupon_code: 'YOLO_90' }, format: :html
+
+ expect(assigns(:coupons)).to eq(%w[YOLO_90])
+ expect(response).to have_http_status(:found)
+ expect(response.location).to include('/checkout')
+ end
+
+ it 'still redirects even if DeactivateCouponService raises' do
+ deactivate = instance_double('Spl::Coupons::DeactivateCouponService')
+ expect(Spl::Coupons::DeactivateCouponService).to receive(:new).with(user, store, 'YOLO_90').and_return(deactivate)
+ expect(deactivate).to receive(:call).and_raise(
+ Spl::Coupons::DeactivateCouponService::DeactivateCouponServiceError, 'some error'
+ )
+
+ expect do
+ post :deactivate_coupon, params: { coupon_code: 'YOLO_90' }, format: :html
+ end.to raise_error('some error')
+
+ expect(response).to have_http_status(:found)
+ expect(response.location).to include('/checkout')
+ end
+ end
end
diff --git a/spec/controllers/spl/spree/account/profile_controller_decorator_spec.rb b/spec/controllers/spl/spree/account/profile_controller_decorator_spec.rb
new file mode 100644
index 0000000..51b8d69
--- /dev/null
+++ b/spec/controllers/spl/spree/account/profile_controller_decorator_spec.rb
@@ -0,0 +1,304 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spree::Account::ProfileController, type: :controller do
+ before do
+ stub_const('Spl::SendOtpService::SplSendOtpError', Class.new(StandardError)) unless defined?(Spl::SendOtpService::SplSendOtpError)
+ end
+
+ let(:country) { create(:country) }
+ let(:store) { create(:store, default_country: country) }
+ let!(:user) { create(:user, public_metadata: {}) }
+
+ let(:terms_accepted) { 'true' }
+ let(:phone_valid) { true }
+ let(:phone_value) { '+48500600700' }
+
+ let(:phone_double) do
+ instance_double(
+ 'PhoneParserService',
+ valid?: phone_valid,
+ country_code: '+48',
+ national_number: '500600700',
+ e164: '+48500600700'
+ )
+ end
+
+ let(:turbo_stream_helper) { instance_double('TurboStreamHelper') }
+
+ before do
+ allow(controller).to receive(:current_store).and_return(store)
+ allow(controller).to receive(:try_spree_current_user).and_return(user)
+ allow(controller).to receive(:login_code_params).and_return(
+ ActionController::Parameters.new(phone: phone_value, accept_yc_terms: terms_accepted).permit!
+ )
+ allow(controller).to receive(:phone_parser).and_return(phone_double)
+ allow(controller).to receive(:turbo_stream).and_return(turbo_stream_helper)
+ allow(turbo_stream_helper).to receive(:replace).and_return('')
+ allow(Spree::Spl).to receive(:report_error).and_return(nil)
+ user.errors.clear
+ end
+
+ describe '#validate_login_code_request (before_action logic)' do
+ context 'when YC terms are not accepted' do
+ let(:terms_accepted) { 'false' }
+
+ it 'adds error and renders 422 (does not touch OTP service)' do
+ expect(Spl::SendOtpService).not_to receive(:new)
+ expect(controller).to receive(:render).with(hash_including(status: :unprocessable_content))
+ controller.send(:validate_login_code_request)
+ expect(user.errors.full_messages.join(' ')).to include(I18n.t('spl.user.errors.must_accept_yc_terms'))
+ end
+ end
+
+ context 'when phone is invalid' do
+ let(:phone_valid) { false }
+
+ it 'adds phone error and renders 422 (does not touch OTP service)' do
+ expect(Spl::SendOtpService).not_to receive(:new)
+ expect(controller).to receive(:render).with(hash_including(status: :unprocessable_content))
+ controller.send(:validate_login_code_request)
+ expect(user.errors.full_messages.join(' ')).to include(I18n.t('spl.user.errors.invalid_phone'))
+ end
+ end
+
+ context 'when everything is valid' do
+ it 'does not render and does not add errors' do
+ expect(controller).not_to receive(:render)
+ controller.send(:validate_login_code_request)
+ expect(user.errors).to be_empty
+ end
+ end
+ end
+
+ describe '#login_code (service + success/rescue)' do
+ context 'when OTP service raises SplSendOtpError' do
+ it 'renders 422 and shows translated message as base error' do
+ service_instance = instance_double('Spl::SendOtpService')
+
+ payload = {
+ 'errorCode' => 'TEMPORARY_BLOCKED',
+ 'validationMessages' => nil,
+ 'fieldValidationMessages' => nil,
+ 'response' => nil,
+ 'msg' => 'Temporarily blocked (too much attempts)'
+ }
+
+ expect(Spl::SendOtpService).to receive(:new)
+ .with(kind_of(DateTime), '+48', '500600700', store)
+ .and_return(service_instance)
+
+ expect(service_instance).to receive(:call).and_raise(Spl::SendOtpService::SplSendOtpError.new(payload.inspect))
+
+ expect(controller).to receive(:render).with(hash_including(status: :unprocessable_content))
+
+ controller.login_code
+
+ expect(user.errors.full_messages.join(' '))
+ .to include(I18n.t('spl.errors.temporary_blocked'))
+ end
+ end
+
+ context 'when OTP is sent successfully' do
+ it 'calls OTP service, updates accept_yc_terms in public_metadata, and renders 200' do
+ service_instance = instance_double('Spl::SendOtpService')
+
+ expect(Spl::SendOtpService).to receive(:new)
+ .with(kind_of(DateTime), '+48', '500600700', store)
+ .and_return(service_instance)
+ expect(service_instance).to receive(:call)
+ expect(controller).to receive(:render).with(hash_including(status: :ok))
+ controller.login_code
+ expect(user.reload.public_metadata['accept_yc_terms']).to eq(true)
+ end
+ end
+ end
+
+ describe '#connect_loyalty_account (service + success/rescue)' do
+ let(:service_params) { { user: { spl_auth_code: '123456' } } }
+
+ before do
+ allow(controller).to receive(:params).and_return(service_params)
+ end
+
+ context 'when account is connected successfully' do
+ it 'calls LoginAccountService and AssignSpartaCardNumberService, then redirects with notice' do
+ login_service = instance_double(Spl::LoginAccountService)
+ assign_service = instance_double(AssignSpartaCardNumberService)
+
+ expect(Spl::LoginAccountService).to receive(:new)
+ .with(user, store, service_params)
+ .and_return(login_service)
+ expect(login_service).to receive(:call)
+
+ expect(AssignSpartaCardNumberService).to receive(:new)
+ .with(user, store)
+ .and_return(assign_service)
+ expect(assign_service).to receive(:call)
+
+ expect(controller).to receive(:redirect_to).with(
+ spree.edit_account_profile_path,
+ hash_including(notice: Spree.t(:successfully_updated, resource: Spree.t(:account)))
+ )
+
+ controller.connect_loyalty_account
+ end
+ end
+
+ shared_examples 'renders connect error' do |error_class|
+ it "renders 422 and adds translated base error for #{error_class}" do
+ payload = {
+ 'errorCode' => 'TEMPORARY_BLOCKED',
+ 'validationMessages' => nil,
+ 'fieldValidationMessages' => nil,
+ 'response' => nil,
+ 'msg' => 'Temporarily blocked (too much attempts)'
+ }
+
+ phone_obj = double('Phone', e164: '+48500600700')
+ allow(user).to receive(:phone).and_return(phone_obj)
+
+ login_service = instance_double(Spl::LoginAccountService)
+ allow(Spl::LoginAccountService).to receive(:new).and_return(login_service)
+ allow(login_service).to receive(:call)
+ .and_raise(error_class.new(payload.inspect))
+
+ expect(controller).to receive(:render).with(hash_including(status: :unprocessable_content))
+
+ controller.connect_loyalty_account
+
+ expect(user.errors.full_messages.join(' '))
+ .to include(I18n.t('spl.errors.temporary_blocked'))
+ end
+ end
+
+ context 'when LoginAccountService raises' do
+ include_examples 'renders connect error', Spl::LoginAccountService::SplLoginAccountError
+ end
+
+ context 'when AssignSpartaCardNumberService raises' do
+ include_examples 'renders connect error', AssignSpartaCardNumberService::AssignSpartaCardNumberError
+ end
+
+ context 'when MeService raises' do
+ include_examples 'renders connect error', Spl::MeService::SplMeError
+ end
+ end
+
+ describe '#registration_code (service + success/rescue)' do
+ before do
+ allow(controller).to receive(:params).and_return(
+ ActionController::Parameters.new(user: { phone: phone_value, accept_yc_terms: terms_accepted })
+ )
+ end
+
+ context 'when OTP request succeeds' do
+ it 'calls RequestOtpService, updates user, and renders 200' do
+ service_instance = instance_double(Spl::RequestOtpService)
+
+ expect(Spl::RequestOtpService).to receive(:new) do |date, passed_store, passed_params|
+ expect(date).to be_a(DateTime)
+ expect(passed_store).to eq(store)
+ expect(passed_params['mobile_country']).to eq('+48')
+ expect(passed_params['phone_number']).to eq('500600700')
+ end.and_return(service_instance)
+
+ expect(service_instance).to receive(:call)
+ expect(controller).to receive(:render).with(hash_including(status: :ok))
+
+ controller.registration_code
+
+ expect(user.reload.phone).to eq(phone_value)
+ expect(user.public_metadata['accept_yc_terms']).to eq(true)
+ end
+ end
+
+ shared_examples 'registration_code error' do |error_class|
+ it "renders 422 and adds translated base error for #{error_class}" do
+ payload = {
+ 'errorCode' => 'TEMPORARY_BLOCKED',
+ 'msg' => 'Temporarily blocked'
+ }
+
+ service_instance = instance_double(Spl::RequestOtpService)
+
+ allow(Spl::RequestOtpService).to receive(:new).and_return(service_instance)
+ allow(service_instance).to receive(:call).and_raise(error_class.new(payload.inspect))
+
+ expect(controller).to receive(:render).with(hash_including(status: :unprocessable_content))
+
+ controller.registration_code
+
+ expect(user.errors.full_messages.join(' ')).to include(I18n.t('spl.errors.temporary_blocked'))
+ end
+ end
+
+ context 'when RequestOtpService raises error' do
+ include_examples 'registration_code error', Spl::RequestOtpService::SplRequestOtpError
+ end
+
+ context 'when OauthTokenService raises error' do
+ include_examples 'registration_code error', Spl::OauthTokenService::OauthTokenError
+ end
+ end
+
+ describe '#register_loyalty_account (service + success/rescue)' do
+ let(:params_hash) { { 'user' => { 'spl_auth_code' => '123456' } } }
+
+ before do
+ allow(controller).to receive(:params).and_return(params_hash)
+ allow(controller).to receive(:spree_current_user).and_return(user)
+ end
+
+ context 'when registration succeeds' do
+ it 'calls RegisterAccountService and redirects with notice' do
+ service_instance = instance_double(Spl::RegisterAccountService)
+
+ expect(Spl::RegisterAccountService).to receive(:new)
+ .with(user, store, '123456')
+ .and_return(service_instance)
+
+ expect(service_instance).to receive(:call)
+
+ expect(controller).to receive(:redirect_to).with(
+ spree.edit_account_profile_path,
+ hash_including(notice: Spree.t(:successfully_updated, resource: Spree.t(:account)))
+ )
+
+ controller.register_loyalty_account
+ end
+ end
+
+ shared_examples 'register_loyalty_account error' do |error_class|
+ it "renders 422 and adds translated base error for #{error_class}" do
+ payload = {
+ 'errorCode' => 'TEMPORARY_BLOCKED',
+ 'msg' => 'Temporarily blocked'
+ }
+
+ phone_obj = double('Phone', e164: '+48500600700')
+ allow(user).to receive(:phone).and_return(phone_obj)
+
+ service_instance = instance_double(Spl::RegisterAccountService)
+
+ allow(Spl::RegisterAccountService).to receive(:new).and_return(service_instance)
+ allow(service_instance).to receive(:call).and_raise(error_class.new(payload.inspect))
+
+ expect(controller).to receive(:render).with(hash_including(status: :unprocessable_content))
+
+ controller.register_loyalty_account
+
+ expect(user.errors.full_messages.join(' ')).to include(I18n.t('spl.errors.temporary_blocked'))
+ end
+ end
+
+ context 'when RegisterAccountService raises error' do
+ include_examples 'register_loyalty_account error', Spl::RegisterAccountService::SplRegisterAccountError
+ end
+
+ context 'when OauthTokenService raises error' do
+ include_examples 'register_loyalty_account error', Spl::OauthTokenService::OauthTokenError
+ end
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index aece87c..306b42f 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -103,4 +103,5 @@
# arbitrary gems may also be filtered via:
# config.filter_gems_from_backtrace("gem name")
Rails.application.reload_routes!
+ Money.default_currency = 'PLN'
end
diff --git a/spec/services/apply_sparta_discount_service_spec.rb b/spec/services/apply_sparta_discount_service_spec.rb
index 58d0122..e893f1f 100644
--- a/spec/services/apply_sparta_discount_service_spec.rb
+++ b/spec/services/apply_sparta_discount_service_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-RSpec.describe ApplySpartaDiscountService do
+RSpec.describe ApplySpartaDiscountService, type: :service do
let(:country) { create(:country) }
let(:store) { create(:store, default_country: country) }
let(:order) { create(:order, store: store) }
diff --git a/spec/services/assign_sparta_card_number_service_spec.rb b/spec/services/assign_sparta_card_number_service_spec.rb
index 8f2f72f..c0769a9 100644
--- a/spec/services/assign_sparta_card_number_service_spec.rb
+++ b/spec/services/assign_sparta_card_number_service_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-RSpec.describe AssignSpartaCardNumberService do
+RSpec.describe AssignSpartaCardNumberService, type: :service do
let(:country) { create(:country) }
let(:store) { create(:store, default_country: country) }
let(:user) { create(:user, public_metadata: initial_public_metadata) }
@@ -47,18 +47,13 @@
describe '#call' do
context 'when main card is active and belongs to the user' do
- let(:initial_public_metadata) do
- { 'spl_no_card' => '5100179585157' }
- end
+ let(:initial_public_metadata) { { 'spl_no_card' => '5100179585157' } }
it 'assigns card number and marks card as active in public_metadata' do
- expect do
- service.call
- end.to change { user.reload.public_metadata }.from(
- { 'spl_no_card' => '5100179585157' }
- ).to(
- { 'spl_no_card' => '5100179585157', 'spl_card_active' => true }
- )
+ expect { service.call }
+ .to change { user.reload.public_metadata }
+ .from({ 'spl_no_card' => '5100179585157' })
+ .to({ 'spl_no_card' => '5100179585157', 'spl_card_active' => true })
end
end
diff --git a/spec/services/phone_parser_service_spec.rb b/spec/services/phone_parser_service_spec.rb
new file mode 100644
index 0000000..29c8a25
--- /dev/null
+++ b/spec/services/phone_parser_service_spec.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe PhoneParserService, type: :service do
+ describe '#initialize' do
+ it 'parses the raw phone with Phonelib and stores it as #phone' do
+ phone_double = instance_double('Phonelib::Phone')
+ expect(Phonelib).to receive(:parse).with('+48500600700').and_return(phone_double)
+
+ service = described_class.new('+48500600700')
+ expect(service.phone).to eq(phone_double)
+ end
+ end
+
+ describe '#valid?' do
+ before do
+ phone_double = instance_double('Phonelib::Phone', valid?: true)
+ allow(Phonelib).to receive(:parse).and_return(phone_double)
+ end
+
+ it 'delegates to the parsed phone object' do
+ expect(described_class.new('+48500600700')).to be_valid
+ end
+ end
+
+ context 'when phone is valid' do
+ subject(:service) { described_class.new('+48500600700') }
+
+ let(:phone_double) do
+ instance_double(
+ 'Phonelib::Phone',
+ valid?: true,
+ country_code: '48',
+ national_number: '500600700',
+ e164: '+48500600700'
+ )
+ end
+
+ describe '#country_code' do
+ it "returns country code with leading '+'" do
+ expect(service.country_code).to eq('+48')
+ end
+ end
+
+ describe '#national_number' do
+ it 'returns national number' do
+ expect(service.national_number).to eq('500600700')
+ end
+ end
+
+ describe '#e164' do
+ it 'returns the E.164 formatted number' do
+ expect(service.e164).to eq('+48500600700')
+ end
+ end
+
+ describe '#to_h' do
+ it 'returns a hash with country_code, national_number and e164' do
+ expect(service.to_h).to eq(
+ country_code: '+48',
+ national_number: '500600700',
+ e164: '+48500600700'
+ )
+ end
+ end
+ end
+
+ context 'when phone is invalid' do
+ subject(:service) { described_class.new('not-a-phone') }
+
+ let(:phone_double) do
+ instance_double(
+ 'Phonelib::Phone',
+ valid?: false,
+ country_code: nil,
+ national_number: nil,
+ e164: nil
+ )
+ end
+
+ before do
+ allow(Phonelib).to receive(:parse).with('not-a-phone').and_return(phone_double)
+ end
+
+ describe '#country_code' do
+ it 'returns nil' do
+ expect(service.country_code).to be_nil
+ end
+ end
+
+ describe '#national_number' do
+ it 'returns nil' do
+ expect(service.national_number).to be_nil
+ end
+ end
+
+ describe '#e164' do
+ it 'returns nil' do
+ expect(service.e164).to be_nil
+ end
+ end
+
+ describe '#to_h' do
+ it 'returns an empty hash' do
+ expect(service.to_h).to eq({})
+ end
+ end
+ end
+end
diff --git a/spec/services/promotion_switcher_service_spec.rb b/spec/services/promotion_switcher_service_spec.rb
index 2314b07..46c9670 100644
--- a/spec/services/promotion_switcher_service_spec.rb
+++ b/spec/services/promotion_switcher_service_spec.rb
@@ -2,7 +2,90 @@
require 'rails_helper'
-RSpec.describe PromotionSwitcherService do
+RSpec.describe PromotionSwitcherService, type: :service do
+ let(:country) { create(:country) }
+ let(:store) { create(:store, default_country: country) }
+ let(:order) do
+ create(
+ :order,
+ store:,
+ public_metadata: public_metadata
+ )
+ end
+
+ let(:check_only) { true }
+
+ let(:service) { described_class.new(order, check_only) }
+
+ let(:variant1) { create(:variant, sku: 'BS49252-BZ020-PSA000-000', price: 6.75) }
+ let(:variant2) { create(:variant, sku: 'BS49252-BZ020-PSA000-001', price: 7.73) }
+
+ let!(:line_item1) { create(:line_item, order:, variant: variant1, quantity: 1, price: 6.75) }
+ let!(:line_item2) { create(:line_item, order:, variant: variant2, quantity: 3, price: 7.73) }
+
+ let(:exemple_sparta_response) do
+ {
+ 'errorCode' => '0',
+ 'balanceBurn' => 0.0,
+ 'balanceEarn' => 0.0,
+ 'balanceAfter' => 0.12,
+ 'bookedEarn' => false,
+ 'processId' => '663c92b05012e0b396ac632b',
+ 'messages' => [],
+ 'basket' => [
+ {
+ 'productCode' => 'TESTPRD1',
+ 'productCode2' => nil,
+ 'quantity' => 1.0,
+ 'amountGross' => 6.75,
+ 'discountGross' => 0.0,
+ 'discountPercent' => nil,
+ 'unitPriceGross' => 6.75,
+ 'discounts' => nil,
+ 'isAward' => nil,
+ 'notPromoted' => nil,
+ 'skipCB' => nil,
+ 'skipDD' => nil,
+ 'skipRD' => nil,
+ 'pos' => 1
+ },
+ {
+ 'productCode' => 'TESTPRD4',
+ 'productCode2' => nil,
+ 'quantity' => 3.0,
+ 'amountGross' => 23.2,
+ 'discountGross' => 0.8,
+ 'discountPercent' => nil,
+ 'unitPriceGross' => 7.73,
+ 'discounts' => [
+ {
+ 'source' => 'LP',
+ 'amount' => 0.8,
+ 'percent' => 5.0,
+ 'code' => '663c926e5012e0b396ac6328',
+ 'name' => '5% discount for TESTPRD4',
+ 'order' => 1,
+ 'quantity' => 2.0,
+ 'unitPriceGrossDiscounted' => nil
+ }
+ ],
+ 'isAward' => nil,
+ 'notPromoted' => nil,
+ 'skipCB' => nil,
+ 'skipDD' => nil,
+ 'skipRD' => nil,
+ 'pos' => 2
+ }
+ ],
+ 'basketChanged' => true,
+ 'amountGross' => 29.95,
+ 'discountGross' => 0.8,
+ 'coupons' => [],
+ 'cardType' => { 'code' => 'DV' },
+ 'requestId' => '00003_LSHRV'
+ }
+ end
+
describe '#call' do
let(:country) { create(:country) }
let(:store) { create(:store, default_country: country) }
@@ -14,36 +97,6 @@
}
end
- let(:order) { create(:order, store: store, public_metadata: public_metadata) }
- let(:check_only) { true }
- let(:service) { described_class.new(order, check_only) }
-
- let(:variant1) { create(:variant, sku: 'TESTPRD1', price: 6.75) }
- let(:variant2) { create(:variant, sku: 'TESTPRD4', price: 7.73) }
-
- let!(:line_item1) { create(:line_item, order: order, variant: variant1, quantity: 1, price: 6.75) }
- let!(:line_item2) { create(:line_item, order: order, variant: variant2, quantity: 3, price: 7.73) }
-
- let(:example_sparta_response) do
- {
- 'errorCode' => '0',
- 'basket' => [
- { 'productCode' => 'TESTPRD1', 'quantity' => 1.0, 'amountGross' => 6.75, 'discountGross' => 0.0, 'pos' => 1 },
- {
- 'productCode' => 'TESTPRD4',
- 'quantity' => 3.0,
- 'amountGross' => 23.2,
- 'discountGross' => 0.8,
- 'discounts' => [
- { 'source' => 'LP', 'amount' => 0.8, 'percent' => 5.0, 'name' => '5% discount for TESTPRD4' }
- ],
- 'pos' => 2
- }
- ],
- 'discountGross' => 0.8
- }
- end
-
it 'calls Sparta loyalty, gets response and applies Sparta discounts' do
sparta_service_double = instance_double(Spl::SpartaLoyaltyService)
apply_service_double = instance_double(ApplySpartaDiscountService)
diff --git a/spec/services/remove_sparta_discount_service_spec.rb b/spec/services/remove_sparta_discount_service_spec.rb
index 1614616..2e335d0 100644
--- a/spec/services/remove_sparta_discount_service_spec.rb
+++ b/spec/services/remove_sparta_discount_service_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-RSpec.describe RemoveSpartaDiscountService do
+RSpec.describe RemoveSpartaDiscountService, type: :service do
let(:country) { create(:country) }
let(:store) { create(:store, default_country: country) }
let(:order) { create(:order, store: store) }
@@ -106,7 +106,7 @@ def self.call(order:, line_item: nil); end
let(:relation) { line_item1.adjustments.where(source_type: 'SPL') }
- it 'marks eligible SPL adjustments as ineligible & closed, recalculates cart, then destroys all given adjustments' do # rubocop:disable Layout/LineLength
+ it 'marks eligible SPL adjustments as ineligible & closed, recalculates cart, then destroys all given adjustments' do
expect(line_item1.adjustments.where(source_type: 'SPL').count).to eq(2)
expect(FakeCartRecalculateService).to receive(:call).with(order: order, line_item: line_item1).twice
diff --git a/spec/services/spl/coupons/activate_coupon_service_spec.rb b/spec/services/spl/coupons/activate_coupon_service_spec.rb
new file mode 100644
index 0000000..0569557
--- /dev/null
+++ b/spec/services/spl/coupons/activate_coupon_service_spec.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::Coupons::ActivateCouponService do
+ subject(:service) { described_class.new(user, store, params) }
+
+ let(:user) { create(:user) }
+ let(:store) { Spree::Store.default }
+
+ let(:spl_url) { 'https://spl.example.test' }
+ let(:prg_code) { 'PRG123' }
+ let(:access_token) { 'LPUXLUZYZ9JN8XLXSSCTZA4Y5LEFEX' }
+
+ let(:coupon_code) { 'ABC123' }
+ let(:params) { coupon_code }
+
+ let(:activate_url_string) { 'https://api.spl.example.test/coupons/activate' }
+ let(:activate_uri) { URI.parse(activate_url_string) }
+
+ let(:success_body_raw) do
+ {
+ 'errorCode' => '0',
+ 'validationMessages' => nil,
+ 'fieldValidationMessages' => nil,
+ 'response' => { 'returned' => nil },
+ 'msg' => 'OK'
+ }.to_json
+ end
+
+ let(:response) { instance_double('Net::HTTPResponse', body: success_body_raw) }
+
+ before do
+ allow(store).to receive(:private_metadata).and_return(
+ 'spl_url' => spl_url,
+ 'spl_prg_code' => prg_code
+ )
+ allow(user).to receive(:private_metadata).and_return(
+ 'spl_access_token' => access_token
+ )
+
+ url_creator = instance_double('Spl::UrlCreatorService', coupon_activate: activate_url_string)
+ allow(Spl::UrlCreatorService).to receive(:new).with(spl_url).and_return(url_creator)
+
+ allow(service).to receive(:send_request).and_return(response)
+ end
+
+ describe '#call' do
+ context "success (errorCode == '0')" do
+ it "sends request to coupon_activate URI with expected body and returns parsed 'response' hash" do
+ expected_body = {
+ context: {
+ prgCode: prg_code,
+ oauthToken: access_token
+ },
+ couponCode: coupon_code
+ }
+
+ expect(service).to receive(:send_request).with(activate_uri, expected_body).and_return(response)
+
+ expect(service.call).to eq({ 'returned' => nil })
+ end
+
+ it "succeeds even if validation messages are present (still errorCode '0')" do
+ body = {
+ 'errorCode' => '0',
+ 'validationMessages' => ['some warning'],
+ 'fieldValidationMessages' => { 'couponCode' => ['format warning'] },
+ 'response' => { 'returned' => nil },
+ 'msg' => 'OK'
+ }.to_json
+
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: body))
+
+ expect(service.call).to eq({ 'returned' => nil })
+ end
+
+ it "returns nil if 'response' key is missing" do
+ body = { 'errorCode' => '0', 'msg' => 'OK' }.to_json
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: body))
+
+ expect(service.call).to be_nil
+ end
+
+ it "returns nil if 'response' is null" do
+ body = { 'errorCode' => '0', 'response' => nil, 'msg' => 'OK' }.to_json
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: body))
+
+ expect(service.call).to be_nil
+ end
+ end
+
+ context "API failure (errorCode != '0')" do
+ it 'raises ActivateCouponServiceError with msg' do
+ body = { 'errorCode' => 'COUPON_CANNOT_BE_MODIFIED', 'msg' => 'Coupon invalid', 'response' => nil }.to_json
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: body))
+
+ expect { service.call }
+ .to raise_error(described_class::ActivateCouponServiceError, 'Coupon invalid')
+ end
+
+ it 'raises even if msg is missing' do
+ body = { 'errorCode' => 'COUPON_CANNOT_BE_MODIFIED' }.to_json
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: body))
+
+ expect { service.call }.to raise_error(described_class::ActivateCouponServiceError)
+ end
+ end
+
+ context 'bad/empty HTTP response' do
+ it 'raises JSON::ParserError for invalid JSON' do
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: 'not-json'))
+ expect { service.call }.to raise_error(JSON::ParserError)
+ end
+
+ it 'raises TypeError when response.body is nil (JSON.parse(nil))' do
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: nil))
+ expect { service.call }.to raise_error(TypeError)
+ end
+
+ it 'raises NoMethodError when send_request returns nil (nil.body)' do
+ allow(service).to receive(:send_request).and_return(nil)
+ expect { service.call }.to raise_error(NoMethodError)
+ end
+
+ it 'bubbles exceptions from send_request (e.g., timeouts)' do
+ allow(service).to receive(:send_request).and_raise(Timeout::Error)
+ expect { service.call }.to raise_error(Timeout::Error)
+ end
+ end
+ end
+end
diff --git a/spec/services/spl/coupons/deactivate_coupon_service_spec.rb b/spec/services/spl/coupons/deactivate_coupon_service_spec.rb
new file mode 100644
index 0000000..f9f0cd9
--- /dev/null
+++ b/spec/services/spl/coupons/deactivate_coupon_service_spec.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::Coupons::DeactivateCouponService do
+ subject(:service) { described_class.new(user, store, params) }
+
+ let(:user) { create(:user) }
+ let(:store) { Spree::Store.default }
+
+ let(:spl_url) { 'https://spl.example.test' }
+ let(:prg_code) { 'prg2' }
+ let(:access_token) { 'LPUXLUZYZ9JN8XLXSSCTZA4Y5LEFEX' }
+
+ let(:coupon_code) { 'COUPON' }
+ let(:params) { coupon_code }
+
+ let(:deactivate_url_string) { 'https://api.spl.example.test/coupons/deactivate' }
+ let(:deactivate_uri) { URI.parse(deactivate_url_string) }
+
+ let(:success_body_raw) do
+ {
+ 'errorCode' => '0',
+ 'validationMessages' => nil,
+ 'fieldValidationMessages' => nil,
+ 'response' => { 'returned' => nil },
+ 'msg' => 'OK'
+ }.to_json
+ end
+
+ let(:response) { instance_double('Net::HTTPResponse', body: success_body_raw) }
+
+ before do
+ allow(store).to receive(:private_metadata).and_return(
+ 'spl_url' => spl_url,
+ 'spl_prg_code' => prg_code
+ )
+ allow(user).to receive(:private_metadata).and_return(
+ 'spl_access_token' => access_token
+ )
+
+ url_creator = instance_double('Spl::UrlCreatorService', coupon_deactivate: deactivate_url_string)
+ allow(Spl::UrlCreatorService).to receive(:new).with(spl_url).and_return(url_creator)
+
+ allow(service).to receive(:send_request).and_return(response)
+ end
+
+ describe '#call' do
+ context "success (errorCode == '0')" do
+ it "sends request to coupon_deactivate URI with expected body and returns parsed 'response' hash" do
+ expected_body = {
+ context: {
+ prgCode: prg_code,
+ oauthToken: access_token
+ },
+ couponCode: coupon_code
+ }
+
+ expect(service).to receive(:send_request).with(deactivate_uri, expected_body).and_return(response)
+
+ expect(service.call).to eq({ 'returned' => nil })
+ end
+
+ it "succeeds even if validation messages are present (still errorCode '0')" do
+ body = {
+ 'errorCode' => '0',
+ 'validationMessages' => ['some warning'],
+ 'fieldValidationMessages' => { 'couponCode' => ['format warning'] },
+ 'response' => { 'returned' => nil },
+ 'msg' => 'OK'
+ }.to_json
+
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: body))
+
+ expect(service.call).to eq({ 'returned' => nil })
+ end
+
+ it "returns nil if 'response' key is missing" do
+ body = { 'errorCode' => '0', 'msg' => 'OK' }.to_json
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: body))
+
+ expect(service.call).to be_nil
+ end
+
+ it "returns nil if 'response' is null" do
+ body = { 'errorCode' => '0', 'response' => nil, 'msg' => 'OK' }.to_json
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: body))
+
+ expect(service.call).to be_nil
+ end
+ end
+
+ context "API failure (errorCode != '0')" do
+ it 'raises DeactivateCouponServiceError with msg' do
+ body = { 'errorCode' => 'COUPON_CANNOT_BE_MODIFIED', 'msg' => 'Coupon invalid', 'response' => nil }.to_json
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: body))
+
+ expect { service.call }
+ .to raise_error(described_class::DeactivateCouponServiceError, 'Coupon invalid')
+ end
+
+ it 'raises even if msg is missing' do
+ body = { 'errorCode' => 'COUPON_CANNOT_BE_MODIFIED' }.to_json
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: body))
+
+ expect { service.call }.to raise_error(described_class::DeactivateCouponServiceError)
+ end
+ end
+
+ context 'bad/empty HTTP response' do
+ it 'raises JSON::ParserError for invalid JSON' do
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: 'not-json'))
+ expect { service.call }.to raise_error(JSON::ParserError)
+ end
+
+ it 'raises TypeError when response.body is nil (JSON.parse(nil))' do
+ allow(service).to receive(:send_request).and_return(instance_double('Net::HTTPResponse', body: nil))
+ expect { service.call }.to raise_error(TypeError)
+ end
+
+ it 'raises NoMethodError when send_request returns nil (nil.body)' do
+ allow(service).to receive(:send_request).and_return(nil)
+ expect { service.call }.to raise_error(NoMethodError)
+ end
+
+ it 'bubbles exceptions from send_request (e.g., timeouts)' do
+ allow(service).to receive(:send_request).and_raise(Timeout::Error)
+ expect { service.call }.to raise_error(Timeout::Error)
+ end
+ end
+ end
+end
diff --git a/spec/services/spl/coupons/get_coupon_service_spec.rb b/spec/services/spl/coupons/get_coupon_service_spec.rb
new file mode 100644
index 0000000..868f5e6
--- /dev/null
+++ b/spec/services/spl/coupons/get_coupon_service_spec.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::Coupons::GetCouponsService do
+ let(:user) { create(:user, private_metadata: { 'spl_access_token' => 'LPUXLUZYZ9JN8XLXSSCTZA4Y5LEFEX' }) }
+ let(:store) { Spree::Store.default }
+ let(:service) { described_class.new(user, store) }
+ let(:request_url) do
+ URI.parse(Spl::UrlCreatorService.new(store.private_metadata['spl_url']).coupon_find)
+ end
+ let(:prepared_body) do
+ {
+ context: {
+ prgCode: 'prg-2',
+ oauthToken: 'LPUXLUZYZ9JN8XLXSSCTZA4Y5LEFEX' # real exammple token
+ },
+ withArchival: true
+ }
+ end
+ let(:request_service) { instance_double(Spl::SendRequestService) }
+ let(:response_double) { instance_double(Net::HTTPResponse) }
+
+ before do
+ store.update(private_metadata: { 'spl_url' => 'https://example.com', 'spl_prg_code' => 'prg-2' })
+ allow(Spl::SendRequestService).to receive(:new)
+ .with(request_url, prepared_body)
+ .and_return(request_service)
+ allow(request_service).to receive(:call).and_return(response_double)
+ end
+
+ describe '#call' do
+ context 'when service returns valid coupons' do
+ let(:valid_response_body) do
+ {
+ 'errorCode' => '0',
+ 'validationMessages' => nil,
+ 'fieldValidationMessages' => nil,
+ 'response' => [
+ {
+ 'code' => '9004850879237',
+ 'expirationDate' => nil,
+ 'valid' => true,
+ 'type' => '90_CRAZY',
+ 'typeId' => '67583a525012cbcf734c281a',
+ 'typeCustomerName' => '90% CRAZY KUPON',
+ 'typeCustomerDescription' => 'YOLO 90% NA WSZYSTKO',
+ 'typeCustomerShortDescription' => nil,
+ 'image' => '06FBC68FD78D56077B86720C9C22A9158FE35184.jpg',
+ 'imageUrl' => 'https://demo.spartaloyalty.com/TestYes/binary/img/06FBC68FD78D56077B86720C9C22A9158FE35184.jpg',
+ 'images' => nil,
+ 'imagesUrl' => nil,
+ 'relatedUrl' => nil,
+ 'autoLoad' => nil,
+ 'couponType' => {
+ 'autoLoad' => 'A',
+ 'transferDisabled' => nil,
+ 'visualizationType' => nil
+ },
+ 'used' => false,
+ 'balance' => nil,
+ 'usageDisabled' => nil,
+ 'usageTemporaryBlocked' => false,
+ 'productCode' => nil,
+ 'productPartnerCode' => nil,
+ 'kind' => nil,
+ 'qrCode' => nil,
+ 'issuedTransactionId' => nil
+ }
+ ],
+ 'msg' => 'OK'
+ }
+ end
+
+ before do
+ allow(response_double).to receive(:body).and_return(valid_response_body.to_json)
+ end
+
+ it 'returns the coupons array' do
+ expect(service.call).to eq(valid_response_body['response'])
+ end
+ end
+
+ context 'when service returns empty coupons array' do
+ let(:empty_response_body) do
+ {
+ 'errorCode' => '0',
+ 'validationMessages' => nil,
+ 'fieldValidationMessages' => nil,
+ 'response' => [],
+ 'msg' => 'OK'
+ }
+ end
+
+ before do
+ allow(response_double).to receive(:body).and_return(empty_response_body.to_json)
+ end
+
+ it 'returns an empty array' do
+ expect(service.call).to eq([])
+ end
+ end
+
+ context 'when service returns an error' do
+ let(:error_response_body) do
+ { 'errorCode' => 'TOKEN_EXPIRED', 'validationMessages' => nil, 'fieldValidationMessages' => nil,
+ 'response' => nil, 'msg' => 'Token expired' }
+ end
+
+ before do
+ allow(response_double).to receive(:body).and_return(error_response_body.to_json)
+ service.instance_variable_set(:@retry_counter, 0)
+ end
+
+ it 'raises SplGetCouponError with message' do
+ expect { service.call }.to raise_error(
+ Spl::Coupons::GetCouponsService::SplGetCouponError,
+ 'Token expired'
+ )
+ end
+ end
+
+ context 'when user is not logged-in ' do
+ let(:user) { create(:user, private_metadata: nil) }
+
+ it 'does nothing' do
+ expect(service.call).to eq(nil)
+ end
+ end
+
+ it 'sends a request with the correct body and URL' do
+ allow(response_double).to receive(:body).and_return({ 'errorCode' => '0', 'response' => [] }.to_json)
+ service.call
+ expect(Spl::SendRequestService).to have_received(:new).with(request_url, prepared_body)
+ expect(request_service).to have_received(:call)
+ end
+ end
+end
diff --git a/spec/services/spl/error_payload_parser_spec.rb b/spec/services/spl/error_payload_parser_spec.rb
new file mode 100644
index 0000000..58e04f4
--- /dev/null
+++ b/spec/services/spl/error_payload_parser_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::ErrorPayloadParser do
+ describe '.parse' do
+ subject(:parse) { described_class.parse(message) }
+
+ context 'when message is a ruby-hash-like string' do
+ let(:message) do
+ %({"errorCode" => "TEMPORARY_BLOCKED", "validationMessages" => nil, "fieldValidationMessages" => nil, "response" => nil, "msg" => "Temporarily blocked (too much attempts)"})
+ end
+
+ it 'parses into a hash with string keys and values' do
+ expect(parse).to eq(
+ 'errorCode' => 'TEMPORARY_BLOCKED',
+ 'validationMessages' => nil,
+ 'fieldValidationMessages' => nil,
+ 'response' => nil,
+ 'msg' => 'Temporarily blocked (too much attempts)'
+ )
+ end
+ end
+
+ context 'when message is not wrapped in braces' do
+ let(:message) { %("errorCode" => "TEMPORARY_BLOCKED") }
+
+ it 'returns nil' do
+ expect(parse).to be_nil
+ end
+ end
+
+ context 'when message is blank' do
+ let(:message) { ' ' }
+
+ it 'returns nil' do
+ expect(parse).to be_nil
+ end
+ end
+
+ context 'when message contains escaped quotes and backslashes' do
+ let(:message) do
+ %({"msg" => "He said \\"hi\\"", "path" => "C:\\\\Temp\\\\file"})
+ end
+
+ it 'unescapes values correctly' do
+ expect(parse).to eq(
+ 'msg' => %(He said "hi"),
+ 'path' => %q(C:\Temp\file)
+ )
+ end
+ end
+
+ context 'when message contains nil values' do
+ let(:message) { %({"response" => nil}) }
+
+ it 'parses nil correctly' do
+ expect(parse).to eq(
+ 'response' => nil
+ )
+ end
+ end
+
+ context 'when message includes unquoted values' do
+ let(:message) { %({"errorCode" => 123, "msg" => "ok"}) }
+
+ it 'keeps unquoted values as strings (best-effort parsing)' do
+ expect(parse).to eq(
+ 'errorCode' => '123',
+ 'msg' => 'ok'
+ )
+ end
+ end
+
+ context 'when message is malformed' do
+ let(:message) { %({this is not => valid,) }
+
+ it 'returns nil (does not raise)' do
+ expect(parse).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/spl/error_translator_spec.rb b/spec/services/spl/error_translator_spec.rb
new file mode 100644
index 0000000..ba76527
--- /dev/null
+++ b/spec/services/spl/error_translator_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::ErrorTranslator do
+ describe '.translate' do
+ let(:locale) { :en }
+
+ before do
+ stub_const('Spree::Spl', Module.new) unless defined?(Spree::Spl)
+ allow(Spree::Spl).to receive(:report_error).and_return(nil)
+ end
+
+ around do |example|
+ I18n.with_locale(locale) do
+ example.run
+ end
+ end
+
+ context 'when errorCode is present and translation key exists' do
+ let(:temporary_blocked_payload) do
+ { 'errorCode' => 'TEMPORARY_BLOCKED', 'msg' => 'Temporarily blocked' }
+ end
+
+ it 'returns the translated message and does not report error' do
+ expect(Spree::Spl).not_to receive(:report_error)
+
+ expected = I18n.t('spl.errors.temporary_blocked', locale: locale)
+ expect(described_class.translate(temporary_blocked_payload, locale: locale)).to eq(expected)
+ end
+ end
+
+ context 'when translation key does not exist' do
+ let(:unknown_code_payload) do
+ { 'errorCode' => 'SOME_NEW_CODE', 'msg' => 'Raw message' }
+ end
+
+ it 'reports error and returns generic translation' do
+ expect(Spree::Spl).to receive(:report_error).with(
+ 'Unknown Sparta error code',
+ hash_including(
+ error_code: 'SOME_NEW_CODE',
+ raw_msg: 'Raw message',
+ payload: unknown_code_payload
+ )
+ )
+
+ expected = I18n.t('spl.errors.generic', locale: locale)
+ expect(described_class.translate(unknown_code_payload, locale: locale)).to eq(expected)
+ end
+ end
+
+ context 'when errorCode is nil/blank' do
+ let(:nil_code_payload) do
+ { 'errorCode' => nil, 'msg' => 'Raw message' }
+ end
+
+ it 'reports error and returns generic translation' do
+ expect(Spree::Spl).to receive(:report_error).with(
+ 'Unknown Sparta error code',
+ hash_including(
+ error_code: nil,
+ raw_msg: 'Raw message',
+ payload: nil_code_payload
+ )
+ )
+
+ expected = I18n.t('spl.errors.generic', locale: locale)
+ expect(described_class.translate(nil_code_payload, locale: locale)).to eq(expected)
+ end
+ end
+
+ context 'when locale is passed explicitly' do
+ it 'uses the provided locale for lookup and translation' do
+ payload = { 'errorCode' => 'TEMPORARY_BLOCKED' }
+ pl = :pl
+
+ # jeśli masz pl.yml w appce, to wystarczy:
+ # expected = I18n.t('spl.errors.temporary_blocked', locale: pl)
+
+ # jeśli NIE masz pl.yml, ale chcesz przetestować ścieżkę "locale param działa":
+ I18n.backend.store_translations(pl, spl: { errors: { temporary_blocked: 'PL message' } })
+ expected = I18n.t('spl.errors.temporary_blocked', locale: pl)
+
+ expect(described_class.translate(payload, locale: pl)).to eq(expected)
+ end
+ end
+ end
+end
diff --git a/spec/services/spl/login_account_service_spec.rb b/spec/services/spl/login_account_service_spec.rb
new file mode 100644
index 0000000..491d785
--- /dev/null
+++ b/spec/services/spl/login_account_service_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'json'
+require 'digest'
+
+RSpec.describe Spl::LoginAccountService, type: :service do
+ let(:country) { create(:country) }
+ let(:user) { create(:user, phone: phone) }
+ let(:store) { create(:store, default_country: country, private_metadata: { 'spl_url' => 'https://spl.test' }) }
+
+ let(:env) do
+ {
+ 'spl_prg_code' => 'PRG',
+ 'spl_api_user' => 'user'
+ }
+ end
+
+ let(:login_url) { URI.parse('https://spl.test/login') }
+
+ let(:phone) { '+48123123123' }
+
+ let(:card_number) { nil }
+ let(:otp_code) { '123456' }
+
+ let(:params) do
+ {
+ 'user' => {
+ 'card_number' => card_number,
+ 'spl_auth_code' => otp_code
+ }
+ }
+ end
+
+ subject(:service) { described_class.new(user, store, params) }
+
+ let(:send_request_service) { instance_double(Spl::SendRequestService) }
+ let(:oauth_service) { instance_double(Spl::OauthTokenService) }
+
+ let(:login_response_body) do
+ {
+ 'errorCode' => '0',
+ 'response' => { 'oauthCode' => 'OAUTH_CODE_123' }
+ }
+ end
+
+ let(:login_response) do
+ instance_double(Net::HTTPResponse, body: login_response_body.to_json)
+ end
+
+ let(:token_body) do
+ { 'accessToken' => 'ACCESS_TOKEN', 'refreshToken' => 'REFRESH_TOKEN' }
+ end
+
+ before do
+ allow(Spl::UrlCreatorService).to receive(:new)
+ .with('https://spl.test')
+ .and_return(double(login: login_url.to_s))
+
+ allow(Spl::StorePrivateMetadataService).to receive(:all)
+ .with(store)
+ .and_return(env)
+
+ allow(Spl::SendRequestService).to receive(:new)
+ .and_return(send_request_service)
+
+ allow(send_request_service).to receive(:call).and_return(login_response)
+
+ allow(Spl::OauthTokenService).to receive(:new)
+ .and_return(oauth_service)
+
+ allow(oauth_service).to receive(:authorization_code_token)
+ .with('OAUTH_CODE_123')
+ .and_return(token_body)
+ end
+
+ describe '#call' do
+ context 'when SPL login succeeds' do
+ it 'updates user tokens from oauth token service' do
+ service.call
+
+ user.reload
+ expect(user.private_metadata['spl_access_token']).to eq('ACCESS_TOKEN')
+ expect(user.private_metadata['spl_refresh_token']).to eq('REFRESH_TOKEN')
+ end
+
+ it 'sends request to login URL with expected body (hashed OTP when length is 6)' do
+ expected_password = Digest::SHA256.hexdigest(otp_code)
+
+ expect(Spl::SendRequestService).to receive(:new) do |url, body|
+ expect(url).to eq(login_url)
+ expect(body).to include(
+ apiUser: 'user',
+ scope: ['spl_cwp'],
+ responseType: 'code',
+ method: 'OTP'
+ )
+ expect(body[:context]).to eq(prgCode: 'PRG')
+ expect(body[:password]).to eq(expected_password)
+ expect(body[:login]).to eq(phone)
+
+ send_request_service
+ end
+
+ service.call
+ end
+ end
+
+ context 'when SPL returns an error' do
+ let(:login_response_body) do
+ { 'errorCode' => '123', 'msg' => 'Bad OTP' }
+ end
+
+ it 'raises SplLoginAccountError' do
+ expect { service.call }
+ .to raise_error(Spl::LoginAccountService::SplLoginAccountError)
+ end
+
+ it 'does not update user tokens' do
+ expect do
+ service.call
+ rescue StandardError
+ nil
+ end.not_to(change { user.reload.private_metadata })
+ end
+ end
+
+ context 'when OTP code is not 6 characters' do
+ let(:otp_code) { 'ALREADY_ENCRYPTED_OR_LONGER' }
+
+ it 'uses OTP as-is (no hashing)' do
+ expect(Spl::SendRequestService).to receive(:new) do |_url, body|
+ expect(body[:password]).to eq(otp_code)
+ send_request_service
+ end
+
+ service.call
+ end
+ end
+
+ context 'when card number is provided and phone is nil' do
+ let(:phone) { nil }
+ let(:card_number) { '1234567890123' }
+
+ it 'uses card number as login' do
+ expect(Spl::SendRequestService).to receive(:new) do |_url, body|
+ expect(body[:login]).to eq(card_number)
+ send_request_service
+ end
+
+ service.call
+ end
+ end
+ end
+end
diff --git a/spec/services/spl/me_service_spec.rb b/spec/services/spl/me_service_spec.rb
new file mode 100644
index 0000000..759239f
--- /dev/null
+++ b/spec/services/spl/me_service_spec.rb
@@ -0,0 +1,163 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::MeService, type: :service do
+ subject(:service) { described_class.new(user, store) }
+
+ let(:country) { create(:country) }
+ let(:user) { create(:user, private_metadata: { 'spl_access_token' => 'access-token-123' }) }
+ let(:store) { create(:store, default_country: country, private_metadata: { 'spl_url' => 'https://api.example.test' }) }
+ let(:env) { { 'spl_prg_code' => 'PRG001' } }
+ let(:me_url) { 'https://api.example.test/me' }
+
+ before do
+ allow(Spl::StorePrivateMetadataService).to receive(:all).with(store).and_return(env)
+
+ url_creator = instance_double(Spl::UrlCreatorService, me: me_url)
+ allow(Spl::UrlCreatorService).to receive(:new).with('https://api.example.test').and_return(url_creator)
+ end
+
+ describe '#call' do
+ context "when API returns success (errorCode == '0')" do
+ let(:expected_body) do
+ {
+ context: {
+ prgCode: 'PRG001',
+ oauthToken: 'access-token-123'
+ }
+ }
+ end
+ let(:success_response_hash) do
+ {
+ 'errorCode' => '0',
+ 'validationMessages' => nil,
+ 'fieldValidationMessages' => nil,
+ 'response' => {
+ 'person' => {
+ 'addonsList' => [],
+ 'address' => {},
+ 'addresses' => nil,
+ 'assignedPlace' => {
+ 'id' => 'id123',
+ 'idAsDictLabel' => 'shop.pl'
+ },
+ 'assignedPlaceManualId' => nil,
+ 'avatarUrl' => nil,
+ 'birthDate' => nil,
+ 'cards' => [
+ {
+ 'burnEnabled' => true,
+ 'cardType' => {
+ 'id' => 'id234',
+ 'idAsDictLabel' => 'Shop - Default Virtual Card'
+ },
+ 'depositBurnDisabled' => nil,
+ 'no' => '5100179585157',
+ 'status' => 'A',
+ 'statusAsDictLabel' => 'Active'
+ }
+ ],
+ 'company' => nil,
+ 'email' => 'name.surname@email.com',
+ 'emailVerified' => nil,
+ 'firstName' => 'name',
+ 'gender' => nil,
+ 'lastName' => 'surname',
+ 'marital' => nil,
+ 'middleName' => nil,
+ 'mobile' => '500600700',
+ 'mobileCountry' => '+48',
+ 'mobileVerified' => true,
+ 'nameDay' => nil,
+ 'passwordLastChanged' => nil,
+ 'permissions' => {},
+ 'personId' => 'id345',
+ 'phone' => nil,
+ 'preReg' => nil,
+ 'preferredCommChannel' => nil,
+ 'pushNotificationsEnabled' => nil,
+ 'social' => [],
+ 'taxNo' => nil,
+ 'undefined' => false,
+ 'walletOwner' => true,
+ 'maritalAsDictLabel' => nil
+ },
+ 'cards' => [
+ {
+ 'burnEnabled' => true,
+ 'cardType' => {
+ 'id' => 'id456',
+ 'idAsDictLabel' => 'Shop - Default Virtual Card'
+ },
+ 'depositBurnDisabled' => nil,
+ 'no' => '5100179585157',
+ 'status' => 'A',
+ 'statusAsDictLabel' => 'Active'
+ }
+ ],
+ 'wallet' => {
+ 'balances' => [
+ {
+ 'balance' => 0.0,
+ 'nbBalance' => 0.0,
+ 'type' => 'P',
+ 'typeAsDictLabel' => 'Points'
+ }
+ ],
+ 'primaryBalance' => 0.0,
+ 'toBeExpired' => { 'primaryBalance' => {} },
+ 'firstTx' => { 'date' => nil }
+ },
+ 'badges' => [],
+ 'mainCard' => {
+ 'burnEnabled' => true,
+ 'cardType' => {
+ 'id' => 'id567',
+ 'idAsDictLabel' => 'Shop - Default Virtual Card'
+ },
+ 'depositBurnDisabled' => nil,
+ 'no' => '5100179585157',
+ 'status' => 'A',
+ 'statusAsDictLabel' => 'Active'
+ },
+ 'forcePasswordChange' => false
+ },
+ 'msg' => 'OK'
+ }
+ end
+
+ it 'sends request with correct body and returns parsed response body' do
+ response = double('Response', body: success_response_hash.to_json)
+ request_service = instance_double(Spl::SendRequestService)
+
+ expect(Spl::SendRequestService).to receive(:new)
+ .with(URI.parse(me_url), expected_body)
+ .and_return(request_service)
+ expect(request_service).to receive(:call).and_return(response)
+ expect(service.call).to eq(success_response_hash)
+ end
+ end
+
+ context "when API returns an error (errorCode != '0')" do
+ let(:error_response_hash) do
+ {
+ 'errorCode' => 'TEMPORARY_BLOCKED',
+ 'msg' => 'Temporarily blocked'
+ }
+ end
+
+ before do
+ response = double('Response', body: error_response_hash.to_json)
+ request_service = instance_double(Spl::SendRequestService)
+ allow(Spl::SendRequestService).to receive(:new).and_return(request_service)
+ allow(request_service).to receive(:call).and_return(response)
+ end
+
+ it 'raises SplMeError' do
+ expect { service.call }
+ .to raise_error(Spl::MeService::SplMeError, error_response_hash.to_s)
+ end
+ end
+ end
+end
diff --git a/spec/services/spl/register_account_service_spec.rb b/spec/services/spl/register_account_service_spec.rb
new file mode 100644
index 0000000..8fcacbc
--- /dev/null
+++ b/spec/services/spl/register_account_service_spec.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::RegisterAccountService do
+ subject(:service) { described_class.new(user, store, params) }
+
+ let(:store) do
+ create(
+ :store,
+ default_country: create(:country),
+ private_metadata: {
+ 'spl_url' => 'https://spl.test',
+ 'spl_prg_code' => 'PRG',
+ 'spl_partner_code' => 'PARTNER',
+ 'spl_place_code' => 'PLACE'
+ }
+ )
+ end
+
+ let(:user) do
+ create(
+ :user,
+ phone: '+48500600700',
+ first_name: 'John',
+ last_name: 'Doe',
+ email: 'john@example.com',
+ public_metadata: { 'accept_yc_terms' => true },
+ private_metadata: {}
+ )
+ end
+
+ let(:params) { '123456' }
+ let(:phone_parser) { instance_double(PhoneParserService, country_code: '+48', national_number: '500600700') }
+ let(:oauth_response) { { 'response' => { 'accessToken' => 'ACCESS_TOKEN', 'refreshToken' => 'REFRESH_TOKEN' } } }
+ let(:register_response_body) { { 'errorCode' => '0', 'response' => { 'cardNo' => 'CARD123' } } }
+ let(:http_response) { instance_double(Net::HTTPResponse, body: register_response_body.to_json) }
+ let(:send_request_service) { instance_double(Spl::SendRequestService, call: http_response) }
+
+ before do
+ allow(PhoneParserService).to receive(:new).and_return(phone_parser)
+ allow(Spl::OauthTokenService).to receive(:new).and_return(
+ instance_double(Spl::OauthTokenService, annonymus_token: oauth_response)
+ )
+ allow(Spl::SendRequestService).to receive(:new).and_return(send_request_service)
+ end
+
+ describe '#call' do
+ context 'when registration succeeds' do
+ it 'updates user public_metadata with SPL card data' do
+ service.call
+
+ expect(user.public_metadata).to include(
+ 'spl_no_card' => 'CARD123',
+ 'spl_card_active' => true
+ )
+ end
+
+ it 'updates user private_metadata with tokens' do
+ service.call
+
+ expect(user.private_metadata).to include('accessToken' => 'ACCESS_TOKEN', 'refreshToken' => 'REFRESH_TOKEN')
+ end
+
+ it 'sends correct payload to SendRequestService' do
+ expect(Spl::SendRequestService).to receive(:new) do |url, body|
+ expect(url.to_s).to include('register')
+ expect(body).to include(authCode: params, partnerCode: 'PARTNER', placeCode: 'PLACE')
+ expect(body[:context]).to include(oauthToken: 'ACCESS_TOKEN', prgCode: 'PRG')
+ expect(body[:person][:permissions]).to eq(processData: true, operationalSms: true)
+ expect(body[:person]).to include(
+ firstName: 'John',
+ lastName: 'Doe',
+ email: 'john@example.com',
+ mobileCountry: '+48',
+ mobile: '500600700'
+ )
+ expect(body[:person][:permissions]).to eq(processData: true, operationalSms: true)
+ end.and_return(send_request_service)
+
+ service.call
+ end
+ end
+
+ context 'when SPL returns errorCode != 0' do
+ let(:register_response_body) { { 'errorCode' => 'TEMPORARY_BLOCKED', 'msg' => 'Temporarily blocked' } }
+
+ it 'raises SplRegisterAccountError with SPL message' do
+ expect do
+ service.call
+ end.to raise_error(Spl::RegisterAccountService::SplRegisterAccountError, 'Temporarily blocked')
+ end
+
+ it 'does not update user public_metadata' do
+ expect do
+ service.call
+ rescue StandardError
+ nil
+ end.not_to(change { user.reload.public_metadata })
+ end
+ end
+
+ context 'when OTP code is not 6 characters' do
+ let(:params) { '123' }
+
+ it 'still sends request to SPL (no local validation)' do
+ expect(Spl::SendRequestService).to receive(:new).and_return(send_request_service)
+
+ service.call
+ end
+ end
+
+ context 'when card number is provided and phone is nil' do
+ before do
+ user.update!(phone: nil, public_metadata: user.public_metadata.merge('spl_no_card' => 'CARD123'))
+ allow(PhoneParserService).to receive(:new).with(nil).and_raise(StandardError, 'Phone missing')
+ end
+
+ it 'raises error before sending request' do
+ expect { service.call }.to raise_error(StandardError, 'Phone missing')
+ end
+
+ it 'does not send request to SPL' do
+ expect(Spl::SendRequestService).not_to receive(:new)
+ expect { service.call }.to raise_error(StandardError)
+ end
+ end
+ end
+end
diff --git a/spec/services/spl/request_otp_service_spec.rb b/spec/services/spl/request_otp_service_spec.rb
new file mode 100644
index 0000000..da6b48e
--- /dev/null
+++ b/spec/services/spl/request_otp_service_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::RequestOtpService do
+ subject(:service) { described_class.new(date, store, params) }
+
+ let(:store) do
+ create(:store, default_country: create(:country), private_metadata: {
+ 'spl_url' => 'https://spl.test',
+ 'spl_prg_code' => 'PRG'
+ })
+ end
+
+ let(:date) { DateTime.current }
+ let(:oauth_response) { { 'response' => { 'accessToken' => 'ACCESS_TOKEN' } } }
+ let(:success_response_body) { { 'errorCode' => '0', 'response' => { 'status' => 'OK' } } }
+ let(:error_response_body) { { 'errorCode' => 'TEMPORARY_BLOCKED', 'msg' => 'Temporarily blocked' } }
+ let(:response_body) { success_response_body }
+ let(:http_response) { instance_double(Net::HTTPResponse, body: response_body.to_json) }
+
+ let(:send_request_service) { instance_double(Spl::SendRequestService, call: http_response) }
+
+ before do
+ allow(Spl::OauthTokenService).to receive(:new).and_return(
+ instance_double(Spl::OauthTokenService, annonymus_token: oauth_response)
+ )
+ allow(Spl::SendRequestService).to receive(:new).and_return(send_request_service)
+ end
+
+ describe '#call' do
+ context 'when requesting OTP via SMS' do
+ let(:params) { { mobile_country: '+48', phone_number: '500600700' } }
+
+ it 'returns parsed response body' do
+ expect(service.call).to eq(success_response_body)
+ end
+
+ it 'sends correct SMS payload to SendRequestService' do
+ expect(Spl::SendRequestService).to receive(:new) do |url, body|
+ expect(url.to_s).to include('requestOTP')
+ expect(body).to include(channel: 'S', mobileCountry: '+48', mobile: '500600700')
+ expect(body[:context]).to include(oauthToken: 'ACCESS_TOKEN', prgCode: 'PRG')
+ end.and_return(send_request_service)
+
+ service.call
+ end
+ end
+
+ context 'when requesting OTP via email' do
+ let(:params) { { email: 'test@example.com' } }
+
+ it 'sends email OTP payload' do
+ expect(Spl::SendRequestService).to receive(:new) do |_url, body|
+ expect(body).to include(channel: 'E', email: 'test@example.com')
+ end.and_return(send_request_service)
+
+ service.call
+ end
+ end
+
+ context 'when SPL returns errorCode != 0' do
+ let(:params) { { mobile_country: '+48', phone_number: '500600700' } }
+ let(:response_body) { error_response_body }
+
+ it 'raises SplRequestOtpError with SPL message' do
+ expect do
+ service.call
+ end.to raise_error(Spl::RequestOtpService::SplRequestOtpError, 'Temporarily blocked')
+ end
+ end
+ end
+end
diff --git a/spec/services/spl/send_otp_service_spec.rb b/spec/services/spl/send_otp_service_spec.rb
new file mode 100644
index 0000000..7754707
--- /dev/null
+++ b/spec/services/spl/send_otp_service_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::SendOtpService, type: :service do
+ let(:store) do
+ instance_double(
+ Spree::Store,
+ private_metadata: { 'spl_url' => 'https://spl.test' }
+ )
+ end
+
+ let(:env) do
+ {
+ 'spl_prg_code' => 'PRG',
+ 'spl_api_user' => 'user',
+ 'spl_api_token' => 'token',
+ 'spl_signature_seed' => 'seed'
+ }
+ end
+
+ let(:date) { DateTime.parse('2025-12-16 12:00:00 UTC') }
+ let(:date_ms) { date.to_i * 1000 }
+ let(:mobile_country) { '+48' }
+ let(:phone_number) { '500600700' }
+ let(:send_otp_url) { 'https://spl.test/api/send-otp' }
+ let(:uri) { URI.parse(send_otp_url) }
+ let(:signature) { 'computed-signature' }
+ let(:url_creator) { instance_double('Spl::UrlCreatorService', send_otp: send_otp_url) }
+ let(:signature_service) { instance_double('Spl::ClientSignatureService', call: signature) }
+ let(:http_response) { instance_double('Net::HTTPResponse', body: response_body_json) }
+
+ before do
+ allow(Spl::StorePrivateMetadataService).to receive(:all).with(store).and_return(env)
+ allow(Spl::UrlCreatorService).to receive(:new).with('https://spl.test').and_return(url_creator)
+ allow(Spl::ClientSignatureService).to receive(:new)
+ .with(date_ms, env['spl_api_token'], env['spl_signature_seed'])
+ .and_return(signature_service)
+ allow(Spl::SendRequestService).to receive(:new)
+ .and_return(instance_double('Spl::SendRequestService', call: http_response))
+ allow(Rails.logger).to receive(:debug)
+ end
+
+ subject(:service) { described_class.new(date, mobile_country, phone_number, store) }
+
+ describe '#call' do
+ context "when API returns success (errorCode == '0')" do
+ let(:response_body_json) { { 'errorCode' => '0', 'msg' => nil, 'response' => { 'ok' => true } }.to_json }
+
+ it 'returns parsed response hash' do
+ result = service.call
+ expect(result).to be_a(Hash)
+ expect(result['errorCode']).to eq('0')
+ expect(result['response']).to eq({ 'ok' => true })
+ end
+
+ it 'sends request with correct url and body' do
+ expected_body = {
+ context: { prgCode: 'PRG' },
+ apiUser: 'user',
+ apiToken: 'token',
+ signatureSeed: 'seed',
+ date: date_ms,
+ mobileCountry: '+48',
+ mobile: '500600700',
+ signature: signature
+ }
+
+ expect(Spl::SendRequestService).to receive(:new) do |passed_url, passed_body|
+ expect(passed_url).to eq(uri)
+ expect(passed_body).to eq(expected_body)
+ end.and_return(instance_double('Spl::SendRequestService', call: http_response))
+
+ service.call
+ end
+ end
+
+ context "when API returns an error (errorCode != '0')" do
+ let(:response_body_json) { { 'errorCode' => 'PERSON_NOT_FOUND', 'msg' => 'Person not found' }.to_json }
+
+ it 'raises SplSendOtpError with msg' do
+ expect { service.call }
+ .to raise_error(Spl::SendOtpService::SplSendOtpError, /Person not found/)
+ end
+ end
+
+ context 'when API returns errorCode but msg is missing' do
+ let(:response_body_json) { { 'errorCode' => 'SOMETHING_BAD' }.to_json }
+
+ it 'raises SplSendOtpError (message can be nil/blank depending on payload)' do
+ expect { service.call }.to raise_error(Spl::SendOtpService::SplSendOtpError)
+ end
+ end
+ end
+end
diff --git a/spec/services/spl/send_request_service_spec.rb b/spec/services/spl/send_request_service_spec.rb
new file mode 100644
index 0000000..1c4e837
--- /dev/null
+++ b/spec/services/spl/send_request_service_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::SendRequestService, type: :service do
+ let(:url) { URI.parse('https://spl.test/api/send-otp') }
+ let(:body) { { foo: 'bar', nested: { a: 1 } } }
+
+ let(:http) { instance_double(Net::HTTP) }
+ let(:response) { instance_double(Net::HTTPResponse) }
+
+ before do
+ allow(Rails.logger).to receive(:debug)
+ end
+
+ describe '#call' do
+ it 'builds a POST request with JSON content-type and sends it over SSL' do
+ expect(Net::HTTP).to receive(:new).with(url.host, url.port).and_return(http)
+ expect(http).to receive(:use_ssl=).with(true)
+
+ # We want to assert on the actual request object created
+ expect(http).to receive(:request) do |request|
+ expect(request).to be_a(Net::HTTP::Post)
+ expect(request['Content-Type']).to eq('application/json')
+ expect(request.body).to eq(body.to_json)
+ response
+ end.and_return(response)
+
+ result = described_class.new(url, body).call
+ expect(result).to eq(response)
+ end
+
+ it 'logs the request body (debug)' do
+ allow(Net::HTTP).to receive(:new).and_return(http)
+ allow(http).to receive(:use_ssl=)
+
+ expect(Rails.logger).to receive(:debug).with(body.to_json.inspect)
+
+ allow(http).to receive(:request).and_return(response)
+
+ described_class.new(url, body).call
+ end
+ end
+end
diff --git a/spec/services/spl/sparta_loyalty_service_spec.rb b/spec/services/spl/sparta_loyalty_service_spec.rb
index 80caf1a..86eabeb 100644
--- a/spec/services/spl/sparta_loyalty_service_spec.rb
+++ b/spec/services/spl/sparta_loyalty_service_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-RSpec.describe Spl::SpartaLoyaltyService do
+RSpec.describe Spl::SpartaLoyaltyService, type: :service do
let(:country) { create(:country) }
let(:store) do
@@ -25,7 +25,7 @@
let(:order_token) { 'ORDER123' }
let(:card_number) { '5100179585157' }
- let(:date) { Time.zone.parse('2025-01-01 12:00:00') }
+ let(:date) { DateTime.parse('2025-01-01 12:00:00') }
let(:check_only) { true }
let(:product1) { create(:product) }
let(:variant1) do
diff --git a/spec/services/spl/store_private_metadata_service_spec.rb b/spec/services/spl/store_private_metadata_service_spec.rb
new file mode 100644
index 0000000..6a1f354
--- /dev/null
+++ b/spec/services/spl/store_private_metadata_service_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::StorePrivateMetadataService, type: :service do
+ let(:private_metadata) do
+ {
+ 'spl_url' => 'https://spl.test',
+ 'spl_api_user' => 'user',
+ 'spl_api_token' => 'token',
+ 'spl_partner_code' => 'partner',
+ 'spl_place_code' => 'place',
+ 'spl_update_status_mode' => 'mode',
+ 'spl_prg_code' => 'PRG',
+ 'spl_mode' => 'mode',
+ 'spl_pos_key' => 'poskey'
+ }
+ end
+
+ let(:store) { instance_double(Spree::Store, private_metadata: private_metadata) }
+
+ describe '.all' do
+ context 'when store is nil' do
+ it 'returns an empty hash' do
+ expect(described_class.all(nil)).to eq({})
+ end
+ end
+
+ context 'when store.private_metadata is nil' do
+ let(:store) { instance_double(Spree::Store, private_metadata: nil) }
+
+ it 'returns an empty hash' do
+ expect(described_class.all(store)).to eq({})
+ end
+ end
+
+ it 'returns the private_metadata contents' do
+ expect(described_class.all(store)).to eq(private_metadata)
+ end
+
+ it 'returns a copy that does not allow mutation of the original store hash' do
+ result = described_class.all(store)
+ result['spl_url'] = 'https://changed.test'
+
+ expect(store.private_metadata['spl_url']).to eq('https://spl.test')
+ end
+
+ it 'deep-dups nested hashes (if present) so nested mutation does not affect the original' do
+ nested = { 'outer' => { 'inner' => 'value' } }
+ store_with_nested = instance_double(Spree::Store, private_metadata: nested)
+
+ result = described_class.all(store_with_nested)
+ result['outer']['inner'] = 'changed'
+
+ expect(store_with_nested.private_metadata['outer']['inner']).to eq('value')
+ end
+ end
+
+ describe '.fetch' do
+ context 'when store is nil' do
+ it 'returns the default when provided' do
+ expect(described_class.fetch(nil, :spl_url, default: 'x')).to eq('x')
+ end
+
+ it 'returns nil when store is nil and no default is provided' do
+ expect(described_class.fetch(nil, :spl_url)).to be_nil
+ end
+ end
+
+ it 'fetches by symbol key' do
+ expect(described_class.fetch(store, :spl_url)).to eq('https://spl.test')
+ end
+
+ it 'fetches by string key' do
+ expect(described_class.fetch(store, 'spl_url')).to eq('https://spl.test')
+ end
+
+ it 'returns default when key is missing' do
+ expect(described_class.fetch(store, :missing_key, default: 'fallback')).to eq('fallback')
+ end
+
+ it 'returns nil when key is missing and no default is provided' do
+ expect(described_class.fetch(store, :missing_key)).to be_nil
+ end
+
+ it 'does not allow callers to mutate nested values through the returned hash' do
+ nested = { 'cfg' => { 'token' => 'abc' } }
+ store_with_nested = instance_double(Spree::Store, private_metadata: nested)
+
+ cfg = described_class.fetch(store_with_nested, :cfg)
+ cfg['token'] = 'changed'
+
+ expect(store_with_nested.private_metadata['cfg']['token']).to eq('abc')
+ end
+ end
+end
diff --git a/spec/services/spl/url_creator_service_spec.rb b/spec/services/spl/url_creator_service_spec.rb
new file mode 100644
index 0000000..1f30830
--- /dev/null
+++ b/spec/services/spl/url_creator_service_spec.rb
@@ -0,0 +1,77 @@
+# spec/services/spl/url_creator_service_spec.rb
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spl::UrlCreatorService, type: :service do
+ subject(:service) { described_class.new(base_url) }
+ let(:base_url) { 'https://example.test' }
+
+ describe '#check_card' do
+ it 'builds the checkCard URL' do
+ expect(service.check_card).to eq('https://example.test/api/tx/checkCard')
+ end
+ end
+
+ describe '#find' do
+ it 'builds the find URL' do
+ expect(service.find).to eq('https://example.test/api/tx/find')
+ end
+ end
+
+ describe '#sale' do
+ it 'builds the sale URL' do
+ expect(service.sale).to eq('https://example.test/api/tx/sale')
+ end
+ end
+
+ describe '#sale_refund' do
+ it 'builds the saleRefund URL' do
+ expect(service.sale_refund).to eq('https://example.test/api/tx/saleRefund')
+ end
+ end
+
+ describe '#me' do
+ it 'builds the me URL' do
+ expect(service.me).to eq('https://example.test/api/cwp/customer/me')
+ end
+ end
+
+ describe '#register' do
+ it 'builds the register URL' do
+ expect(service.register).to eq('https://example.test/api/cwp/customer/register')
+ end
+ end
+
+ describe '#request_otp' do
+ it 'builds the requestOTP URL' do
+ expect(service.request_otp).to eq('https://example.test/api/cwp/customer/requestOTP')
+ end
+ end
+
+ describe '#login' do
+ it 'builds the login URL' do
+ expect(service.login).to eq('https://example.test/api/oauth/login')
+ end
+ end
+
+ describe '#send_otp' do
+ it 'builds the sendOTP URL' do
+ expect(service.send_otp).to eq('https://example.test/api/oauth/sendOTP')
+ end
+ end
+
+ describe '#oauth_token' do
+ it 'builds the token URL' do
+ expect(service.oauth_token).to eq('https://example.test/api/oauth/token')
+ end
+ end
+
+ context 'when base url has a trailing slash' do
+ let(:base_url) { 'https://example.test/' }
+
+ it 'keeps the double slash behavior (document current behavior)' do
+ expect(service.me).to eq('https://example.test//api/cwp/customer/me')
+ end
+ end
+end
diff --git a/spree-spl.gemspec b/spree-spl.gemspec
index f90aa7b..fdcdc65 100644
--- a/spree-spl.gemspec
+++ b/spree-spl.gemspec
@@ -29,7 +29,7 @@ Gem::Specification.new do |spec|
spec.add_dependency('faraday')
spec.add_dependency('openssl')
- spree_version = ['~> 5.1', '>= 5.1.4']
+ spree_version = ['~> 5.2', '>= 5.2.4']
spec.add_dependency 'bigdecimal'
spec.add_dependency 'rails', '>= 7.0', '< 8.3'
spec.add_dependency 'spree', spree_version