spree_core 5.5.3 → 5.6.0.rc1

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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +12 -2
  3. data/app/helpers/spree/currency_helper.rb +1 -4
  4. data/app/helpers/spree/locale_helper.rb +18 -13
  5. data/app/helpers/spree/localized_names_helper.rb +25 -0
  6. data/app/mailers/spree/admin_user_mailer.rb +71 -0
  7. data/app/mailers/spree/base_mailer.rb +73 -11
  8. data/app/mailers/spree/export_mailer.rb +7 -7
  9. data/app/mailers/spree/import_mailer.rb +19 -0
  10. data/app/mailers/spree/invitation_mailer.rb +16 -11
  11. data/app/mailers/spree/report_mailer.rb +6 -6
  12. data/app/mailers/spree/webhook_mailer.rb +7 -6
  13. data/app/models/concerns/spree/admin_user_methods.rb +44 -0
  14. data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
  15. data/app/models/concerns/spree/presentation_translatable.rb +39 -0
  16. data/app/models/concerns/spree/product_scopes.rb +21 -3
  17. data/app/models/concerns/spree/single_store_resource.rb +5 -0
  18. data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
  19. data/app/models/concerns/spree/stores/markets.rb +9 -1
  20. data/app/models/concerns/spree/stores/setup.rb +37 -23
  21. data/app/models/concerns/spree/translatable_resource.rb +95 -5
  22. data/app/models/concerns/spree/user_methods.rb +27 -2
  23. data/app/models/spree/ability.rb +5 -4
  24. data/app/models/spree/allowed_origin.rb +31 -0
  25. data/app/models/spree/category.rb +12 -2
  26. data/app/models/spree/channel/gating.rb +67 -0
  27. data/app/models/spree/channel.rb +1 -0
  28. data/app/models/spree/classification.rb +12 -0
  29. data/app/models/spree/country.rb +22 -1
  30. data/app/models/spree/credit_card.rb +29 -0
  31. data/app/models/spree/currency.rb +47 -0
  32. data/app/models/spree/current.rb +12 -1
  33. data/app/models/spree/customer_group.rb +1 -0
  34. data/app/models/spree/export.rb +19 -0
  35. data/app/models/spree/exports/coupon_codes.rb +1 -2
  36. data/app/models/spree/import.rb +84 -1
  37. data/app/models/spree/import_row.rb +7 -1
  38. data/app/models/spree/imports/product_translations.rb +6 -0
  39. data/app/models/spree/locale.rb +104 -0
  40. data/app/models/spree/option_type.rb +11 -13
  41. data/app/models/spree/option_value.rb +5 -17
  42. data/app/models/spree/order.rb +19 -2
  43. data/app/models/spree/payment_method.rb +6 -5
  44. data/app/models/spree/policy.rb +2 -1
  45. data/app/models/spree/product.rb +23 -2
  46. data/app/models/spree/promotion/rules/channel.rb +37 -0
  47. data/app/models/spree/promotion/rules/market.rb +37 -0
  48. data/app/models/spree/promotion.rb +5 -3
  49. data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
  50. data/app/models/spree/role_user.rb +4 -0
  51. data/app/models/spree/search_provider/meilisearch.rb +22 -9
  52. data/app/models/spree/setup_task.rb +12 -0
  53. data/app/models/spree/setup_tasks.rb +86 -0
  54. data/app/models/spree/shipment.rb +38 -1
  55. data/app/models/spree/shipment_handler.rb +1 -1
  56. data/app/models/spree/stock/availability_validator.rb +1 -1
  57. data/app/models/spree/stock/quantifier.rb +21 -1
  58. data/app/models/spree/stock_item.rb +2 -1
  59. data/app/models/spree/stock_location.rb +4 -1
  60. data/app/models/spree/store.rb +45 -9
  61. data/app/models/spree/store_payment_method.rb +5 -1
  62. data/app/models/spree/store_promotion.rb +4 -0
  63. data/app/models/spree/taxon.rb +112 -9
  64. data/app/models/spree/taxonomy.rb +1 -1
  65. data/app/models/spree/translations/batch.rb +104 -0
  66. data/app/models/spree/variant.rb +29 -2
  67. data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
  68. data/app/services/spree/carts/complete.rb +3 -0
  69. data/app/services/spree/fulfillments/create.rb +259 -0
  70. data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
  71. data/app/services/spree/locales.rb +52 -0
  72. data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
  73. data/app/services/spree/shipments/update.rb +1 -1
  74. data/app/services/spree/stock_reservations/reserve.rb +4 -1
  75. data/app/services/spree/taxons/add_products.rb +3 -0
  76. data/app/services/spree/taxons/remove_products.rb +3 -0
  77. data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
  78. data/app/subscribers/spree/import_email_subscriber.rb +26 -0
  79. data/app/views/layouts/spree/base_mailer.html.erb +14 -17
  80. data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
  81. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
  82. data/app/views/spree/export_mailer/export_done.html.erb +6 -14
  83. data/app/views/spree/import_mailer/import_done.html.erb +12 -0
  84. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
  85. data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
  86. data/app/views/spree/report_mailer/report_done.html.erb +6 -14
  87. data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
  88. data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
  89. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
  90. data/app/views/spree/shared/_mailer_button.html.erb +10 -0
  91. data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
  92. data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
  93. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
  94. data/config/initializers/spree_store_setup_tasks.rb +23 -0
  95. data/config/locales/en.yml +42 -992
  96. data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
  97. data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
  98. data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
  99. data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
  100. data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
  101. data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
  102. data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
  103. data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
  104. data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
  105. data/db/sample_data/promotions.rb +1 -1
  106. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
  107. data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
  108. data/lib/spree/core/configuration.rb +1 -0
  109. data/lib/spree/core/controller_helpers/locale.rb +4 -1
  110. data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
  111. data/lib/spree/core/dependencies.rb +3 -0
  112. data/lib/spree/core/engine.rb +24 -0
  113. data/lib/spree/core/preferences/preferable.rb +6 -1
  114. data/lib/spree/core/previews/admin_user_preview.rb +22 -0
  115. data/lib/spree/core/previews/export_preview.rb +37 -0
  116. data/lib/spree/core/previews/invitation_preview.rb +47 -0
  117. data/lib/spree/core/previews/preview_data.rb +52 -0
  118. data/lib/spree/core/previews/report_preview.rb +40 -0
  119. data/lib/spree/core/previews/webhook_preview.rb +34 -0
  120. data/lib/spree/core/version.rb +1 -1
  121. data/lib/spree/core.rb +22 -1
  122. data/lib/spree/permitted_attributes.rb +5 -4
  123. data/lib/spree/testing_support/factories/export_factory.rb +4 -0
  124. data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
  125. data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
  126. data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
  127. data/lib/spree/translations.rb +113 -0
  128. data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
  129. data/lib/tasks/role_users.rake +21 -0
  130. data/lib/tasks/single_store_associations.rake +65 -0
  131. data/lib/tasks/taxons.rake +62 -1
  132. metadata +47 -5
  133. data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
@@ -1,8 +1,18 @@
1
1
  module Spree
2
+ # @deprecated Use a single-store +belongs_to :store+ FK instead. This concern
3
+ # modelled the historic many-to-many store relationship (Promotion,
4
+ # PaymentMethod) and is removed in Spree 6.0 once those models finish
5
+ # migrating to single-store ownership. See
6
+ # docs/plans/5.6-6.0-single-store-promotions-payment-methods.md.
2
7
  module StoreScopedResource
3
8
  extend ActiveSupport::Concern
4
9
 
5
10
  included do
11
+ Spree::Deprecation.warn(
12
+ "Spree::StoreScopedResource is deprecated and will be removed in Spree 6.0. " \
13
+ "Use a single-store `belongs_to :store` FK instead (see Spree::Promotion / Spree::PaymentMethod)."
14
+ )
15
+
6
16
  scope :for_store, ->(store) { joins(:stores).where(Store.table_name => { id: store.id }) }
7
17
 
8
18
  before_validation :set_default_store, if: :new_record?
@@ -91,7 +91,8 @@ module Spree
91
91
  end
92
92
  end
93
93
 
94
- # Returns supported locales derived from markets, falling back to store attributes
94
+ # Supported locale codes derived from markets, falling back to store
95
+ # attributes.
95
96
  # @return [Array<String>]
96
97
  def supported_locales_list
97
98
  @supported_locales_list ||= if has_markets?
@@ -101,6 +102,13 @@ module Spree
101
102
  end
102
103
  end
103
104
 
105
+ # Supported locales as rich Spree::Locale objects (name, default?, rtl?).
106
+ # The string-code form is +supported_locales_list+.
107
+ # @return [Array<Spree::Locale>]
108
+ def supported_locales
109
+ @supported_locales ||= supported_locales_list.map { |code| Spree::Locale.new(code: code) }
110
+ end
111
+
104
112
  # Returns true if the store has markets, false otherwise
105
113
  # @return [Boolean]
106
114
  def has_markets?
@@ -3,50 +3,64 @@ module Spree
3
3
  module Setup
4
4
  extend ActiveSupport::Concern
5
5
 
6
- def setup_task_done?(task)
7
- case task
8
- when :setup_payment_method
9
- payment_method_setup?
10
- when :setup_taxes_collection
11
- Spree::TaxRate.any?
12
- when :add_products
13
- products.any?
14
- when :set_customer_support_email
15
- customer_support_email.present?
6
+ # The evaluated Getting Started checklist for this store, in display
7
+ # order — what the Admin API serializes and every other setup method
8
+ # derives from. Memoized per instance: the dashboard evaluates the
9
+ # checklist several times per render (sidebar badge, progress bar, task
10
+ # list) and each done-check costs a query.
11
+ #
12
+ # @return [Array<Spree::SetupTask>]
13
+ def setup_tasks
14
+ return [] if deleted?
15
+
16
+ @setup_tasks ||= Spree.store_setup_tasks.for(self).map do |definition|
17
+ Spree::SetupTask.new(name: definition.key.to_s, done: definition.done?(self))
16
18
  end
17
19
  end
18
20
 
19
- def setup_tasks_total
20
- @setup_tasks_total = setup_tasks_list.count
21
+ # Drop the memoized checklist with the rest of the instance's state, so
22
+ # a mutate-then-rerender flow (e.g. a Turbo Stream update) can re-read
23
+ # completion within one request.
24
+ def reload(options = nil)
25
+ @setup_tasks = nil
26
+ super
21
27
  end
22
28
 
23
- def setup_tasks_list
24
- return [] if deleted?
29
+ def setup_task_done?(task)
30
+ setup_tasks.find { |t| t.name == task.to_s }&.done
31
+ end
25
32
 
26
- @setup_tasks_list = []
27
- @setup_tasks_list << :setup_payment_method
28
- @setup_tasks_list << :add_products
29
- @setup_tasks_list << :set_customer_support_email
30
- @setup_tasks_list << :setup_taxes_collection
33
+ def setup_tasks_list
34
+ setup_tasks.map { |task| task.name.to_sym }
35
+ end
31
36
 
32
- @setup_tasks_list
37
+ def setup_tasks_total
38
+ setup_tasks.size
33
39
  end
34
40
 
35
41
  def setup_tasks_done
36
- @setup_tasks_done = setup_tasks_list.select { |task| setup_task_done?(task) }.count
42
+ setup_tasks.count(&:done)
37
43
  end
38
44
 
39
45
  def setup_completed?
40
- @setup_completed ||= setup_tasks_done == setup_tasks_total
46
+ setup_tasks_done == setup_tasks_total
41
47
  end
42
48
 
43
49
  def setup_percentage
44
- @setup_percentage ||= (setup_tasks_done / setup_tasks_total.to_f * 100).to_i
50
+ return 0 if setup_tasks_total.zero?
51
+
52
+ (setup_tasks_done / setup_tasks_total.to_f * 100).to_i
45
53
  end
46
54
 
47
55
  def payment_method_setup?
48
56
  payment_methods.active.where.not(type: Spree::PaymentMethod::StoreCredit.to_s).any?
49
57
  end
58
+
59
+ # A storefront counts as set up once the merchant has saved the storefront
60
+ # URL (entered manually or backfilled by the Vercel callback).
61
+ def storefront_setup?
62
+ preferred_storefront_url.present?
63
+ end
50
64
  end
51
65
  end
52
66
  end
@@ -5,11 +5,6 @@ module Spree
5
5
  included do
6
6
  extend Mobility
7
7
  default_scope { i18n }
8
-
9
- def get_field_with_locale(locale, field_name, fallback: false)
10
- # method will return nil if no translation is present due to fallback: false setting
11
- public_send(field_name, locale: locale, fallback: fallback)
12
- end
13
8
  end
14
9
 
15
10
  class_methods do
@@ -17,6 +12,39 @@ module Spree
17
12
  const_get(:TRANSLATABLE_FIELDS)
18
13
  end
19
14
 
15
+ # Maps a public API field name to the internal Mobility field, for models
16
+ # whose translatable column has a legacy name (e.g. OptionType exposes
17
+ # +label+ but stores +presentation+). The public name is what the API
18
+ # serializer and translation matrix use; the writer alias (+label=+)
19
+ # already routes to the internal field through Mobility. Override per
20
+ # model; default is identity.
21
+ #
22
+ # @return [Hash{Symbol=>Symbol}] public_name => internal_field
23
+ def translatable_field_aliases
24
+ {}
25
+ end
26
+
27
+ # The translatable fields by their PUBLIC API name (read/write symmetry),
28
+ # substituting any aliased public name for the internal field.
29
+ #
30
+ # @return [Array<Symbol>]
31
+ def public_translatable_fields
32
+ inverse = translatable_field_aliases.invert
33
+ translatable_fields.map { |field| inverse[field] || field }
34
+ end
35
+
36
+ # Translatable fields that hold rich text (HTML). Drives generic editor
37
+ # rendering — the translation matrix tags these as +html+ so the SPA shows
38
+ # a rich-text editor instead of a plain input. A +text+ column is
39
+ # type-indistinguishable from a plain string, so the model must declare
40
+ # which of its fields are rich text (via a +RICH_TEXT_TRANSLATABLE_FIELDS+
41
+ # constant). Default is none.
42
+ #
43
+ # @return [Array<Symbol>] public field names
44
+ def translatable_rich_text_fields
45
+ const_defined?(:RICH_TEXT_TRANSLATABLE_FIELDS) ? const_get(:RICH_TEXT_TRANSLATABLE_FIELDS) : []
46
+ end
47
+
20
48
  def translation_table_alias
21
49
  "#{self::Translation.table_name}_#{Mobility.normalize_locale(Mobility.locale)}"
22
50
  end
@@ -25,5 +53,67 @@ module Spree
25
53
  Arel::Table.new(translation_table_alias)
26
54
  end
27
55
  end
56
+
57
+ def get_field_with_locale(locale, field_name, fallback: false)
58
+ # method will return nil if no translation is present due to fallback: false setting
59
+ public_send(field_name, locale: locale, fallback: fallback)
60
+ end
61
+
62
+ # Upserts per-locale, per-field translations.
63
+ #
64
+ # Semantics (omit-to-leave-alone, never implicit-delete):
65
+ # - a locale absent from +values+ is untouched
66
+ # - a field absent within a present locale is untouched
67
+ # - a field set to +""+ writes an empty string (read falls back to source
68
+ # via Mobility +column_fallback+); +nil+ deletes that cell
69
+ #
70
+ # Value normalization (HTML sanitization, slug parameterization) is owned by
71
+ # the model's own writers — this only routes each value to its setter under
72
+ # the right locale.
73
+ #
74
+ # @param values [Hash{String=>Hash{String=>String,nil}}] locale => { field => value }
75
+ # @raise [ActiveRecord::RecordInvalid] on an unsupported locale or invalid save
76
+ # @return [self]
77
+ def upsert_translations(values)
78
+ return self if values.blank?
79
+
80
+ validate_translation_locales!(values.keys)
81
+ # Accept (and write) the PUBLIC field names so read/write are symmetric
82
+ # with the serializer. `label=` etc. delegate to the internal Mobility
83
+ # field under the active locale.
84
+ allowed = self.class.public_translatable_fields.map(&:to_s)
85
+
86
+ transaction do
87
+ values.each do |locale, fields|
88
+ Mobility.with_locale(locale) do
89
+ fields.to_h.slice(*allowed).each { |field, value| public_send("#{field}=", value) }
90
+ end
91
+ end
92
+ save!
93
+ end
94
+
95
+ self
96
+ end
97
+
98
+ # The store whose supported locales gate which translations are allowed and
99
+ # which locales the matrix exposes. The record's own store when it has one,
100
+ # else the current store.
101
+ def translatable_store
102
+ return self if is_a?(Spree::Store)
103
+
104
+ try(:store) || Spree::Current.store
105
+ end
106
+
107
+ private
108
+
109
+ # @raise [ActiveRecord::RecordInvalid] if any locale isn't supported by the store
110
+ def validate_translation_locales!(locales)
111
+ supported = translatable_store&.supported_locales_list || []
112
+ unsupported = locales.map(&:to_s) - supported.map(&:to_s)
113
+ return if unsupported.empty?
114
+
115
+ errors.add(:base, :unsupported_locale, message: "Unsupported locale(s): #{unsupported.join(', ')}")
116
+ raise ActiveRecord::RecordInvalid, self
117
+ end
28
118
  end
29
119
  end
@@ -48,7 +48,10 @@ module Spree
48
48
  # Token auto-invalidates when password changes (salt changes)
49
49
  # Expiration is configurable via Spree::Config.customer_password_reset_expires_in (in minutes)
50
50
  generates_token_for :password_reset, expires_in: Spree::Config.customer_password_reset_expires_in.minutes do
51
- password_salt&.last(10) || encrypted_password&.last(10)
51
+ # `try` rather than `&.`: models without a password_salt method or
52
+ # column at all (modern Devise) must fall through to encrypted_password
53
+ # — a bare call would raise NameError before the fallback.
54
+ try(:password_salt)&.last(10) || encrypted_password&.last(10)
52
55
  end
53
56
 
54
57
  # we need to have this callback before any dependent: :destroy associations
@@ -85,6 +88,17 @@ module Spree
85
88
  belongs_to :ship_address, class_name: 'Spree::Address', optional: true
86
89
  belongs_to :bill_address, class_name: 'Spree::Address', optional: true
87
90
 
91
+ # Replaces the customer's group membership. Accepts both prefixed IDs and
92
+ # raw integer IDs. Only groups belonging to the current store are
93
+ # assigned — ids from another store are dropped, preventing cross-store
94
+ # membership. Mirrors +Spree::Product#category_ids=+.
95
+ def customer_group_ids=(ids)
96
+ decoded_ids = Array(ids).filter_map do |id|
97
+ id.to_s.include?('_') ? Spree::CustomerGroup.decode_prefixed_id(id) : id
98
+ end
99
+ super(Spree::CustomerGroup.for_store(Spree::Current.store).where(id: decoded_ids).ids)
100
+ end
101
+
88
102
  #
89
103
  # Attachments
90
104
  #
@@ -200,7 +214,18 @@ module Spree
200
214
  def total_available_store_credit(currency = nil, store = nil)
201
215
  store ||= Store.default
202
216
  currency ||= store.default_currency
203
- store_credits.without_gift_card.for_store(store).where(currency: currency).reload.to_a.sum(&:amount_remaining)
217
+
218
+ # When the association is already loaded (e.g. preloaded via the admin
219
+ # API's scope_includes), filter in memory to avoid an N+1 — but mirror
220
+ # the query branch exactly: non-gift-card credits, scoped to the store
221
+ # and currency. A gift card is identified by its originator_type.
222
+ if store_credits.loaded?
223
+ store_credits.
224
+ select { |sc| sc.originator_type != 'Spree::GiftCard' && sc.store_id.to_s == store.id.to_s && sc.currency == currency }.
225
+ sum(&:amount_remaining)
226
+ else
227
+ store_credits.without_gift_card.for_store(store).where(currency: currency).to_a.sum(&:amount_remaining)
228
+ end
204
229
  end
205
230
 
206
231
  # Returns the available store credits for the current store per currency
@@ -45,17 +45,18 @@ module Spree
45
45
  end
46
46
 
47
47
  # Determines the role names for the current user, scoped to the current
48
- # store. A +Spree::RoleUser+ binds a role to a store via +resource+, so a
49
- # role held on one store does not apply on another.
48
+ # store. A +Spree::RoleUser+ is bound to a store via its +store_id+ (set from
49
+ # the role's resource), so a role held on one store does not apply on another,
50
+ # independent of the polymorphic +resource+ the role is attached to.
50
51
  #
51
52
  # @return [Array<Symbol>] the role names
52
53
  def determine_role_names
53
54
  return [:default] unless @user.persisted?
54
55
 
55
56
  if @user.respond_to?(:role_users)
56
- role_names = @user.role_users.where(resource: @store).
57
+ role_names = @user.role_users.where(store: @store).
57
58
  joins(:role).
58
- pluck("#{Spree::Role.table_name}.name").map(&:to_sym)
59
+ pluck("#{Spree::Role.table_name}.name").map(&:to_sym).uniq
59
60
  return role_names if role_names.any?
60
61
  end
61
62
 
@@ -32,6 +32,37 @@ module Spree
32
32
  nil
33
33
  end
34
34
 
35
+ # Normalizes free-form input (possibly a bare host like `my-shop.vercel.app`)
36
+ # to a canonical origin string (`scheme://host[:port]`, the scheme's default
37
+ # port stripped), or nil when it's not a valid http(s) URL.
38
+ #
39
+ # @param raw [String, nil]
40
+ # @return [String, nil]
41
+ def self.normalize_origin(raw)
42
+ raw = raw.to_s.strip
43
+ return if raw.blank?
44
+
45
+ candidate = raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
46
+ parsed = parse_origin(candidate)
47
+ return if parsed.nil?
48
+
49
+ origin = "#{parsed[:scheme]}://#{parsed[:host]}"
50
+ # Strip only the scheme's own default — https://host:80 must keep its
51
+ # port, or the normalized origin would silently mean a different one.
52
+ default_port = parsed[:scheme] == 'https' ? 443 : 80
53
+ origin += ":#{parsed[:port]}" unless parsed[:port] == default_port
54
+ origin
55
+ end
56
+
57
+ # Whether this origin points at a loopback/development host ({LOOPBACK_HOSTS}),
58
+ # e.g. the `http://localhost` origin seeded on install. Loopback origins are
59
+ # ignored when deciding if a real storefront has been connected to the store.
60
+ #
61
+ # @return [Boolean]
62
+ def loopback?
63
+ LOOPBACK_HOSTS.include?(self.class.parse_origin(origin)&.dig(:host))
64
+ end
65
+
35
66
  # Returns true if the given URL's origin matches this stored origin.
36
67
  #
37
68
  # Scheme and host must match exactly (host comparison is case- and trailing-dot-
@@ -1,6 +1,16 @@
1
1
  module Spree
2
- # Public API name for Taxon. Will become the base class in 6.0
3
- # when spree_taxons table is renamed to spree_categories.
2
+ # Public API name for Taxon, used by the admin dashboard. Unlike a legacy
3
+ # +Spree::Taxon+, a Category does not require a +Spree::Taxonomy+ — it is
4
+ # owned directly via +store_id+, so a parentless category is a genuine
5
+ # top-level node. (Legacy taxonomy-backed taxons are unaffected.) Will become
6
+ # the base class in 6.0 when +Spree::Taxonomy+ is dropped and +spree_taxons+
7
+ # is renamed to +spree_categories+.
4
8
  class Category < Taxon
9
+ default_scope { manual }
10
+
11
+ # A category is owned directly via +store_id+ and never needs a taxonomy.
12
+ def requires_taxonomy?
13
+ false
14
+ end
5
15
  end
6
16
  end
@@ -0,0 +1,67 @@
1
+ module Spree
2
+ class Channel
3
+ # Storefront access gating for a channel. Decides what an anonymous
4
+ # (not-logged-in) visitor may see and whether an order may be placed
5
+ # without an account. Both controls fall back to the owning Store when the
6
+ # channel preference is unset, mirroring +OrderRouting::HasStrategyPreference+.
7
+ module Gating
8
+ extend ActiveSupport::Concern
9
+
10
+ # Posture controlling catalog/price visibility for anonymous visitors:
11
+ # - +public+ products + prices visible to anyone
12
+ # - +prices_hidden+ products browsable, prices null for guests
13
+ # - +login_required+ catalog reads rejected for guests
14
+ STOREFRONT_ACCESS = %w[public prices_hidden login_required].freeze
15
+
16
+ included do
17
+ # Empty -> falls back to the Store-level preference. `guest_checkout` is
18
+ # `nullable` so a blank write stays nil (inherit) rather than coercing to
19
+ # false, while an explicit true/false remains a channel override.
20
+ preference :storefront_access, :string, default: nil
21
+ preference :guest_checkout, :boolean, default: nil, nullable: true
22
+
23
+ validate :storefront_access_must_be_valid
24
+ end
25
+
26
+ # @return [String] the effective access posture: the channel preference,
27
+ # or the store fallback when unset, defaulting to +public+.
28
+ def resolved_storefront_access
29
+ preferred_storefront_access.presence ||
30
+ store&.preferred_storefront_access.presence ||
31
+ 'public'
32
+ end
33
+
34
+ # @return [Boolean] whether guest checkout is allowed: the channel
35
+ # preference, or the store fallback when the channel value is unset.
36
+ def resolved_guest_checkout
37
+ value = preferred_guest_checkout
38
+ return value unless value.nil?
39
+
40
+ store.nil? ? true : store.preferred_guest_checkout
41
+ end
42
+
43
+ # @return [Boolean] true when guests must not see prices on this channel.
44
+ def storefront_prices_hidden?
45
+ resolved_storefront_access == 'prices_hidden'
46
+ end
47
+
48
+ # @return [Boolean] true when guests must authenticate before browsing.
49
+ def storefront_login_required?
50
+ resolved_storefront_access == 'login_required'
51
+ end
52
+
53
+ private
54
+
55
+ def storefront_access_must_be_valid
56
+ value = preferred_storefront_access
57
+ return if value.blank?
58
+ return if STOREFRONT_ACCESS.include?(value.to_s)
59
+
60
+ errors.add(
61
+ :preferred_storefront_access,
62
+ Spree.t(:invalid_storefront_access, scope: [:errors, :messages], default: 'is not a valid storefront access level')
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -11,6 +11,7 @@ module Spree
11
11
  include Spree::Metafields
12
12
  include Spree::Metadata
13
13
  include Spree::OrderRouting::HasStrategyPreference
14
+ include Spree::Channel::Gating
14
15
 
15
16
  # Empty -> falls back to the Store-level preference.
16
17
  preference :order_routing_strategy, :string, default: nil
@@ -13,6 +13,12 @@ module Spree
13
13
  # For #3494
14
14
  validates :taxon_id, uniqueness: { scope: :product_id, message: :already_linked, allow_blank: true }
15
15
 
16
+ # Keep the taxon's descendant-inclusive products_count (and its ancestors')
17
+ # in sync on direct create/destroy. Bulk Taxons::AddProducts/RemoveProducts
18
+ # skip these callbacks and recompute explicitly.
19
+ after_create :recalculate_taxon_products_count
20
+ after_destroy :recalculate_taxon_products_count
21
+
16
22
  self.whitelisted_ransackable_attributes = ['taxon_id', 'product_id']
17
23
 
18
24
  scope :by_best_selling, lambda { |order_direction = :desc|
@@ -36,5 +42,11 @@ module Spree
36
42
  end
37
43
  end
38
44
  }
45
+
46
+ private
47
+
48
+ def recalculate_taxon_products_count
49
+ Spree::Taxon.recalculate_products_count(taxon_id)
50
+ end
39
51
  end
40
52
  end
@@ -31,7 +31,7 @@ module Spree
31
31
  pluck(:name, :id, :iso).map do |name, id, iso|
32
32
  {
33
33
  id: id,
34
- name: "#{iso_to_emoji_flag(iso)} #{name}"
34
+ name: new(iso: iso, name: name).option_label
35
35
  }
36
36
  end.as_json
37
37
  end
@@ -46,6 +46,27 @@ module Spree
46
46
  @current_market ||= Spree::Current.store&.market_for_country(self)
47
47
  end
48
48
 
49
+ # Display name localized to +locale+ via the countries gem (CLDR), falling
50
+ # back to the stored +name+ (or the ISO when there is no name) for an
51
+ # unknown ISO or missing translation.
52
+ # @param locale [Symbol, String]
53
+ # @return [String]
54
+ def localized_name(locale: I18n.locale)
55
+ fallback = name.presence || iso.to_s
56
+ data = ISO3166::Country[iso.to_s.upcase]
57
+ return fallback unless data
58
+
59
+ base_locale = locale.to_s.downcase.tr('_', '-').split('-').first
60
+ (data.translation(base_locale) || data.translation(:en)).presence || fallback
61
+ end
62
+
63
+ # Flag emoji + localized name, for select options.
64
+ # @param locale [Symbol, String]
65
+ # @return [String]
66
+ def option_label(locale: I18n.locale)
67
+ "#{self.class.iso_to_emoji_flag(iso)} #{localized_name(locale: locale)}"
68
+ end
69
+
49
70
  def <=>(other)
50
71
  name <=> other.name
51
72
  end
@@ -37,6 +37,11 @@ module Spree
37
37
  validates :name, presence: true
38
38
  end
39
39
 
40
+ # Prevents saving the same physical card (same gateway fingerprint + expiry)
41
+ # twice for a user and payment method. Skipped when the gateway does not
42
+ # provide a fingerprint.
43
+ validate :fingerprint_not_duplicated, if: -> { fingerprint.present? }
44
+
40
45
  scope :with_payment_profile, -> { where.not(gateway_customer_profile_id: nil) }
41
46
  scope :capturable, -> { where.not(gateway_customer_profile_id: nil).or(where.not(gateway_payment_profile_id: nil)) }
42
47
  scope :default, -> { where(default: true) }
@@ -47,6 +52,19 @@ module Spree
47
52
  }
48
53
  scope :not_removed, -> { where(deleted_at: nil) }
49
54
 
55
+ # Matches saved cards by the gateway's stable card fingerprint plus expiry,
56
+ # used to reuse an existing card instead of saving a duplicate when a gateway
57
+ # issues a fresh payment method id for the same physical card.
58
+ #
59
+ # @param fingerprint [String] gateway card fingerprint
60
+ # @param month [Integer, String] expiry month
61
+ # @param year [Integer, String] expiry year
62
+ scope :by_fingerprint, ->(fingerprint, month, year) {
63
+ where(fingerprint: fingerprint).
64
+ where("CAST(#{table_name}.month AS DECIMAL) = ?", month).
65
+ where("CAST(#{table_name}.year AS DECIMAL) = ?", year)
66
+ }
67
+
50
68
  alias_attribute :brand, :cc_type
51
69
  alias_attribute :last4, :last_digits
52
70
 
@@ -165,6 +183,17 @@ module Spree
165
183
 
166
184
  private
167
185
 
186
+ # month/year are varchar columns exposed as integer attributes, so the
187
+ # match must go through #by_fingerprint's decimal cast rather than a plain
188
+ # equality (which Postgres rejects as `character varying = integer`).
189
+ def fingerprint_not_duplicated
190
+ duplicates = self.class.where(user_id: user_id, payment_method_id: payment_method_id).
191
+ by_fingerprint(fingerprint, month, year)
192
+ duplicates = duplicates.where.not(id: id) if persisted?
193
+
194
+ errors.add(:fingerprint, :taken) if duplicates.exists?
195
+ end
196
+
168
197
  def require_card_numbers?
169
198
  !encrypted_data.present? && !has_payment_profile?
170
199
  end
@@ -0,0 +1,47 @@
1
+ module Spree
2
+ # Virtual model for a currency. Wraps a currency +code+ (ISO 4217) and exposes
3
+ # its display name and select label — the single home for that logic instead
4
+ # of being restated in helpers. Mirrors +Spree::Locale+.
5
+ class Currency
6
+ include ActiveModel::Model
7
+ include Comparable
8
+
9
+ # @!attribute code
10
+ # @return [String] the currency code, e.g. "USD", "EUR"
11
+ attr_accessor :code
12
+
13
+ # Display name, e.g. "United States Dollar". Falls back to the code for an
14
+ # unknown currency.
15
+ # @return [String]
16
+ def name
17
+ # `find` returns nil (it does not raise) for unknown codes.
18
+ ::Money::Currency.find(code.to_s.upcase)&.name || code.to_s.upcase
19
+ end
20
+
21
+ # Select label, e.g. "USD — United States Dollar".
22
+ # @return [String]
23
+ def label
24
+ upper = code.to_s.upcase
25
+ return upper if name.blank? || name.casecmp?(code.to_s)
26
+
27
+ "#{upper} — #{name}"
28
+ end
29
+
30
+ def to_s
31
+ code.to_s.upcase
32
+ end
33
+
34
+ # Compare/equality by code so a Currency slots into string-keyed collections.
35
+ def <=>(other)
36
+ to_s <=> other.to_s
37
+ end
38
+
39
+ def eql?(other)
40
+ other.is_a?(Spree::Currency) && to_s == other.to_s
41
+ end
42
+
43
+ def hash
44
+ to_s.hash
45
+ end
46
+ end
47
+ end
@@ -4,7 +4,7 @@ module Spree
4
4
  # All attributes are automatically reset between requests by Rails.
5
5
  # Fallback chains ensure sensible defaults when attributes are not explicitly set.
6
6
  class Current < ::ActiveSupport::CurrentAttributes
7
- attribute :store, :channel, :market, :currency, :locale, :zone, :default_tax_zone, :price_lists, :global_pricing_context
7
+ attribute :store, :channel, :market, :currency, :locale, :content_locale, :zone, :default_tax_zone, :price_lists, :global_pricing_context
8
8
 
9
9
  resets { @default_tax_zone_loaded = false }
10
10
 
@@ -38,6 +38,17 @@ module Spree
38
38
  super || market&.default_locale.presence || store&.default_locale.presence || I18n.default_locale.to_s
39
39
  end
40
40
 
41
+ # Returns the locale that base (untranslated) record columns are authored
42
+ # in for the current request — the current store's default locale. It is
43
+ # assigned per request alongside +I18n.locale+ and must never be written
44
+ # to the process-global +I18n.default_locale+, which every thread in the
45
+ # server process shares. Outside a request it falls back to the
46
+ # application default locale.
47
+ # @return [String] locale code, e.g. +"en"+, +"de"+
48
+ def content_locale
49
+ super.presence || I18n.default_locale.name
50
+ end
51
+
41
52
  # Returns the current tax zone.
42
53
  # Fallback: market's tax zone (from default country) -> global default tax zone.
43
54
  # @return [Spree::Zone, nil]
@@ -30,6 +30,7 @@ module Spree
30
30
  #
31
31
  # Instance Methods
32
32
  #
33
+ # FIXME: convert this to counter cache
33
34
  def customers_count
34
35
  customer_group_users.size
35
36
  end