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.
- checksums.yaml +4 -4
- data/app/helpers/spree/base_helper.rb +12 -2
- data/app/helpers/spree/currency_helper.rb +1 -4
- data/app/helpers/spree/locale_helper.rb +18 -13
- data/app/helpers/spree/localized_names_helper.rb +25 -0
- data/app/mailers/spree/admin_user_mailer.rb +71 -0
- data/app/mailers/spree/base_mailer.rb +73 -11
- data/app/mailers/spree/export_mailer.rb +7 -7
- data/app/mailers/spree/import_mailer.rb +19 -0
- data/app/mailers/spree/invitation_mailer.rb +16 -11
- data/app/mailers/spree/report_mailer.rb +6 -6
- data/app/mailers/spree/webhook_mailer.rb +7 -6
- data/app/models/concerns/spree/admin_user_methods.rb +44 -0
- data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
- data/app/models/concerns/spree/presentation_translatable.rb +39 -0
- data/app/models/concerns/spree/product_scopes.rb +21 -3
- data/app/models/concerns/spree/single_store_resource.rb +5 -0
- data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
- data/app/models/concerns/spree/stores/markets.rb +9 -1
- data/app/models/concerns/spree/stores/setup.rb +37 -23
- data/app/models/concerns/spree/translatable_resource.rb +95 -5
- data/app/models/concerns/spree/user_methods.rb +27 -2
- data/app/models/spree/ability.rb +5 -4
- data/app/models/spree/allowed_origin.rb +31 -0
- data/app/models/spree/category.rb +12 -2
- data/app/models/spree/channel/gating.rb +67 -0
- data/app/models/spree/channel.rb +1 -0
- data/app/models/spree/classification.rb +12 -0
- data/app/models/spree/country.rb +22 -1
- data/app/models/spree/credit_card.rb +29 -0
- data/app/models/spree/currency.rb +47 -0
- data/app/models/spree/current.rb +12 -1
- data/app/models/spree/customer_group.rb +1 -0
- data/app/models/spree/export.rb +19 -0
- data/app/models/spree/exports/coupon_codes.rb +1 -2
- data/app/models/spree/import.rb +84 -1
- data/app/models/spree/import_row.rb +7 -1
- data/app/models/spree/imports/product_translations.rb +6 -0
- data/app/models/spree/locale.rb +104 -0
- data/app/models/spree/option_type.rb +11 -13
- data/app/models/spree/option_value.rb +5 -17
- data/app/models/spree/order.rb +19 -2
- data/app/models/spree/payment_method.rb +6 -5
- data/app/models/spree/policy.rb +2 -1
- data/app/models/spree/product.rb +23 -2
- data/app/models/spree/promotion/rules/channel.rb +37 -0
- data/app/models/spree/promotion/rules/market.rb +37 -0
- data/app/models/spree/promotion.rb +5 -3
- data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
- data/app/models/spree/role_user.rb +4 -0
- data/app/models/spree/search_provider/meilisearch.rb +22 -9
- data/app/models/spree/setup_task.rb +12 -0
- data/app/models/spree/setup_tasks.rb +86 -0
- data/app/models/spree/shipment.rb +38 -1
- data/app/models/spree/shipment_handler.rb +1 -1
- data/app/models/spree/stock/availability_validator.rb +1 -1
- data/app/models/spree/stock/quantifier.rb +21 -1
- data/app/models/spree/stock_item.rb +2 -1
- data/app/models/spree/stock_location.rb +4 -1
- data/app/models/spree/store.rb +45 -9
- data/app/models/spree/store_payment_method.rb +5 -1
- data/app/models/spree/store_promotion.rb +4 -0
- data/app/models/spree/taxon.rb +112 -9
- data/app/models/spree/taxonomy.rb +1 -1
- data/app/models/spree/translations/batch.rb +104 -0
- data/app/models/spree/variant.rb +29 -2
- data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
- data/app/services/spree/carts/complete.rb +3 -0
- data/app/services/spree/fulfillments/create.rb +259 -0
- data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
- data/app/services/spree/locales.rb +52 -0
- data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
- data/app/services/spree/shipments/update.rb +1 -1
- data/app/services/spree/stock_reservations/reserve.rb +4 -1
- data/app/services/spree/taxons/add_products.rb +3 -0
- data/app/services/spree/taxons/remove_products.rb +3 -0
- data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
- data/app/subscribers/spree/import_email_subscriber.rb +26 -0
- data/app/views/layouts/spree/base_mailer.html.erb +14 -17
- data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
- data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
- data/app/views/spree/export_mailer/export_done.html.erb +6 -14
- data/app/views/spree/import_mailer/import_done.html.erb +12 -0
- data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
- data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
- data/app/views/spree/report_mailer/report_done.html.erb +6 -14
- data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
- data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
- data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
- data/app/views/spree/shared/_mailer_button.html.erb +10 -0
- data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
- data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
- data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
- data/config/initializers/spree_store_setup_tasks.rb +23 -0
- data/config/locales/en.yml +42 -992
- data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
- data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
- data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
- data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
- data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
- data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
- data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
- data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
- data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
- data/db/sample_data/promotions.rb +1 -1
- data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
- data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
- data/lib/spree/core/configuration.rb +1 -0
- data/lib/spree/core/controller_helpers/locale.rb +4 -1
- data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
- data/lib/spree/core/dependencies.rb +3 -0
- data/lib/spree/core/engine.rb +24 -0
- data/lib/spree/core/preferences/preferable.rb +6 -1
- data/lib/spree/core/previews/admin_user_preview.rb +22 -0
- data/lib/spree/core/previews/export_preview.rb +37 -0
- data/lib/spree/core/previews/invitation_preview.rb +47 -0
- data/lib/spree/core/previews/preview_data.rb +52 -0
- data/lib/spree/core/previews/report_preview.rb +40 -0
- data/lib/spree/core/previews/webhook_preview.rb +34 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +22 -1
- data/lib/spree/permitted_attributes.rb +5 -4
- data/lib/spree/testing_support/factories/export_factory.rb +4 -0
- data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
- data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
- data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
- data/lib/spree/translations.rb +113 -0
- data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
- data/lib/tasks/role_users.rake +21 -0
- data/lib/tasks/single_store_associations.rake +65 -0
- data/lib/tasks/taxons.rake +62 -1
- metadata +47 -5
- 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
|
-
#
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
24
|
-
|
|
29
|
+
def setup_task_done?(task)
|
|
30
|
+
setup_tasks.find { |t| t.name == task.to_s }&.done
|
|
31
|
+
end
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
37
|
+
def setup_tasks_total
|
|
38
|
+
setup_tasks.size
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
def setup_tasks_done
|
|
36
|
-
|
|
42
|
+
setup_tasks.count(&:done)
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
def setup_completed?
|
|
40
|
-
|
|
46
|
+
setup_tasks_done == setup_tasks_total
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
def setup_percentage
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/app/models/spree/ability.rb
CHANGED
|
@@ -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+
|
|
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(
|
|
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
|
|
3
|
-
#
|
|
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
|
data/app/models/spree/channel.rb
CHANGED
|
@@ -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
|
data/app/models/spree/country.rb
CHANGED
|
@@ -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:
|
|
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
|
data/app/models/spree/current.rb
CHANGED
|
@@ -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]
|