spree_core 6.0.0.rc4 → 6.0.0.rc6
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/jobs/spree/images/save_from_url_job.rb +24 -5
- data/app/models/concerns/spree/account_deactivation.rb +54 -0
- data/app/models/concerns/spree/calculator/currency_amounts.rb +19 -6
- data/app/models/concerns/spree/derived_number.rb +1 -1
- data/app/models/concerns/spree/has_external_references.rb +24 -16
- data/app/models/concerns/spree/prefixed_id.rb +15 -5
- data/app/models/concerns/spree/publishable.rb +47 -1
- data/app/models/concerns/spree/purchase/addresses.rb +114 -36
- data/app/models/concerns/spree/purchase/digital_items.rb +17 -3
- data/app/models/concerns/spree/purchase/freight.rb +2 -1
- data/app/models/concerns/spree/purchase/payment_processing.rb +7 -1
- data/app/models/concerns/spree/purchase/quantity_rules.rb +2 -0
- data/app/models/concerns/spree/stores/channels.rb +3 -1
- data/app/models/concerns/spree/translatable_resource.rb +6 -1
- data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
- data/app/models/spree/ability.rb +8 -0
- data/app/models/spree/address.rb +6 -0
- data/app/models/spree/admin_user.rb +1 -0
- data/app/models/spree/base.rb +33 -1
- data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -1
- data/app/models/spree/calculator/shipping/flat_rate.rb +3 -1
- data/app/models/spree/calculator/shipping/per_item.rb +3 -1
- data/app/models/spree/cart.rb +7 -7
- data/app/models/spree/category.rb +76 -5
- data/app/models/spree/channel.rb +79 -40
- data/app/models/spree/company.rb +46 -3
- data/app/models/spree/customer.rb +1 -1
- data/app/models/spree/customer_group.rb +59 -41
- data/app/models/spree/data_request.rb +34 -1
- data/app/models/spree/delivery_method.rb +18 -8
- data/app/models/spree/delivery_profile.rb +18 -0
- data/app/models/spree/digital_asset.rb +14 -0
- data/app/models/spree/event.rb +20 -5
- data/app/models/spree/fulfillment.rb +8 -1
- data/app/models/spree/fulfillment_changer.rb +3 -1
- data/app/models/spree/fulfillment_item.rb +11 -0
- data/app/models/spree/import.rb +20 -2
- data/app/models/spree/order.rb +1 -1
- data/app/models/spree/order_routing/strategy/rules.rb +4 -1
- data/app/models/spree/price_list.rb +15 -0
- data/app/models/spree/product/channels.rb +16 -8
- data/app/models/spree/product.rb +7 -2
- data/app/models/spree/return.rb +8 -1
- data/app/models/spree/sales_context.rb +162 -0
- data/app/models/spree/search_provider/base.rb +8 -3
- data/app/models/spree/search_provider/database.rb +2 -10
- data/app/models/spree/seller.rb +35 -5
- data/app/models/spree/seller_balance.rb +28 -0
- data/app/models/spree/seller_requirements/delivery_method.rb +20 -2
- data/app/models/spree/stock/coordinator.rb +7 -4
- data/app/models/spree/stock/estimator.rb +6 -0
- data/app/models/spree/stock/inventory_unit_builder.rb +14 -2
- data/app/models/spree/stock/packer.rb +39 -5
- data/app/models/spree/stock/quantifier.rb +16 -0
- data/app/models/spree/stock_location.rb +15 -8
- data/app/models/spree/store.rb +31 -2
- data/app/models/spree/store_credit.rb +4 -4
- data/app/models/spree/tax_line.rb +5 -4
- data/app/models/spree/variant.rb +23 -6
- data/app/models/spree/webhook_endpoint.rb +45 -11
- data/app/services/spree/carts/destroy.rb +7 -2
- data/app/services/spree/carts/price_items.rb +3 -0
- data/app/services/spree/carts/split_by_seller.rb +3 -1
- data/app/services/spree/carts/update.rb +1 -4
- data/app/services/spree/catalogs/set_product_terms.rb +8 -3
- data/app/services/spree/collections/add_products.rb +1 -1
- data/app/services/spree/collections/regenerate_products.rb +1 -3
- data/app/services/spree/collections/remove_products.rb +1 -1
- data/app/services/spree/data_requests/confirm.rb +37 -0
- data/app/services/spree/data_requests/create.rb +61 -13
- data/app/services/spree/newsletter/subscribe.rb +9 -1
- data/app/services/spree/order_groups/allocate_payments.rb +7 -5
- data/app/services/spree/orders/update.rb +19 -0
- data/app/services/spree/product_memberships.rb +16 -6
- data/app/services/spree/products/duplicator.rb +12 -4
- data/app/services/spree/search_provider/custom_field_schema.rb +10 -2
- data/app/services/spree/stock_levels/correct.rb +11 -4
- data/app/services/spree/stock_reservations/reserve.rb +1 -1
- data/app/services/spree/stores/provision_defaults.rb +11 -7
- data/app/services/spree/tags/bulk_add.rb +16 -6
- data/app/workflows/spree/carts/complete.rb +25 -9
- data/app/workflows/spree/carts/upsert_items.rb +31 -4
- data/app/workflows/spree/customers/anonymize.rb +40 -19
- data/app/workflows/spree/data_requests/fulfill.rb +20 -6
- data/app/workflows/spree/digital_assets/create.rb +56 -0
- data/app/workflows/spree/digital_assets/update.rb +44 -0
- data/app/workflows/spree/digital_assets/writing.rb +108 -0
- data/app/workflows/spree/imports/start_mapping.rb +3 -1
- data/app/workflows/spree/orders/complete.rb +12 -5
- data/app/workflows/spree/orders/create.rb +41 -17
- data/app/workflows/spree/payments/capture.rb +12 -0
- data/app/workflows/spree/products/nested_attributes.rb +12 -28
- data/app/workflows/spree/products/propose.rb +9 -4
- data/app/workflows/spree/stock_levels/bulk_upsert.rb +28 -13
- data/app/workflows/spree/variants/nested_attributes.rb +35 -10
- data/config/locales/en.yml +14 -0
- data/db/migrate/20261022000001_store_credit_actor_polymorphic.rb +13 -0
- data/db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb +7 -0
- data/lib/generators/spree/dummy/templates/rails/test.rb +87 -0
- data/lib/spree/checkout/default_requirements.rb +12 -2
- data/lib/spree/core/dependencies.rb +5 -0
- data/lib/spree/core/engine.rb +2 -0
- data/lib/spree/core/permissions/default_catalog.rb +7 -2
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/reporting/default_vocabulary.rb +1 -1
- data/lib/spree/testing_support/factories/data_request_factory.rb +4 -0
- data/lib/spree/testing_support/factories/product_factory.rb +10 -4
- data/lib/spree/testing_support/factories/store_factory.rb +4 -1
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +10 -0
- data/lib/tasks/backfill_tagging_tenants.rake +35 -0
- data/lib/tasks/seller_sample_data.rake +114 -112
- metadata +15 -6
|
@@ -23,6 +23,7 @@ module Spree
|
|
|
23
23
|
@amount = capture_amount(amount)
|
|
24
24
|
|
|
25
25
|
step :ensure_capturable
|
|
26
|
+
step :ensure_within_payment_amount
|
|
26
27
|
# Veto point — fraud holds, manual review. Before any money moves.
|
|
27
28
|
run_hooks :validate
|
|
28
29
|
run_hooks :before_capture
|
|
@@ -69,6 +70,17 @@ module Spree
|
|
|
69
70
|
failure(payment, :payment_not_capturable)
|
|
70
71
|
end
|
|
71
72
|
|
|
73
|
+
# A capture takes some or all of what was authorized, never more: a
|
|
74
|
+
# gateway may let a larger figure through, and the books would then
|
|
75
|
+
# carry a capture the authorization never covered. Nor nothing, or less
|
|
76
|
+
# than nothing — every caller gets the same rule, not only the API.
|
|
77
|
+
def ensure_within_payment_amount
|
|
78
|
+
failure(payment, :capture_amount_not_positive) unless @amount.positive?
|
|
79
|
+
return if @amount <= payment.amount
|
|
80
|
+
|
|
81
|
+
failure(payment, :capture_exceeds_payment_amount)
|
|
82
|
+
end
|
|
83
|
+
|
|
72
84
|
def claim
|
|
73
85
|
halt!(payment) unless payment.started_processing!
|
|
74
86
|
rescue Spree::Core::GatewayError => error
|
|
@@ -195,6 +195,10 @@ module Spree
|
|
|
195
195
|
# accumulate copies. Omitting an entry leaves it alone; removal is the
|
|
196
196
|
# nested digital_assets DELETE endpoint's job.
|
|
197
197
|
#
|
|
198
|
+
# Delegates to the digital asset workflows, so a file written as part of
|
|
199
|
+
# a product payload passes the same gate — and the same :validate
|
|
200
|
+
# handlers — as one written through the digital assets endpoint.
|
|
201
|
+
#
|
|
198
202
|
# @param product [Spree::Product]
|
|
199
203
|
# @param digital_assets_params [Array<Hash>, nil]
|
|
200
204
|
# @return [void]
|
|
@@ -203,38 +207,18 @@ module Spree
|
|
|
203
207
|
|
|
204
208
|
digital_assets_params.each do |raw|
|
|
205
209
|
attrs = (raw.respond_to?(:to_h) ? raw.to_h : raw).with_indifferent_access
|
|
206
|
-
|
|
207
210
|
asset_id = attrs.delete(:id)
|
|
208
|
-
signed_id = attrs.delete(:signed_id)
|
|
209
|
-
variant_id = attrs.delete(:variant_id)
|
|
210
|
-
|
|
211
|
-
# An id patches an existing asset; without one this is a new asset on
|
|
212
|
-
# the named variant (or the default). A patch for an asset that is
|
|
213
|
-
# gone is skipped rather than recreated.
|
|
214
|
-
if asset_id.present?
|
|
215
|
-
asset = product.digital_assets.find_by_param(asset_id) || next
|
|
216
|
-
else
|
|
217
|
-
target = variant_id.present? ? product.variants.find_by_param!(variant_id) : product.default_variant
|
|
218
|
-
next unless target
|
|
219
211
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
# uploaded to the public bucket would stay publicly readable. Refuse
|
|
228
|
-
# it rather than attaching it wrongly.
|
|
229
|
-
blob = ActiveStorage::Blob.find_signed(signed_id)
|
|
230
|
-
unless blob && blob.service_name.to_s == Spree.private_storage_service_name.to_s
|
|
231
|
-
asset.errors.add(:attachment, :digital_assets_attachment_must_be_private, message: I18n.t('spree.digital_assets_attachment_must_be_private'))
|
|
232
|
-
raise ActiveRecord::RecordInvalid.new(asset)
|
|
212
|
+
result =
|
|
213
|
+
if asset_id.present?
|
|
214
|
+
# A patch for an asset that is gone is skipped rather than recreated.
|
|
215
|
+
asset = product.digital_assets.find_by_param(asset_id) || next
|
|
216
|
+
Spree.digital_asset_update_workflow.call(digital_asset: asset, attributes: attrs)
|
|
217
|
+
else
|
|
218
|
+
Spree.digital_asset_create_workflow.call(product: product, attributes: attrs)
|
|
233
219
|
end
|
|
234
220
|
|
|
235
|
-
|
|
236
|
-
end
|
|
237
|
-
asset.save!
|
|
221
|
+
raise ActiveRecord::RecordInvalid, result.value if result.failure?
|
|
238
222
|
end
|
|
239
223
|
end
|
|
240
224
|
end
|
|
@@ -13,16 +13,20 @@ module Spree
|
|
|
13
13
|
|
|
14
14
|
# @param product [Spree::Product]
|
|
15
15
|
# @param submitted_by [Spree.admin_user_class, nil] who asked
|
|
16
|
+
# @param reason [String, nil] why a listing already on sale goes back to
|
|
17
|
+
# review — what changed on it. Recorded on the submission row. Without
|
|
18
|
+
# one, only a draft or a rejected product can be submitted.
|
|
16
19
|
# @return [Spree::ServiceModule::Result] value is the product
|
|
17
|
-
def perform(product:, submitted_by: nil)
|
|
20
|
+
def perform(product:, submitted_by: nil, reason: nil)
|
|
18
21
|
super
|
|
19
22
|
|
|
20
23
|
# Only a listing that is being worked on can be submitted. Guarding
|
|
21
24
|
# `active?` alone let an archived product through — and with
|
|
22
25
|
# auto-approval on, a withdrawn listing went straight back on sale
|
|
23
26
|
# without anybody reviewing it. Re-submitting one already in review
|
|
24
|
-
# is equally meaningless.
|
|
25
|
-
|
|
27
|
+
# is equally meaningless. An active listing reopens review only when
|
|
28
|
+
# the caller says what changed, which no seller-facing action does.
|
|
29
|
+
unless product.draft? || product.rejected? || (reason.present? && product.active?)
|
|
26
30
|
reject!(:cannot_propose, message: I18n.t('activerecord.errors.models.spree/product.attributes.base.cannot_propose'))
|
|
27
31
|
end
|
|
28
32
|
|
|
@@ -51,7 +55,8 @@ module Spree
|
|
|
51
55
|
# one: the trail is how many times this was asked for, and rewriting
|
|
52
56
|
# the last answer would erase that.
|
|
53
57
|
def open_submission
|
|
54
|
-
product.submissions.create!(status: 'pending', submitted_by: submitted_by
|
|
58
|
+
product.submissions.create!(status: 'pending', submitted_by: submitted_by,
|
|
59
|
+
metadata: reason.present? ? { 'reason' => reason } : {})
|
|
55
60
|
end
|
|
56
61
|
|
|
57
62
|
def auto_approve?
|
|
@@ -35,9 +35,10 @@ module Spree
|
|
|
35
35
|
# means the later line, not the sum of both.
|
|
36
36
|
deduped = keyed.reverse.uniq { |row| [row[:variant_id].to_s, row[:stock_location_id].to_s] }.reverse
|
|
37
37
|
|
|
38
|
+
existing = existing_stock_levels(deduped)
|
|
38
39
|
count = 0
|
|
39
40
|
Spree::StockLevel.transaction do
|
|
40
|
-
deduped.each { |row| count += 1 if apply_row(row) }
|
|
41
|
+
deduped.each { |row| count += 1 if apply_row(row, existing) }
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
success(stock_level_count: count)
|
|
@@ -52,27 +53,41 @@ module Spree
|
|
|
52
53
|
# happens to name — including another merchant's. One caller decoding
|
|
53
54
|
# ids correctly does not make the workflow safe for the next one.
|
|
54
55
|
def resolve(rows)
|
|
55
|
-
variants =
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
@variants = scoped_records(store_variants, Spree::Variant, rows.map { |row| row[:variant_id] })
|
|
57
|
+
@stock_locations = scoped_records(store_stock_locations, Spree::StockLocation,
|
|
58
|
+
rows.map { |row| row[:stock_location_id] })
|
|
59
|
+
@variants_by_id = @variants.values.index_by(&:id)
|
|
60
|
+
@stock_locations_by_id = @stock_locations.values.index_by(&:id)
|
|
58
61
|
|
|
59
62
|
rows.map do |row|
|
|
60
63
|
row.merge(
|
|
61
|
-
variant_id: variants[row[:variant_id]],
|
|
62
|
-
stock_location_id:
|
|
64
|
+
variant_id: @variants[row[:variant_id]]&.id,
|
|
65
|
+
stock_location_id: @stock_locations[row[:stock_location_id]]&.id
|
|
63
66
|
).compact
|
|
64
67
|
end
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
# Accepts a prefixed id or a raw one: the Admin API speaks prefixed,
|
|
68
71
|
# while the importer and seeds hold records they already loaded.
|
|
69
|
-
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash{Object => ActiveRecord::Base}] each value as given => this store's record
|
|
74
|
+
def scoped_records(scope, model, values)
|
|
70
75
|
decoded = values.compact.uniq.index_with do |value|
|
|
71
76
|
model.decode_own_prefixed_id(value) || value
|
|
72
77
|
end
|
|
73
|
-
found = scope.where(id: decoded.values.compact).
|
|
78
|
+
found = scope.where(id: decoded.values.compact).index_by { |record| record.id.to_s }
|
|
79
|
+
|
|
80
|
+
decoded.transform_values { |id| found[id.to_s] if id }.compact
|
|
81
|
+
end
|
|
74
82
|
|
|
75
|
-
|
|
83
|
+
# Every shelf the feed names, in one query rather than one per row.
|
|
84
|
+
# Lowest id wins, as an unordered lookup per pair would return.
|
|
85
|
+
def existing_stock_levels(rows)
|
|
86
|
+
Spree::StockLevel.
|
|
87
|
+
where(variant_id: rows.map { |row| row[:variant_id] }.uniq,
|
|
88
|
+
stock_location_id: rows.map { |row| row[:stock_location_id] }.uniq).
|
|
89
|
+
order(id: :desc).
|
|
90
|
+
index_by { |stock_level| [stock_level.variant_id.to_s, stock_level.stock_location_id.to_s] }
|
|
76
91
|
end
|
|
77
92
|
|
|
78
93
|
def store_variants
|
|
@@ -83,10 +98,10 @@ module Spree
|
|
|
83
98
|
@store.stock_locations
|
|
84
99
|
end
|
|
85
100
|
|
|
86
|
-
def apply_row(row)
|
|
87
|
-
stock_level = Spree::StockLevel.
|
|
88
|
-
|
|
89
|
-
)
|
|
101
|
+
def apply_row(row, existing)
|
|
102
|
+
stock_level = existing[[row[:variant_id].to_s, row[:stock_location_id].to_s]] || Spree::StockLevel.new
|
|
103
|
+
stock_level.variant = @variants_by_id.fetch(row[:variant_id])
|
|
104
|
+
stock_level.stock_location = @stock_locations_by_id.fetch(row[:stock_location_id])
|
|
90
105
|
stock_level.backorderable = row[:backorderable] if row.key?(:backorderable)
|
|
91
106
|
stock_level.save! if stock_level.new_record? || stock_level.changed?
|
|
92
107
|
|
|
@@ -48,6 +48,8 @@ module Spree
|
|
|
48
48
|
return if prices_params.nil?
|
|
49
49
|
|
|
50
50
|
currencies = []
|
|
51
|
+
# Loaded once so each currency's write finds its row in memory.
|
|
52
|
+
variant.prices.load if variant.persisted?
|
|
51
53
|
|
|
52
54
|
Array(prices_params).each do |raw|
|
|
53
55
|
price = raw.to_h.with_indifferent_access
|
|
@@ -59,7 +61,8 @@ module Spree
|
|
|
59
61
|
|
|
60
62
|
return unless variant.persisted?
|
|
61
63
|
|
|
62
|
-
variant.prices.
|
|
64
|
+
stale = variant.prices.select { |price| price.persisted? && price.price_list_id.nil? && currencies.exclude?(price.currency) }
|
|
65
|
+
variant.prices.destroy(*stale) if stale.any?
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
# @param variant [Spree::Variant]
|
|
@@ -68,22 +71,44 @@ module Spree
|
|
|
68
71
|
def apply_stock_levels(variant, stock_levels_params)
|
|
69
72
|
return if stock_levels_params.blank?
|
|
70
73
|
|
|
71
|
-
Array(stock_levels_params).
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
rows = Array(stock_levels_params).map { |raw| raw.to_h.with_indifferent_access }
|
|
75
|
+
rows.reject! { |row| row[:stock_location_id].blank? }
|
|
76
|
+
return if rows.empty?
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
locations = stock_locations_by_param(variant, rows.map { |row| row[:stock_location_id] })
|
|
79
|
+
# Loaded once so each row's write finds its level in memory.
|
|
80
|
+
variant.stock_levels.load if variant.persisted?
|
|
81
|
+
|
|
82
|
+
rows.each do |row|
|
|
83
|
+
location = locations[row[:stock_location_id].to_s]
|
|
79
84
|
next if location.nil?
|
|
80
85
|
|
|
81
86
|
variant.set_stock(row[:count_on_hand], row[:backorderable], location)
|
|
82
87
|
end
|
|
88
|
+
|
|
89
|
+
# A count change locks and reloads its level, so the locations the
|
|
90
|
+
# response's stock figures read are loaded for every level afterwards.
|
|
91
|
+
ActiveRecord::Associations::Preloader.new(records: variant.stock_levels.to_a, associations: :stock_location).call
|
|
83
92
|
end
|
|
84
93
|
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
# Resolves every stock location the payload names in one read. Scoped
|
|
95
|
+
# through the product's own store: Spree::StockLocation lookup is
|
|
96
|
+
# global, so an id belonging to another store would otherwise put this
|
|
97
|
+
# variant's stock in that store's warehouse. A store keeps few
|
|
98
|
+
# locations, so loading them beats a lookup per row.
|
|
99
|
+
#
|
|
100
|
+
# @return [Hash{String => Spree::StockLocation}] keyed by both the
|
|
101
|
+
# prefixed id and the raw id, which is what the payload may carry
|
|
102
|
+
def stock_locations_by_param(variant, params)
|
|
103
|
+
scope = variant.product&.store&.stock_locations
|
|
104
|
+
return {} if scope.nil? || params.empty?
|
|
105
|
+
|
|
106
|
+
wanted = params.map(&:to_s).uniq
|
|
107
|
+
scope.each_with_object({}) do |location, memo|
|
|
108
|
+
[location.prefixed_id.to_s, location.id.to_s].each do |key|
|
|
109
|
+
memo[key] = location if wanted.include?(key)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
87
112
|
end
|
|
88
113
|
end
|
|
89
114
|
end
|
data/config/locales/en.yml
CHANGED
|
@@ -463,6 +463,9 @@ en:
|
|
|
463
463
|
required_for_carton_purchase_unit: is required when the purchase unit is cartons
|
|
464
464
|
spree/webhook_endpoint:
|
|
465
465
|
attributes:
|
|
466
|
+
subscriptions:
|
|
467
|
+
invalid_pattern: 'must hold event names or patterns such as order.* or *.created (rejected: %{entries})'
|
|
468
|
+
too_many: can hold at most %{count} entries
|
|
466
469
|
url:
|
|
467
470
|
internal_address_not_allowed: must not point to an internal or private network address
|
|
468
471
|
invalid_url: must be a valid http or https URL
|
|
@@ -685,6 +688,8 @@ en:
|
|
|
685
688
|
canceler: Canceler
|
|
686
689
|
cannot_be_destroyed: Order cannot be destroyed.
|
|
687
690
|
capture: Capture
|
|
691
|
+
capture_amount_not_positive: The capture amount must be greater than zero.
|
|
692
|
+
capture_exceeds_payment_amount: The capture amount cannot exceed the payment amount.
|
|
688
693
|
cart: Cart
|
|
689
694
|
cart_already_updated: The cart has already been updated.
|
|
690
695
|
cart_completion_in_progress: The cart is being completed. Please retry in a moment.
|
|
@@ -765,6 +770,7 @@ en:
|
|
|
765
770
|
created_at: Created at
|
|
766
771
|
credit_card: Credit Card
|
|
767
772
|
currency: Currency
|
|
773
|
+
currency_does_not_match_market: must be %{currency}, the currency of the chosen market
|
|
768
774
|
currency_not_supported_by_store: is not supported by this store
|
|
769
775
|
custom_field_definitions: Custom Field Definitions
|
|
770
776
|
custom_fields: Custom Fields
|
|
@@ -773,7 +779,9 @@ en:
|
|
|
773
779
|
anonymize_failed: The erasure could not be completed and nothing was changed
|
|
774
780
|
customers: Customers
|
|
775
781
|
data_request_errors:
|
|
782
|
+
address_changed: The account's email address changed after this request was made
|
|
776
783
|
already_anonymized: This customer's personal data has already been erased
|
|
784
|
+
confirmation_invalid: This confirmation link has expired or is no longer valid
|
|
777
785
|
download_unavailable: This download link has expired or is no longer valid
|
|
778
786
|
not_pending: This request has already been processed
|
|
779
787
|
default: Default
|
|
@@ -814,6 +822,11 @@ en:
|
|
|
814
822
|
digital_delivery: Digital Delivery
|
|
815
823
|
digital_assets: Digital assets
|
|
816
824
|
digital_assets_attachment_must_be_private: Upload the file to private storage before attaching it
|
|
825
|
+
digital_assets_content_type_list_invalid: 'Not a content type: %{types}. Use the type/subtype form, such as application/pdf'
|
|
826
|
+
digital_assets_content_type_not_accepted: "%{content_type} files are not accepted on this marketplace"
|
|
827
|
+
digital_assets_provider_takes_no_file: This file is delivered by the marketplace's own system, so a replacement file cannot be uploaded
|
|
828
|
+
digital_assets_sellers_disabled: This marketplace does not let sellers add or change digital files
|
|
829
|
+
digital_assets_upload_not_found: The uploaded file could not be found. Upload it again
|
|
817
830
|
discontinued: Discontinued
|
|
818
831
|
discontinued_variants_present: Some line items in this order have products that are no longer available.
|
|
819
832
|
display_delivery_range: Delivery in %{delivery_range} business days
|
|
@@ -1016,6 +1029,7 @@ en:
|
|
|
1016
1029
|
line_items: Line items
|
|
1017
1030
|
link: Link
|
|
1018
1031
|
locale: Locale
|
|
1032
|
+
locale_not_supported_by_market: is not offered by the chosen market
|
|
1019
1033
|
locale_not_supported_by_store: is not supported by this store
|
|
1020
1034
|
locales_not_listed: 'includes locales Spree does not support: %{codes}'
|
|
1021
1035
|
manual: Manual
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Makes a store credit's issuer polymorphic, so a credit issued with a secret
|
|
2
|
+
# API key — a return an integration refunds to store credit — records the key
|
|
3
|
+
# rather than failing. See docs/plans/6.0-action-actors.md.
|
|
4
|
+
class StoreCreditActorPolymorphic < ActiveRecord::Migration[8.1]
|
|
5
|
+
def change
|
|
6
|
+
# Nullable with no default: the type is data, filled by
|
|
7
|
+
# `rake spree:upgrade:backfill_actor_types`. Until it runs, the
|
|
8
|
+
# transitional reader resolves these rows through the admin user class.
|
|
9
|
+
add_column :spree_store_credits, :created_by_type, :string
|
|
10
|
+
add_index :spree_store_credits, [:created_by_type, :created_by_id],
|
|
11
|
+
name: 'index_spree_store_credits_on_created_by_actor'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Lets a staff account be switched off without deleting it, so records that
|
|
2
|
+
# name it as their actor keep their history. See Spree::AccountDeactivation.
|
|
3
|
+
class AddDeactivatedAtToSpreeAdminUsers < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
add_column :spree_admin_users, :deactivated_at, :datetime
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -77,4 +77,91 @@ Dummy::Application.configure do
|
|
|
77
77
|
config.active_record.encryption.key_derivation_salt = 'spree-test-key-derivation-salt'
|
|
78
78
|
|
|
79
79
|
routes.default_url_options = { host: 'localhost', port: 3000 }
|
|
80
|
+
|
|
81
|
+
# An N+1 inside an API request fails the example instead of being logged,
|
|
82
|
+
# so a serializer or model method that queries once per record cannot land
|
|
83
|
+
# unnoticed. Wrap a deliberately repeated lookup in Prosopite.pause.
|
|
84
|
+
if defined?(Prosopite)
|
|
85
|
+
# Hooked from Spree's own test app rather than declared in the
|
|
86
|
+
# controller, so a host application that bundles Prosopite for itself is
|
|
87
|
+
# never opted into Spree's guard. Prepended so the lookups made by the
|
|
88
|
+
# authentication and parent callbacks are scanned too.
|
|
89
|
+
config.to_prepare do
|
|
90
|
+
if defined?(Spree::Api::V3::BaseController)
|
|
91
|
+
Spree::Api::V3::BaseController.prepend_around_action { |_controller, action| Prosopite.scan(&action) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
Prosopite.rails_logger = true
|
|
96
|
+
# Raise only under RSpec, where a report belongs to the example that
|
|
97
|
+
# caused it. The same dummy app serves a real server for the SDK and
|
|
98
|
+
# browser suites, and there a raise becomes a 500 the client cannot act
|
|
99
|
+
# on — those runs log instead. SPREE_N_PLUS_ONE_GUARD overrides either
|
|
100
|
+
# way ('raise', 'log').
|
|
101
|
+
running_specs = ENV.key?('TEST_ENV_NUMBER') || $PROGRAM_NAME.end_with?('rspec')
|
|
102
|
+
guard_mode = ENV.fetch('SPREE_N_PLUS_ONE_GUARD') { running_specs ? 'raise' : 'log' }
|
|
103
|
+
Prosopite.raise = guard_mode == 'raise'
|
|
104
|
+
|
|
105
|
+
# The Rails cleaner keeps only the host app's frames, which hides the
|
|
106
|
+
# serializer or model line that issued the repeated query.
|
|
107
|
+
Prosopite.backtrace_cleaner = ActiveSupport::BacktraceCleaner.new.tap do |cleaner|
|
|
108
|
+
cleaner.add_silencer { |line| line.include?('/gems/') || line.include?('/rspec') }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The guard covers what a request reads. Writes run their callbacks one
|
|
112
|
+
# record at a time by design — a `dependent: :destroy` cascade, a number
|
|
113
|
+
# or uniqueness probe per created row — so statements issued under a
|
|
114
|
+
# save or destroy are not reported.
|
|
115
|
+
Prosopite.allow_stack_paths = [
|
|
116
|
+
%r{active_record/persistence\.rb:\d+:in '[^']*#(save|save!|destroy|destroy!|touch)'},
|
|
117
|
+
# A touch is a write too, and Spree overrides `touch` in its own
|
|
118
|
+
# concern, so the frame is Spree's rather than Active Record's.
|
|
119
|
+
%r{active_record/timestamp\.rb:\d+:in '[^']*#touch'},
|
|
120
|
+
%r{/spree/publishable\.rb:\d+:in '[^']*#touch'},
|
|
121
|
+
%r{/paranoia\.rb:\d+:in '[^']*destroy'},
|
|
122
|
+
# Validations run before the persistence frame starts, so a uniqueness
|
|
123
|
+
# or ladder probe on the row being saved needs its own entry.
|
|
124
|
+
%r{active_record/validations\.rb:\d+:in '[^']*#(save|save!)'},
|
|
125
|
+
# after_commit callbacks are the same per-record write work, deferred
|
|
126
|
+
# until the transaction that wrote the rows ends.
|
|
127
|
+
%r{active_record/transactions\.rb:\d+:in '[^']*#committed!'},
|
|
128
|
+
# Locking a row re-reads it, once per row the caller is about to write.
|
|
129
|
+
%r{active_record/locking/pessimistic\.rb:\d+:in '[^']*#lock!'},
|
|
130
|
+
# A product payload writes each nested variant and media row through its
|
|
131
|
+
# own workflow — SKU probe, option lookup, blob resolution — once per row.
|
|
132
|
+
%r{/spree/products/nested_attributes\.rb:\d+:in '[^']*#(write_variant|apply_media)'},
|
|
133
|
+
# A settlement is swept per currency, and each sweep writes its own payout.
|
|
134
|
+
%r{/spree/seller_payouts/sweep\.rb:\d+:in '[^']*#collect_transfers'},
|
|
135
|
+
# A split checkout re-sums and then places each seller's order it just
|
|
136
|
+
# wrote, one workflow run per order.
|
|
137
|
+
%r{/spree/carts/split_by_seller\.rb:\d+:in '[^']*#resum_totals'},
|
|
138
|
+
%r{/spree/orders/complete\.rb:\d+:in '[^']*#place_sibling'},
|
|
139
|
+
# Each requested line is priced, stock-checked, saved and taxed as it
|
|
140
|
+
# is written.
|
|
141
|
+
%r{/spree/carts/upsert_items\.rb:\d+:in '[^']*#apply_item'}
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
# A purchase's billing and shipping addresses, and a transfer's two
|
|
145
|
+
# warehouses, are two links into one table read through the same code,
|
|
146
|
+
# so one record reads as a repeat. Lists still load each link in one
|
|
147
|
+
# query for the whole page.
|
|
148
|
+
Prosopite.ignore_queries = [
|
|
149
|
+
/FROM [`"]?(spree_addresses|spree_stock_locations)[`"]? WHERE (.+ AND )?[`"]?\1[`"]?\.[`"]?id[`"]? (=|IN)/
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
# Prosopite fingerprints through pg_query, which rejects SQLite's `?`
|
|
153
|
+
# binds, so SQLite borrows the text normalizer written for MySQL. That
|
|
154
|
+
# normalizer blanks anything double-quoted, which is a string literal in
|
|
155
|
+
# MySQL but an identifier in SQLite — left alone it erases every table
|
|
156
|
+
# name, collapsing unrelated queries into one fingerprint and reporting
|
|
157
|
+
# a single record's distinct associations as an N+1. Re-quoting
|
|
158
|
+
# identifiers as backticks first keeps table names in the fingerprint.
|
|
159
|
+
Prosopite.singleton_class.prepend(Module.new do
|
|
160
|
+
def fingerprint(query)
|
|
161
|
+
return super unless ActiveRecord::Base.connection_db_config.adapter == 'sqlite3'
|
|
162
|
+
|
|
163
|
+
mysql_fingerprint(query.gsub(/"([^"]*)"/) { "`#{Regexp.last_match(1)}`" })
|
|
164
|
+
end
|
|
165
|
+
end)
|
|
166
|
+
end
|
|
80
167
|
end
|
|
@@ -107,7 +107,11 @@ module Spree
|
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
def completion_line_items
|
|
110
|
-
|
|
110
|
+
# Everything the discontinuation and stock checks read, for every line
|
|
111
|
+
# at once.
|
|
112
|
+
@completion_line_items ||= @cart.line_items.includes(
|
|
113
|
+
variant: [{ product: %i[store product_publications] }, { stock_levels: %i[stock_location active_stock_reservations] }]
|
|
114
|
+
).to_a
|
|
111
115
|
end
|
|
112
116
|
|
|
113
117
|
def delivery_step_required?
|
|
@@ -125,7 +129,13 @@ module Spree
|
|
|
125
129
|
# What checkout has to collect, which is the whole total unless an
|
|
126
130
|
# arrangement collects only part of it up front.
|
|
127
131
|
def payment_satisfied?
|
|
128
|
-
@cart.
|
|
132
|
+
paid = if @cart.read_association_in_memory?(:payments)
|
|
133
|
+
@cart.payments.reject(&:has_invalid_status?).sum(BigDecimal('0'), &:amount)
|
|
134
|
+
else
|
|
135
|
+
@cart.payments.valid.sum(:amount)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
paid >= @cart.amount_due_at_checkout
|
|
129
139
|
end
|
|
130
140
|
|
|
131
141
|
# Advisory rather than completion-only: the buyer has to be told the
|
|
@@ -186,6 +186,11 @@ module Spree
|
|
|
186
186
|
# handler sees a nested payload and the variants endpoint alike
|
|
187
187
|
variant_create_workflow: 'Spree::Variants::Create',
|
|
188
188
|
variant_update_workflow: 'Spree::Variants::Update',
|
|
189
|
+
|
|
190
|
+
# digital assets — the operator's and the seller's endpoints and the
|
|
191
|
+
# nested list on a product write all run through these
|
|
192
|
+
digital_asset_create_workflow: 'Spree::DigitalAssets::Create',
|
|
193
|
+
digital_asset_update_workflow: 'Spree::DigitalAssets::Update',
|
|
189
194
|
product_archive_workflow: 'Spree::Products::Archive',
|
|
190
195
|
product_draft_workflow: 'Spree::Products::Draft',
|
|
191
196
|
|
data/lib/spree/core/engine.rb
CHANGED
|
@@ -506,6 +506,7 @@ module Spree
|
|
|
506
506
|
Spree::Address,
|
|
507
507
|
Spree::Claim,
|
|
508
508
|
Spree::Collection,
|
|
509
|
+
Spree::Company,
|
|
509
510
|
Spree::CreditCard,
|
|
510
511
|
Spree::Exchange,
|
|
511
512
|
Spree::GiftCard,
|
|
@@ -521,6 +522,7 @@ module Spree
|
|
|
521
522
|
Spree::Product,
|
|
522
523
|
Spree::ProductType,
|
|
523
524
|
Spree::Promotion,
|
|
525
|
+
Spree::PurchaseOrder,
|
|
524
526
|
Spree::Refund,
|
|
525
527
|
Spree::Return,
|
|
526
528
|
Spree::Fulfillment,
|
|
@@ -165,7 +165,7 @@ module Spree
|
|
|
165
165
|
[Spree::Store, Spree::PaymentMethod, Spree::Gateway,
|
|
166
166
|
Spree::DeliveryZone, Spree::DeliveryZoneMember,
|
|
167
167
|
Spree::StockLocation, Spree::DeliveryProfile,
|
|
168
|
-
Spree::Market, Spree::TaxCategory, Spree::TaxRate,
|
|
168
|
+
Spree::Market, Spree::TaxCategory, Spree::TaxRate,
|
|
169
169
|
Spree::RefundReason, Spree::ReturnReason, Spree::ClaimReason,
|
|
170
170
|
Spree::OrderCancellationReason, Spree::TaxExemptionReason, Spree::Channel,
|
|
171
171
|
Spree::OrderRoutingRule, Spree::CustomFieldDefinition, Spree::Policy]
|
|
@@ -201,7 +201,12 @@ module Spree
|
|
|
201
201
|
})
|
|
202
202
|
catalog.register_scope(:integrations, group: :settings, resources: -> { [Spree::Integration] })
|
|
203
203
|
|
|
204
|
-
|
|
204
|
+
# The CORS allowlist is a credential as much as a key is: an origin on
|
|
205
|
+
# it may call the cookie-authenticated refresh endpoint from a browser
|
|
206
|
+
# and read back any signed-in admin's access token. So it sits with
|
|
207
|
+
# API keys rather than settings, under a scope a delegated OAuth token
|
|
208
|
+
# never holds.
|
|
209
|
+
catalog.register_scope(:api_keys, group: :access, resources: -> { [Spree::ApiKey, Spree::AllowedOrigin] })
|
|
205
210
|
# Connecting an AI agent hands a third party a standing credential to
|
|
206
211
|
# the store, so it is its own permission rather than part of settings
|
|
207
212
|
# — a merchandiser who may edit a delivery zone is not thereby someone
|
data/lib/spree/core/version.rb
CHANGED
|
@@ -541,7 +541,7 @@ module Spree
|
|
|
541
541
|
# and tax category per row and throw all of it away.
|
|
542
542
|
hydrate: lambda { |store, ids, _params|
|
|
543
543
|
currency = Spree::Current.currency || store.default_currency
|
|
544
|
-
products = store.products.with_deleted.includes(:primary_media).where(id: ids)
|
|
544
|
+
products = store.products.with_deleted.includes(:primary_media, default_variant: :prices).where(id: ids)
|
|
545
545
|
products.to_h do |product|
|
|
546
546
|
amount = product.default_variant&.amount_in(currency)
|
|
547
547
|
[product.id, {
|
|
@@ -15,8 +15,11 @@ FactoryBot.define do
|
|
|
15
15
|
end
|
|
16
16
|
after(:create) do |product|
|
|
17
17
|
if product.store&.default_channel && product.product_publications.empty?
|
|
18
|
-
Spree::ProductPublication.create
|
|
19
|
-
|
|
18
|
+
# Built through the association, not Spree::ProductPublication.create!:
|
|
19
|
+
# the `empty?` above loads and caches the collection, so a row written
|
|
20
|
+
# around it leaves the product holding an association that reads empty
|
|
21
|
+
# while the table has the row.
|
|
22
|
+
product.product_publications.create!(
|
|
20
23
|
channel: product.store.default_channel,
|
|
21
24
|
published_at: product.available_on,
|
|
22
25
|
unpublished_at: product.discontinue_on
|
|
@@ -69,8 +72,11 @@ FactoryBot.define do
|
|
|
69
72
|
# keep passing. Production callers must publish explicitly via the
|
|
70
73
|
# Admin SDK / Dashboard create form.
|
|
71
74
|
if product.store&.default_channel && product.product_publications.empty?
|
|
72
|
-
Spree::ProductPublication.create
|
|
73
|
-
|
|
75
|
+
# Built through the association, not Spree::ProductPublication.create!:
|
|
76
|
+
# the `empty?` above loads and caches the collection, so a row written
|
|
77
|
+
# around it leaves the product holding an association that reads empty
|
|
78
|
+
# while the table has the row.
|
|
79
|
+
product.product_publications.create!(
|
|
74
80
|
channel: product.store.default_channel,
|
|
75
81
|
published_at: product.available_on,
|
|
76
82
|
unpublished_at: product.discontinue_on
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
FactoryBot.define do
|
|
2
2
|
factory :store, class: Spree::Store do
|
|
3
|
-
|
|
3
|
+
# The suite-wide default store outlives the process that created it, while
|
|
4
|
+
# a sequence restarts with every process, so the code also steps past
|
|
5
|
+
# whatever is already in the database.
|
|
6
|
+
sequence(:code) { |i| "spree_#{i + Spree::Store.unscoped.count}" }
|
|
4
7
|
name { 'Spree Test Store' }
|
|
5
8
|
url { 'www.example.com' }
|
|
6
9
|
mail_from_address { 'no-reply@example.com' }
|
|
@@ -819,3 +819,13 @@ steps:
|
|
|
819
819
|
Run AFTER the steps that create or move attachments (media, categories,
|
|
820
820
|
returns), so their files are covered. Idempotent — files that already
|
|
821
821
|
have a store are skipped.
|
|
822
|
+
|
|
823
|
+
- id: backfill_tagging_tenants
|
|
824
|
+
name: "Stamp the store on taggings written without one"
|
|
825
|
+
task: "spree:upgrade:backfill_tagging_tenants"
|
|
826
|
+
notes: |
|
|
827
|
+
Tags added through the bulk endpoints carried no store on their
|
|
828
|
+
taggings, and the admin tag list filters by it, so those tags were
|
|
829
|
+
missing from the list. This stamps the store of the record on every
|
|
830
|
+
tagging without one, for each taggable that keeps its taggings per
|
|
831
|
+
store. Idempotent — taggings that already carry a store are skipped.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
namespace :spree do
|
|
2
|
+
namespace :upgrade do
|
|
3
|
+
desc <<~DESC
|
|
4
|
+
Stamps the store on taggings written without one, for every taggable
|
|
5
|
+
that keeps its taggings per store. Tags added through the bulk endpoints
|
|
6
|
+
before Spree 6.0 carried no store, and the admin tag list filters by it,
|
|
7
|
+
so those tags were missing from the list. Idempotent — taggings that
|
|
8
|
+
already carry a store are skipped.
|
|
9
|
+
DESC
|
|
10
|
+
task backfill_tagging_tenants: :environment do
|
|
11
|
+
total = 0
|
|
12
|
+
|
|
13
|
+
Spree.taggable_types.map(&:constantize).each do |taggable|
|
|
14
|
+
column = taggable.try(:tenant_column)
|
|
15
|
+
next if column.blank?
|
|
16
|
+
|
|
17
|
+
count = 0
|
|
18
|
+
ActsAsTaggableOn::Tagging.where(taggable_type: taggable.polymorphic_name, tenant: nil).in_batches do |taggings|
|
|
19
|
+
tenants = taggable.unscoped.where(id: taggings.select(:taggable_id)).pluck(:id, column).to_h
|
|
20
|
+
|
|
21
|
+
tenants.compact.group_by { |_id, tenant| tenant }.each do |tenant, pairs|
|
|
22
|
+
count += taggings.where(taggable_id: pairs.map(&:first)).update_all(tenant: tenant.to_s)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
next if count.zero?
|
|
27
|
+
|
|
28
|
+
total += count
|
|
29
|
+
puts " #{taggable.name}: #{count} tagging(s) stamped with the #{column}."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
puts total.zero? ? ' Nothing to backfill.' : " Backfilled #{total} tagging(s)."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|