solidus_advanced_pricing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +53 -0
  3. data/.gem_release.yml +5 -0
  4. data/.github/stale.yml +1 -0
  5. data/.github/workflows/lint.yml +25 -0
  6. data/.github/workflows/test.yml +86 -0
  7. data/.github_changelog_generator +2 -0
  8. data/.gitignore +21 -0
  9. data/.rspec +2 -0
  10. data/.rubocop.yml +5 -0
  11. data/CHANGELOG.md +3 -0
  12. data/Gemfile +64 -0
  13. data/LICENSE +26 -0
  14. data/README.md +377 -0
  15. data/Rakefile +41 -0
  16. data/app/assets/javascripts/spree/backend/solidus_advanced_pricing.js +2 -0
  17. data/app/assets/javascripts/spree/frontend/solidus_advanced_pricing.js +2 -0
  18. data/app/assets/stylesheets/spree/backend/solidus_advanced_pricing.css +4 -0
  19. data/app/assets/stylesheets/spree/frontend/solidus_advanced_pricing.css +4 -0
  20. data/app/decorators/models/solidus_advanced_pricing/spree/app_configuration_decorator.rb +17 -0
  21. data/app/decorators/models/solidus_advanced_pricing/spree/permitted_attributes_decorator.rb +27 -0
  22. data/app/decorators/models/solidus_advanced_pricing/spree/price_decorator.rb +56 -0
  23. data/app/decorators/models/solidus_advanced_pricing/spree/role_decorator.rb +24 -0
  24. data/app/decorators/models/solidus_advanced_pricing/spree/variant_decorator.rb +52 -0
  25. data/app/models/solidus_advanced_pricing/price_selector.rb +73 -0
  26. data/app/models/solidus_advanced_pricing/price_type.rb +80 -0
  27. data/app/models/solidus_advanced_pricing/price_type_cache.rb +45 -0
  28. data/app/models/solidus_advanced_pricing/pricing_options.rb +107 -0
  29. data/app/overrides/spree/admin/prices/_form/add_advanced_fields.html.erb.deface +2 -0
  30. data/app/overrides/spree/admin/prices/_master_variant_table/add_advanced_columns.html.erb.deface +2 -0
  31. data/app/overrides/spree/admin/prices/_master_variant_table/add_advanced_headers.html.erb.deface +4 -0
  32. data/app/overrides/spree/admin/prices/_table/add_advanced_columns.html.erb.deface +2 -0
  33. data/app/overrides/spree/admin/prices/_table/add_advanced_headers.html.erb.deface +4 -0
  34. data/app/overrides/spree/admin/shared/_configuration_menu/add_price_types_link.html.erb.deface +4 -0
  35. data/bin/console +17 -0
  36. data/bin/rails +7 -0
  37. data/bin/rails-engine +13 -0
  38. data/bin/rails-sandbox +16 -0
  39. data/bin/rake +7 -0
  40. data/bin/sandbox +85 -0
  41. data/bin/setup +8 -0
  42. data/config/locales/en.yml +44 -0
  43. data/config/routes.rb +34 -0
  44. data/db/migrate/20260915000001_create_solidus_advanced_pricing_price_types.rb +16 -0
  45. data/db/migrate/20260915000002_seed_solidus_advanced_pricing_price_types.rb +34 -0
  46. data/db/migrate/20260915000003_add_advanced_pricing_to_spree_prices.rb +21 -0
  47. data/docs/superpowers/specs/2026-09-14-solidus-advanced-pricing-design.md +351 -0
  48. data/lib/components/admin/solidus_admin/price_types/index/component.rb +65 -0
  49. data/lib/controllers/admin/solidus_admin/price_types_controller.rb +26 -0
  50. data/lib/controllers/api/spree/api/prices_controller.rb +32 -0
  51. data/lib/controllers/backend/spree/admin/price_types_controller.rb +40 -0
  52. data/lib/generators/solidus_advanced_pricing/install/install_generator.rb +33 -0
  53. data/lib/generators/solidus_advanced_pricing/install/templates/initializer.rb +7 -0
  54. data/lib/solidus_advanced_pricing/configuration.rb +21 -0
  55. data/lib/solidus_advanced_pricing/engine.rb +41 -0
  56. data/lib/solidus_advanced_pricing/testing_support/factories/price_type_factory.rb +13 -0
  57. data/lib/solidus_advanced_pricing/testing_support/factories.rb +4 -0
  58. data/lib/solidus_advanced_pricing/version.rb +5 -0
  59. data/lib/solidus_advanced_pricing.rb +19 -0
  60. data/lib/views/api/spree/api/prices/_price.json.jbuilder +8 -0
  61. data/lib/views/api/spree/api/prices/index.json.jbuilder +3 -0
  62. data/lib/views/api/spree/api/prices/show.json.jbuilder +1 -0
  63. data/lib/views/backend/spree/admin/price_types/_form.html.erb +20 -0
  64. data/lib/views/backend/spree/admin/price_types/edit.html.erb +9 -0
  65. data/lib/views/backend/spree/admin/price_types/index.html.erb +31 -0
  66. data/lib/views/backend/spree/admin/price_types/new.html.erb +9 -0
  67. data/lib/views/backend/spree/admin/prices/_advanced_columns.html.erb +7 -0
  68. data/lib/views/backend/spree/admin/prices/_advanced_fields.html.erb +46 -0
  69. data/solidus_advanced_pricing.gemspec +39 -0
  70. metadata +228 -0
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusAdvancedPricing
4
+ # Filter -> country bucket -> cheapest. Role is eligibility only, never specificity:
5
+ # a role-targeted price set above the untargeted price therefore never applies.
6
+ class PriceSelector < ::Spree::Variant::PriceSelector
7
+ def self.pricing_options_class
8
+ SolidusAdvancedPricing::PricingOptions
9
+ end
10
+
11
+ def price_for_options(price_options)
12
+ candidates = eligible_prices(price_options)
13
+ return nil if candidates.empty?
14
+
15
+ cheapest(bucket_by_country(candidates, price_options.country_iso))
16
+ end
17
+
18
+ private
19
+
20
+ def eligible_prices(price_options)
21
+ wanted_type = price_options.desired_attributes[:price_type_id]
22
+
23
+ variant.prices.select do |price|
24
+ kept?(price) &&
25
+ price.currency == price_options.currency &&
26
+ matches_type?(price, wanted_type) &&
27
+ valid_at?(price, price_options.at) &&
28
+ visible_to?(price, price_options.customer_role_ids)
29
+ end
30
+ end
31
+
32
+ def kept?(price)
33
+ variant.discarded? || price.kept?
34
+ end
35
+
36
+ # nil = untyped base prices only (the admin's view); :any = every type competes
37
+ # (a customer's view); an id = that type only.
38
+ def matches_type?(price, wanted_type)
39
+ return true if wanted_type == :any
40
+
41
+ price.price_type_id == wanted_type
42
+ end
43
+
44
+ def valid_at?(price, time)
45
+ return true if time.nil?
46
+
47
+ (price.valid_from.nil? || price.valid_from <= time) &&
48
+ (price.valid_to.nil? || price.valid_to > time)
49
+ end
50
+
51
+ def visible_to?(price, customer_role_ids)
52
+ price.role_id.nil? || customer_role_ids.include?(price.role_id)
53
+ end
54
+
55
+ # Country is specificity, not competition: a country-specific price beats the
56
+ # any-country fallback even when the fallback is cheaper.
57
+ def bucket_by_country(candidates, country_iso)
58
+ specific = candidates.select { |price| price.country_iso == country_iso && !price.country_iso.nil? }
59
+ specific.presence || candidates.select { |price| price.country_iso.nil? }
60
+ end
61
+
62
+ def cheapest(candidates)
63
+ candidates.min_by do |price|
64
+ [
65
+ price.amount,
66
+ SolidusAdvancedPricing::PriceTypeCache.position_for(price.price_type_id),
67
+ -(price.updated_at || Time.zone.now).to_i,
68
+ -(price.id || Float::INFINITY)
69
+ ]
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusAdvancedPricing
4
+ # Discarding a type hides it from admin dropdowns but leaves historical
5
+ # prices resolvable through it; prices are never reassigned when a type is
6
+ # retired, so a retired type's code stays reserved rather than reusable.
7
+ # `::Spree::Base` etc. are qualified from the root: the
8
+ # `solidus_advanced_pricing/spree` decorator namespace shadows the bare
9
+ # `Spree` constant here otherwise (Zeitwerk const-shadowing gotcha).
10
+ class PriceType < ::Spree::Base
11
+ include ::Spree::SoftDeletable
12
+
13
+ self.table_name = "solidus_advanced_pricing_price_types"
14
+
15
+ # Needed for the solidus_admin index search box (name_or_code_cont); Ransack
16
+ # denies any attribute not explicitly allowed here.
17
+ self.allowed_ransackable_attributes = %w[name code]
18
+
19
+ has_many :prices,
20
+ class_name: "::Spree::Price",
21
+ foreign_key: :price_type_id,
22
+ inverse_of: :price_type
23
+
24
+ # Not `dependent: :restrict_with_error` — that check is default-scoped and
25
+ # misses discarded prices, which then trip the FK on DELETE.
26
+ before_destroy :prevent_destroying_referenced_type
27
+
28
+ before_validation :normalize_code
29
+
30
+ validates :name, presence: true
31
+ # Codes are normalized to lowercase so a plain unique index IS the rule on
32
+ # PostgreSQL, MySQL and SQLite alike. `case_sensitive: false` would have the
33
+ # validator and the index disagree on PG/SQLite, letting `insert_all` create
34
+ # two rows differing only in case.
35
+ validates :code, presence: true, uniqueness: {case_sensitive: true}
36
+
37
+ scope :ordered, -> { order(:position, :id) }
38
+
39
+ SEEDS = [
40
+ {code: "wholesale", name: "Wholesale", position: 1},
41
+ {code: "sale", name: "Sale", position: 2},
42
+ {code: "clearance", name: "Clearance", position: 3},
43
+ {code: "employee", name: "Employee", position: 4},
44
+ {code: "map", name: "MAP", position: 5},
45
+ {code: "promotional", name: "Promotional", position: 6}
46
+ ].freeze
47
+
48
+ # Idempotent. Used by the test suite and available to stores for re-seeding.
49
+ # The migration deliberately does NOT call this — a historical migration must
50
+ # not depend on current app code — so the two lists may drift, which is fine:
51
+ # the migration is history, this is the present.
52
+ def self.seed!
53
+ SEEDS.each do |attrs|
54
+ with_discarded.find_or_create_by!(code: attrs[:code]) do |price_type|
55
+ price_type.name = attrs[:name]
56
+ price_type.position = attrs[:position]
57
+ end
58
+ end
59
+ end
60
+
61
+ def to_s
62
+ name
63
+ end
64
+
65
+ after_commit { SolidusAdvancedPricing::PriceTypeCache.clear }
66
+
67
+ private
68
+
69
+ def normalize_code
70
+ self.code = code&.strip&.downcase.presence
71
+ end
72
+
73
+ def prevent_destroying_referenced_type
74
+ return unless prices.with_discarded.exists?
75
+
76
+ errors.add(:base, :referenced_by_prices)
77
+ throw :abort
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusAdvancedPricing
4
+ # Process-local memoization for values read on every pricing lookup.
5
+ # Cleared by PriceType's after_save/after_destroy/after_discard callbacks
6
+ # and, later, by Spree::Price.
7
+ module PriceTypeCache
8
+ class << self
9
+ def pricing_role_ids
10
+ return @pricing_role_ids if defined?(@pricing_role_ids)
11
+
12
+ @pricing_role_ids = ::Spree::Price.distinct.pluck(:role_id).compact
13
+ end
14
+
15
+ # id => position, for the selector's tie-breaker. A handful of rows that
16
+ # change almost never, so one memoized map beats a query per candidate price.
17
+ def positions
18
+ return @positions if defined?(@positions)
19
+
20
+ @positions = PriceType.with_discarded.pluck(:id, :position).to_h
21
+ end
22
+
23
+ def position_for(price_type_id)
24
+ positions.fetch(price_type_id, 0)
25
+ end
26
+
27
+ # code => id. Handful of rows that change almost never.
28
+ def ids_by_code
29
+ return @ids_by_code if defined?(@ids_by_code)
30
+
31
+ @ids_by_code = PriceType.with_discarded.pluck(:code, :id).to_h
32
+ end
33
+
34
+ def id_for(code)
35
+ ids_by_code[code.to_s]
36
+ end
37
+
38
+ def clear
39
+ remove_instance_variable(:@pricing_role_ids) if defined?(@pricing_role_ids)
40
+ remove_instance_variable(:@positions) if defined?(@positions)
41
+ remove_instance_variable(:@ids_by_code) if defined?(@ids_by_code)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusAdvancedPricing
4
+ # desired_attributes describes the price being sought and may hold only assignable
5
+ # Spree::Price columns, because core calls prices.build(default_price_attributes).
6
+ # at/customer_role_ids describe who is asking and live outside that hash.
7
+ class PricingOptions < ::Spree::Variant::PricingOptions
8
+ attr_reader :at, :customer_role_ids
9
+
10
+ def self.default_price_attributes
11
+ super.merge(
12
+ price_type_id: nil,
13
+ role_id: nil
14
+ )
15
+ end
16
+
17
+ # price_type_id: :any means "no type filter, every type competes". nil, the
18
+ # default set by default_price_attributes above, means "untyped base prices
19
+ # only" and must not leak into a customer-facing lookup, or a sale price
20
+ # could never be selected.
21
+ def self.from_line_item(line_item)
22
+ options = super
23
+ new(
24
+ options.desired_attributes.merge(
25
+ price_type_id: :any,
26
+ customer_role_ids: pricing_relevant_role_ids(line_item.order&.user)
27
+ )
28
+ )
29
+ end
30
+
31
+ # price_type_id: :any (see from_line_item above).
32
+ def self.from_context(context)
33
+ options = super
34
+ new(
35
+ options.desired_attributes.merge(
36
+ price_type_id: :any,
37
+ customer_role_ids: pricing_relevant_role_ids(context.try(:current_spree_user))
38
+ )
39
+ )
40
+ end
41
+
42
+ def self.from_price(price)
43
+ new(
44
+ currency: price.currency,
45
+ country_iso: price.country_iso,
46
+ price_type_id: price.price_type_id,
47
+ role_id: price.role_id
48
+ )
49
+ end
50
+
51
+ # Narrowed to roles some price actually uses: selection is unaffected, but it
52
+ # collapses cache key cardinality (Task 12).
53
+ def self.pricing_relevant_role_ids(user)
54
+ return [] if user.nil?
55
+
56
+ user.spree_role_ids & SolidusAdvancedPricing::PriceTypeCache.pricing_role_ids
57
+ end
58
+
59
+ def initialize(desired_attributes = {})
60
+ attributes = desired_attributes.dup
61
+ @at = attributes.key?(:at) ? attributes.delete(:at) : Time.current
62
+ @customer_role_ids = Array(attributes.delete(:customer_role_ids))
63
+ super(attributes)
64
+ end
65
+
66
+ # Core keys only on desired_attributes; customer context lives outside it, so
67
+ # without this a role-targeted price would be cached and served to a guest.
68
+ def cache_key
69
+ [super, roles_cache_component, time_cache_component].compact.join("/")
70
+ end
71
+
72
+ # nil is a real filter now (price_type_id IS NULL matches untyped base prices)
73
+ # and is kept; :any means no type filter at all, so it's stripped instead —
74
+ # it's a selector-only sentinel and not a column value `where` could use.
75
+ def search_arguments
76
+ arguments = desired_attributes.dup
77
+ arguments[:country_iso] = [desired_attributes[:country_iso], nil].flatten.uniq
78
+ arguments[:role_id] = [nil, *customer_role_ids].uniq
79
+ arguments.delete(:price_type_id) if desired_attributes[:price_type_id] == :any
80
+ arguments
81
+ end
82
+
83
+ # at and customer_role_ids live outside desired_attributes, so they have to be
84
+ # carried explicitly or a derived lookup silently becomes an admin one.
85
+ def with(**overrides)
86
+ self.class.new(
87
+ desired_attributes.merge(at: at, customer_role_ids: customer_role_ids).merge(overrides)
88
+ )
89
+ end
90
+
91
+ private
92
+
93
+ def roles_cache_component
94
+ return "r-none" if customer_role_ids.empty?
95
+
96
+ "r-#{customer_role_ids.sort.join("-")}"
97
+ end
98
+
99
+ def time_cache_component
100
+ granularity = ::Spree::Config.advanced_pricing_cache_granularity.to_i
101
+ return nil if granularity <= 0
102
+ return nil if at.nil?
103
+
104
+ "t-#{at.to_i / granularity}"
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,2 @@
1
+ <!-- insert_bottom "[data-hook='admin_product_price_form']" -->
2
+ <%= render 'spree/admin/prices/advanced_fields', f: f %>
@@ -0,0 +1,2 @@
1
+ <!-- insert_before "[data-hook='prices_row'] td.actions" -->
2
+ <%= render 'spree/admin/prices/advanced_columns', price: price %>
@@ -0,0 +1,4 @@
1
+ <!-- insert_before "[data-hook='master_prices_header'] th.actions" -->
2
+ <th><%= SolidusAdvancedPricing::PriceType.model_name.human %></th>
3
+ <th><%= Spree::Price.human_attribute_name(:role_id) %></th>
4
+ <th><%= t('solidus_advanced_pricing.validity') %></th>
@@ -0,0 +1,2 @@
1
+ <!-- insert_before "[data-hook='prices_row'] td.actions" -->
2
+ <%= render 'spree/admin/prices/advanced_columns', price: price %>
@@ -0,0 +1,4 @@
1
+ <!-- insert_before "[data-hook='prices_header'] th.actions" -->
2
+ <th><%= SolidusAdvancedPricing::PriceType.model_name.human %></th>
3
+ <th><%= Spree::Price.human_attribute_name(:role_id) %></th>
4
+ <th><%= t('solidus_advanced_pricing.validity') %></th>
@@ -0,0 +1,4 @@
1
+ <!-- insert_bottom "[data-hook='admin_configurations_sidebar_menu']" -->
2
+ <% if can?(:manage, SolidusAdvancedPricing::PriceType) %>
3
+ <%= settings_tab_item t('solidus_advanced_pricing.price_types'), spree.admin_price_types_path %>
4
+ <% end %>
data/bin/console ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "bundler/setup"
6
+ require "solidus_advanced_pricing"
7
+
8
+ # You can add fixtures and/or initialization code here to make experimenting
9
+ # with your gem easier. You can also use a different console, if you like.
10
+ $LOAD_PATH.unshift(*Dir["#{__dir__}/../app/*"])
11
+
12
+ # (If you use this, don't forget to add pry to your Gemfile!)
13
+ # require "pry"
14
+ # Pry.start
15
+
16
+ require "irb"
17
+ IRB.start(__FILE__)
data/bin/rails ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ if %w[g generate].include? ARGV.first
4
+ exec "#{__dir__}/rails-engine", *ARGV
5
+ else
6
+ exec "#{__dir__}/rails-sandbox", *ARGV
7
+ end
data/bin/rails-engine ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # This command will automatically be run when you run "rails" with Rails gems
3
+ # installed from the root of your application.
4
+
5
+ ENGINE_ROOT = File.expand_path('..', __dir__)
6
+ ENGINE_PATH = File.expand_path('../lib/solidus_advanced_pricing/engine', __dir__)
7
+
8
+ # Set up gems listed in the Gemfile.
9
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
10
+ require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
11
+
12
+ require 'rails/all'
13
+ require 'rails/engine/commands'
data/bin/rails-sandbox ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ app_root = "sandbox"
4
+
5
+ unless File.exist? "#{app_root}/bin/rails"
6
+ warn "Creating the sandbox app..."
7
+ Dir.chdir "#{__dir__}/.." do
8
+ system "#{__dir__}/sandbox" or begin
9
+ warn "Automatic creation of the sandbox app failed"
10
+ exit 1
11
+ end
12
+ end
13
+ end
14
+
15
+ Dir.chdir app_root
16
+ exec "bin/rails", *ARGV
data/bin/rake ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rubygems"
5
+ require "bundler/setup"
6
+
7
+ load Gem.bin_path("rake", "rake")
data/bin/sandbox ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -e
4
+ test -z "${DEBUG+empty_string}" || set -x
5
+
6
+ test "$DB" = "sqlite" && export DB="sqlite3"
7
+
8
+ if [ -z "$PAYMENT_METHOD" ]
9
+ then
10
+ PAYMENT_METHOD="none"
11
+ fi
12
+
13
+ if [ -z "$SOLIDUS_BRANCH" ]
14
+ then
15
+ echo "~~> Use 'export SOLIDUS_BRANCH=[main|v4.0|...]' to control the Solidus branch"
16
+ SOLIDUS_BRANCH="main"
17
+ fi
18
+ echo "~~> Using branch $SOLIDUS_BRANCH of solidus"
19
+
20
+ extension_name="solidus_advanced_pricing"
21
+
22
+ # Stay away from the bundler env of the containing extension.
23
+ function unbundled {
24
+ ruby -rbundler -e'
25
+ Bundler.with_unbundled_env {system *ARGV}' -- \
26
+ env BUNDLE_SUPPRESS_INSTALL_USING_MESSAGES=true $@
27
+ }
28
+
29
+ echo "~~~> Removing the old sandbox"
30
+ rm -rf ./sandbox
31
+
32
+ echo "~~~> Creating a pristine Rails app"
33
+ rails_version=`bundle exec ruby -e'require "rails"; puts Rails.version'`
34
+ rails _${rails_version}_ new sandbox \
35
+ --database="${DB:-sqlite3}" \
36
+ --skip-git \
37
+ --skip-keeps \
38
+ --skip-rc \
39
+ --skip-bootsnap \
40
+ --skip-test
41
+
42
+ if [ ! -d "sandbox" ]; then
43
+ echo 'sandbox rails application failed'
44
+ exit 1
45
+ fi
46
+
47
+ echo "~~~> Adding solidus (with i18n) to the Gemfile"
48
+ cd ./sandbox
49
+ cat <<RUBY >> Gemfile
50
+ gem 'solidus', github: 'solidusio/solidus', branch: '$SOLIDUS_BRANCH'
51
+ gem 'rails-i18n'
52
+ gem 'solidus_i18n'
53
+ gem 'solidus_auth_devise'
54
+
55
+ gem '$extension_name', path: '..'
56
+
57
+ group :test, :development do
58
+ platforms :mri do
59
+ gem 'pry-byebug'
60
+ end
61
+ end
62
+ RUBY
63
+
64
+ echo "Generating manifest file"
65
+ mkdir -p app/assets/config
66
+ cat <<MANIFESTJS > app/assets/config/manifest.js
67
+ //= link_tree ../images
68
+ //= link_directory ../javascripts .js
69
+ //= link_directory ../stylesheets .css
70
+ MANIFESTJS
71
+
72
+ unbundled bundle install --gemfile Gemfile
73
+
74
+ unbundled bundle exec rake db:drop db:create
75
+
76
+ unbundled bundle exec rails generate solidus:install \
77
+ --auto-accept \
78
+ $@
79
+
80
+ unbundled bundle exec rails generate solidus:auth:install --auto-run-migrations
81
+ unbundled bundle exec rails generate ${extension_name}:install --auto-run-migrations
82
+
83
+ echo
84
+ echo "🚀 Sandbox app successfully created for $extension_name!"
85
+ echo "🧪 This app is intended for test purposes."
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ gem install bundler --conservative
7
+ bundle update
8
+ bin/rake clobber
@@ -0,0 +1,44 @@
1
+ # Sample localization file for English. Add more files in this directory for other locales.
2
+ # See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
3
+
4
+ en:
5
+ hello: Hello world
6
+ solidus_advanced_pricing:
7
+ price_types: "Price Types"
8
+ all_customers: "All customers"
9
+ role_hint: "Leave blank for all customers. A price type name alone grants no access control — an employee-only price needs this field set."
10
+ admin_notes_hint: "Internal only. Never shown to customers."
11
+ validity: "Validity"
12
+ solidus_admin:
13
+ menu_item:
14
+ price_types: "Price Types"
15
+ price_types:
16
+ title: "Price Types"
17
+ destroy:
18
+ success: "Price types were successfully removed."
19
+ activerecord:
20
+ models:
21
+ solidus_advanced_pricing/price_type:
22
+ one: "Price Type"
23
+ other: "Price Types"
24
+ attributes:
25
+ spree/price:
26
+ price_type_id: "Price Type"
27
+ role_id: "Visible to role"
28
+ valid_from: "Valid from"
29
+ valid_to: "Valid to"
30
+ admin_notes: "Admin notes"
31
+ errors:
32
+ models:
33
+ solidus_advanced_pricing/price_type:
34
+ attributes:
35
+ base:
36
+ referenced_by_prices: "This price type is still used by one or more prices and cannot be deleted. Retire it instead."
37
+ spree/price:
38
+ attributes:
39
+ valid_to:
40
+ must_be_after_valid_from: "must be after the valid from date"
41
+ spree/role:
42
+ attributes:
43
+ base:
44
+ referenced_by_prices: "This role is used to target one or more prices and cannot be deleted."
data/config/routes.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ Spree::Core::Engine.routes.draw do
4
+ namespace :api, defaults: {format: "json"} do
5
+ resources :variants, only: [] do
6
+ resources :prices, only: [:index, :show]
7
+ end
8
+ end
9
+
10
+ namespace :admin do
11
+ resources :price_types, except: [:show]
12
+ end
13
+ end
14
+
15
+ # SolidusAdmin::ResourcesController, which our controller inherits from, only exists
16
+ # from Solidus 4.5 -- solidus_admin itself ships from 4.3. Version check rather than
17
+ # a constant check: routes are drawn before solidus_admin's autoloads are ready.
18
+ if defined?(SolidusAdmin::Engine) && Spree.solidus_gem_version >= Gem::Version.new("4.5")
19
+ SolidusAdmin::Engine.routes.draw do
20
+ # admin_resources is added via `extend` on the Mapper instance solidus_admin's
21
+ # own routes.rb draws with -- a separate `draw` call like this one gets its
22
+ # own Mapper, so it isn't inherited and has to be required/extended again.
23
+ require "solidus_admin/admin_resources"
24
+ extend SolidusAdmin::AdminResources
25
+
26
+ # :index and the batch :destroy only -- SolidusAdmin::Engine is mounted
27
+ # before Spree::Core::Engine (see spec/dummy's routes.rb), so a wider set
28
+ # here (e.g. :new, :edit) would shadow the legacy backend's own working
29
+ # forms at the same /admin/price_types/... paths with a 404, since this
30
+ # gem intentionally doesn't implement solidus_admin new/edit components
31
+ # for price types.
32
+ admin_resources :price_types, only: [:index, :destroy]
33
+ end
34
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSolidusAdvancedPricingPriceTypes < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :solidus_advanced_pricing_price_types do |t|
6
+ t.string :name, null: false
7
+ t.string :code, null: false
8
+ t.integer :position, null: false, default: 0
9
+ t.datetime :deleted_at
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :solidus_advanced_pricing_price_types, :code, unique: true
14
+ add_index :solidus_advanced_pricing_price_types, :deleted_at
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SeedSolidusAdvancedPricingPriceTypes < ActiveRecord::Migration[7.0]
4
+ SEEDS = [
5
+ {code: "wholesale", name: "Wholesale", position: 1},
6
+ {code: "sale", name: "Sale", position: 2},
7
+ {code: "clearance", name: "Clearance", position: 3},
8
+ {code: "employee", name: "Employee", position: 4},
9
+ {code: "map", name: "MAP", position: 5},
10
+ {code: "promotional", name: "Promotional", position: 6}
11
+ ].freeze
12
+
13
+ def up
14
+ now = Time.current
15
+
16
+ SEEDS.each do |attrs|
17
+ next if price_types.where(code: attrs[:code]).exists?
18
+
19
+ price_types.insert_all([attrs.merge(created_at: now, updated_at: now)])
20
+ end
21
+ end
22
+
23
+ def down
24
+ price_types.where(code: SEEDS.map { |attrs| attrs[:code] }).delete_all
25
+ end
26
+
27
+ private
28
+
29
+ def price_types
30
+ @price_types ||= Class.new(ActiveRecord::Base) do
31
+ self.table_name = "solidus_advanced_pricing_price_types"
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddAdvancedPricingToSpreePrices < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :spree_prices, :price_type_id, :bigint
6
+ # :integer, not :bigint — spree_roles.id is int and MySQL rejects mismatched FK precision.
7
+ add_column :spree_prices, :role_id, :integer
8
+ add_column :spree_prices, :valid_from, :datetime
9
+ add_column :spree_prices, :valid_to, :datetime
10
+ add_column :spree_prices, :admin_notes, :text
11
+
12
+ add_index :spree_prices, :price_type_id
13
+ add_index :spree_prices, :role_id
14
+ add_index :spree_prices,
15
+ [:variant_id, :currency, :country_iso],
16
+ name: "index_spree_prices_on_advanced_pricing_lookup"
17
+
18
+ add_foreign_key :spree_prices, :solidus_advanced_pricing_price_types, column: :price_type_id, on_delete: :restrict
19
+ add_foreign_key :spree_prices, :spree_roles, column: :role_id, on_delete: :restrict
20
+ end
21
+ end