spree_core 5.5.3 → 5.6.0.rc2

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 (140) 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 +47 -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/newsletter_subscriber.rb +4 -0
  41. data/app/models/spree/option_type.rb +11 -13
  42. data/app/models/spree/option_value.rb +5 -17
  43. data/app/models/spree/order.rb +19 -2
  44. data/app/models/spree/payment_method.rb +6 -5
  45. data/app/models/spree/permission_sets/default_customer.rb +3 -0
  46. data/app/models/spree/policy.rb +2 -1
  47. data/app/models/spree/product.rb +23 -2
  48. data/app/models/spree/promotion/rules/channel.rb +37 -0
  49. data/app/models/spree/promotion/rules/market.rb +37 -0
  50. data/app/models/spree/promotion.rb +5 -3
  51. data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
  52. data/app/models/spree/role_user.rb +4 -0
  53. data/app/models/spree/search_provider/meilisearch.rb +22 -9
  54. data/app/models/spree/setup_task.rb +12 -0
  55. data/app/models/spree/setup_tasks.rb +86 -0
  56. data/app/models/spree/shipment.rb +38 -1
  57. data/app/models/spree/shipment_handler.rb +1 -1
  58. data/app/models/spree/stock/availability_validator.rb +1 -1
  59. data/app/models/spree/stock/quantifier.rb +21 -1
  60. data/app/models/spree/stock_item.rb +2 -1
  61. data/app/models/spree/stock_location.rb +4 -1
  62. data/app/models/spree/store.rb +45 -9
  63. data/app/models/spree/store_payment_method.rb +5 -1
  64. data/app/models/spree/store_promotion.rb +4 -0
  65. data/app/models/spree/taxon.rb +112 -9
  66. data/app/models/spree/taxonomy.rb +1 -1
  67. data/app/models/spree/translations/batch.rb +104 -0
  68. data/app/models/spree/user_identity.rb +35 -24
  69. data/app/models/spree/variant.rb +29 -2
  70. data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
  71. data/app/services/spree/carts/complete.rb +3 -0
  72. data/app/services/spree/fulfillments/create.rb +259 -0
  73. data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
  74. data/app/services/spree/locales.rb +52 -0
  75. data/app/services/spree/newsletter/subscribe.rb +1 -0
  76. data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
  77. data/app/services/spree/shipments/update.rb +1 -1
  78. data/app/services/spree/stock_reservations/reserve.rb +4 -1
  79. data/app/services/spree/taxons/add_products.rb +3 -0
  80. data/app/services/spree/taxons/remove_products.rb +3 -0
  81. data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
  82. data/app/subscribers/spree/import_email_subscriber.rb +26 -0
  83. data/app/views/layouts/spree/base_mailer.html.erb +14 -17
  84. data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
  85. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
  86. data/app/views/spree/export_mailer/export_done.html.erb +6 -14
  87. data/app/views/spree/import_mailer/import_done.html.erb +12 -0
  88. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
  89. data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
  90. data/app/views/spree/report_mailer/report_done.html.erb +6 -14
  91. data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
  92. data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
  93. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
  94. data/app/views/spree/shared/_mailer_button.html.erb +10 -0
  95. data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
  96. data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
  97. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
  98. data/config/initializers/spree_store_setup_tasks.rb +23 -0
  99. data/config/locales/en.yml +42 -992
  100. data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
  101. data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
  102. data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
  103. data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
  104. data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
  105. data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
  106. data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
  107. data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
  108. data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
  109. data/db/sample_data/promotions.rb +1 -1
  110. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
  111. data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
  112. data/lib/spree/core/configuration.rb +1 -0
  113. data/lib/spree/core/controller_helpers/locale.rb +4 -1
  114. data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
  115. data/lib/spree/core/dependencies.rb +3 -0
  116. data/lib/spree/core/engine.rb +24 -0
  117. data/lib/spree/core/preferences/preferable.rb +6 -1
  118. data/lib/spree/core/previews/admin_user_preview.rb +22 -0
  119. data/lib/spree/core/previews/export_preview.rb +37 -0
  120. data/lib/spree/core/previews/invitation_preview.rb +47 -0
  121. data/lib/spree/core/previews/preview_data.rb +52 -0
  122. data/lib/spree/core/previews/report_preview.rb +40 -0
  123. data/lib/spree/core/previews/webhook_preview.rb +34 -0
  124. data/lib/spree/core/version.rb +1 -1
  125. data/lib/spree/core.rb +38 -1
  126. data/lib/spree/permitted_attributes.rb +5 -4
  127. data/lib/spree/testing_support/factories/export_factory.rb +4 -0
  128. data/lib/spree/testing_support/factories/order_factory.rb +1 -1
  129. data/lib/spree/testing_support/factories/payment_factory.rb +3 -3
  130. data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
  131. data/lib/spree/testing_support/factories/payment_session_factory.rb +2 -2
  132. data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
  133. data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
  134. data/lib/spree/translations.rb +113 -0
  135. data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
  136. data/lib/tasks/role_users.rake +21 -0
  137. data/lib/tasks/single_store_associations.rake +65 -0
  138. data/lib/tasks/taxons.rake +62 -1
  139. metadata +47 -5
  140. data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
@@ -0,0 +1,259 @@
1
+ module Spree
2
+ module Fulfillments
3
+ # Manually creates a fulfillment (Spree::Shipment) on a completed order,
4
+ # bypassing order routing. Moves the requested quantities of each line
5
+ # item's not-yet-shipped inventory units out of their current shipments
6
+ # into the new fulfillment, mirroring externally-managed fulfillment
7
+ # (3PL, courier API, drop-shipping) back into Spree.
8
+ #
9
+ # Stock bookkeeping follows the split/transfer semantics: when the source
10
+ # and target stock locations differ, moved quantities are restocked at the
11
+ # source and unstocked at the target. Source shipments left empty are
12
+ # destroyed and their cost and selected delivery method carry over to the
13
+ # new fulfillment, unless the caller provides its own +cost+ /
14
+ # +delivery_method+.
15
+ #
16
+ # The resulting cost is frozen only for fulfillments registered with
17
+ # status: 'shipped' (rate refresh skips shipped shipments). Pending/ready
18
+ # fulfillments participate in the standard rate machinery — the order
19
+ # updater re-prices them from the delivery method calculators on the next
20
+ # recalculation, exactly like shipments created via split/transfer.
21
+ class Create
22
+ prepend Spree::ServiceModule::Base
23
+
24
+ # @param order [Spree::Order] completed order to fulfill
25
+ # @param stock_location [Spree::StockLocation] location the fulfillment ships from
26
+ # @param items [Array<Hash>, nil] `[{ line_item: Spree::LineItem, quantity: Integer }]`;
27
+ # nil fulfills every not-yet-shipped unit on the order
28
+ # @param tracking [String, nil] carrier tracking number
29
+ # @param delivery_method [Spree::ShippingMethod, nil] carrier; stored as the selected rate.
30
+ # Defaults to the delivery method of the drained source fulfillment(s)
31
+ # @param cost [String, Numeric, nil] explicit shipping cost (e.g. the 3PL's price).
32
+ # Defaults to the summed cost of the drained source fulfillment(s), keeping the
33
+ # order total unchanged; an explicit cost changes the order total and payment state.
34
+ # Guaranteed to persist only with status: 'shipped' — pending fulfillments are
35
+ # re-priced by the rate engine (see class docs)
36
+ # @param status [String, nil] pass 'shipped' to register an already-shipped fulfillment
37
+ # @param metadata [Hash, nil] metadata stored on the fulfillment
38
+ # @return [Spree::ServiceModule::Result] the created shipment on success
39
+ def call(order:, stock_location:, items: nil, tracking: nil, delivery_method: nil, cost: nil, status: nil, metadata: nil)
40
+ return failure(nil, Spree.t('fulfillments.errors.invalid_status')) unless status.nil? || status == 'shipped'
41
+
42
+ cost = parse_cost(cost)
43
+ return cost if cost.is_a?(Spree::ServiceModule::Result)
44
+
45
+ fulfillment = nil
46
+
47
+ # The order row is locked before reading fulfillable units so
48
+ # concurrent creations (e.g. duplicate carrier webhooks) validate and
49
+ # move units against a serialized snapshot. The API layer already
50
+ # serializes via with_order_lock; this covers direct service callers.
51
+ ActiveRecord::Base.transaction do
52
+ order.lock!
53
+
54
+ return failure(nil, Spree.t('fulfillments.errors.order_not_completed')) unless order.completed?
55
+ return failure(nil, Spree.t('fulfillments.errors.order_canceled')) if order.canceled?
56
+
57
+ units_by_line_item = fulfillable_units(order)
58
+
59
+ requested = normalize_items(order, items, units_by_line_item)
60
+ return requested if requested.is_a?(Spree::ServiceModule::Result)
61
+
62
+ fulfillment = order.shipments.new(
63
+ stock_location: stock_location,
64
+ address_id: order.ship_address_id,
65
+ tracking: tracking
66
+ )
67
+ fulfillment.metadata = metadata if metadata.present?
68
+ fulfillment.save!
69
+
70
+ source_shipments = move_units(order, fulfillment, requested, units_by_line_item)
71
+ inherited = destroy_drained_shipments(source_shipments, capture_delivery_method: delivery_method.nil?)
72
+ attach_cost_and_rate(fulfillment, delivery_method, cost, inherited)
73
+
74
+ if status == 'shipped'
75
+ mark_shipped(fulfillment)
76
+ else
77
+ fulfillment.update!(order)
78
+ end
79
+
80
+ order.reload.update_with_updater!
81
+ end
82
+
83
+ success(fulfillment.reload)
84
+ end
85
+
86
+ private
87
+
88
+ # Units that can still be moved into a manual fulfillment: on-hand or
89
+ # backordered units sitting in shipments that haven't shipped or been
90
+ # canceled (canceled shipments already restocked their stock). Loaded
91
+ # in one query, on-hand first so moved units stay shippable, grouped
92
+ # by line item for both validation and moving.
93
+ def fulfillable_units(order)
94
+ order.inventory_units.
95
+ on_hand_or_backordered.
96
+ joins(:shipment).
97
+ merge(Spree::Shipment.ready_or_pending).
98
+ preload(:shipment, :variant).
99
+ order(Arel.sql("CASE WHEN #{Spree::InventoryUnit.table_name}.state = 'on_hand' THEN 0 ELSE 1 END"), :id).
100
+ group_by(&:line_item_id)
101
+ end
102
+
103
+ def normalize_items(order, items, units_by_line_item)
104
+ available_for = ->(line_item) { units_by_line_item.fetch(line_item.id, []).sum(&:quantity) }
105
+
106
+ if items.nil?
107
+ derived = order.line_items.filter_map do |line_item|
108
+ quantity = available_for.call(line_item)
109
+ { line_item: line_item, quantity: quantity } if quantity.positive?
110
+ end
111
+ return failure(nil, Spree.t('fulfillments.errors.no_items_to_fulfill')) if derived.empty?
112
+
113
+ return derived
114
+ end
115
+
116
+ return failure(nil, Spree.t('fulfillments.errors.no_items_to_fulfill')) if items.empty?
117
+
118
+ # Merge duplicate line item entries, then validate quantities.
119
+ merged = items.group_by { |item| item[:line_item].id }.values.map do |grouped|
120
+ { line_item: grouped.first[:line_item], quantity: grouped.sum { |item| item[:quantity].to_i } }
121
+ end
122
+
123
+ merged.each do |item|
124
+ line_item = item[:line_item]
125
+ quantity = item[:quantity]
126
+
127
+ unless quantity.positive?
128
+ return failure(nil, Spree.t('fulfillments.errors.invalid_quantity', item: line_item.prefixed_id))
129
+ end
130
+
131
+ available = available_for.call(line_item)
132
+ if quantity > available
133
+ return failure(
134
+ nil,
135
+ Spree.t('fulfillments.errors.insufficient_quantity',
136
+ item: line_item.prefixed_id, requested: quantity, available: available)
137
+ )
138
+ end
139
+ end
140
+
141
+ merged
142
+ end
143
+
144
+ # Moves the requested quantities into the fulfillment, on-hand units
145
+ # first so the new fulfillment is shippable whenever possible. Restocks
146
+ # the source location and unstocks the target for tracked variants when
147
+ # the locations differ, keeping stock levels truthful about where the
148
+ # goods actually leave from.
149
+ #
150
+ # @return [Array<Spree::Shipment>] the shipments units were taken from
151
+ def move_units(order, fulfillment, requested, units_by_line_item)
152
+ source_shipments = []
153
+ stock_moves = Hash.new(0)
154
+
155
+ requested.each do |item|
156
+ remaining = item[:quantity]
157
+
158
+ units_by_line_item.fetch(item[:line_item].id, []).each do |unit|
159
+ break if remaining.zero?
160
+
161
+ take = [unit.quantity, remaining].min
162
+ source_shipments << unit.shipment
163
+ stock_moves[[unit.shipment, unit.variant]] += take
164
+
165
+ target = fulfillment.inventory_units.find_or_initialize_by(
166
+ state: unit.state,
167
+ variant_id: unit.variant_id,
168
+ line_item_id: unit.line_item_id,
169
+ order_id: order.id
170
+ )
171
+ target.pending = unit.pending
172
+ # The quantity column has a database default of 1, so a fresh
173
+ # record must be set to the moved quantity, not incremented.
174
+ target.quantity = target.new_record? ? take : target.quantity + take
175
+ target.save!
176
+
177
+ if take == unit.quantity
178
+ unit.destroy!
179
+ else
180
+ unit.update!(quantity: unit.quantity - take)
181
+ end
182
+
183
+ remaining -= take
184
+ end
185
+ end
186
+
187
+ stock_moves.each do |(source_shipment, variant), quantity|
188
+ next unless variant.track_inventory?
189
+ next if source_shipment.stock_location_id == fulfillment.stock_location_id
190
+
191
+ source_shipment.stock_location.restock(variant, quantity, source_shipment)
192
+ fulfillment.stock_location.unstock(variant, quantity, fulfillment)
193
+ end
194
+
195
+ source_shipments.uniq
196
+ end
197
+
198
+ # Destroys fully drained source shipments, capturing what the new
199
+ # fulfillment inherits from them — the summed cost and the first
200
+ # selected delivery method, read before destroy since the rates are
201
+ # deleted along with the shipment. The delivery method lookup costs
202
+ # queries, so it is skipped when the caller provided its own.
203
+ #
204
+ # @return [Hash] `{ cost: BigDecimal, delivery_method: Spree::ShippingMethod or nil }`
205
+ def destroy_drained_shipments(source_shipments, capture_delivery_method:)
206
+ inherited = { cost: 0, delivery_method: nil }
207
+
208
+ source_shipments.each do |shipment|
209
+ next unless shipment.inventory_units.sum(:quantity).zero?
210
+
211
+ inherited[:cost] += shipment.cost
212
+ inherited[:delivery_method] ||= shipment.shipping_method if capture_delivery_method
213
+ shipment.destroy!
214
+ end
215
+
216
+ inherited
217
+ end
218
+
219
+ # The fulfillment inherits the cost and carrier of the shipments it
220
+ # replaced — keeping the order total (and thus payment state) unchanged —
221
+ # unless the caller provides its own. Frozen once shipped; see the class
222
+ # docs for pending-path re-pricing. The carrier rides along as a
223
+ # selected rate.
224
+ def attach_cost_and_rate(fulfillment, delivery_method, cost, inherited)
225
+ effective_cost = cost || inherited[:cost]
226
+ method = delivery_method || inherited[:delivery_method]
227
+
228
+ fulfillment.update_columns(cost: effective_cost) if effective_cost.positive?
229
+ fulfillment.add_shipping_method(method, true) if method
230
+ end
231
+
232
+ # Strict decimal parsing (same semantics as Shipment#cost=, which the
233
+ # update_columns freeze path bypasses) — the lenient LocalizedNumber
234
+ # would turn garbage into 0, and 0 is a legal cost here.
235
+ #
236
+ # @return [BigDecimal, Numeric, nil] nil when no cost was given (blank
237
+ # counts as omitted); a failure Result for malformed or negative input
238
+ def parse_cost(cost)
239
+ return if cost.blank?
240
+
241
+ parsed = cost.is_a?(String) ? BigDecimal(cost.strip) : cost
242
+ return failure(nil, Spree.t('fulfillments.errors.invalid_cost')) if parsed.negative?
243
+
244
+ parsed
245
+ rescue ArgumentError
246
+ failure(nil, Spree.t('fulfillments.errors.invalid_cost'))
247
+ end
248
+
249
+ # Registers an externally-completed fulfillment: backorders are filled
250
+ # (the external location evidently had the goods) and the paid-order
251
+ # readiness gate is bypassed deliberately — the goods already left.
252
+ def mark_shipped(fulfillment)
253
+ fulfillment.inventory_units.backordered.each(&:fill_backorder!)
254
+ fulfillment.update_columns(state: 'ready') unless fulfillment.ready?
255
+ fulfillment.reload.ship!
256
+ end
257
+ end
258
+ end
259
+ end
@@ -9,7 +9,13 @@ module Spree
9
9
  object[locale] = [store_default_locale]
10
10
  end
11
11
 
12
- fallbacks_instance = I18n::Locale::Fallbacks.new(fallbacks)
12
+ # Pass the store default as the terminal default so EVERY locale —
13
+ # including regional variants not in `supported_locales_list` (e.g.
14
+ # `pt-BR` when only `pt` is configured) — ultimately falls back to it.
15
+ # The store default resolves to the populated DB column via Mobility's
16
+ # `column_fallback`, so translated reads never return nil for a locale
17
+ # that lacks a translation row.
18
+ fallbacks_instance = I18n::Locale::Fallbacks.new(store_default_locale, fallbacks)
13
19
 
14
20
  Mobility.store_based_fallbacks = fallbacks_instance
15
21
  end
@@ -0,0 +1,52 @@
1
+ module Spree
2
+ module Locales
3
+ # Canonical set of locale codes a merchant may translate **data** into
4
+ # (Mobility-backed content such as product names, descriptions, taxons,
5
+ # option values, etc.).
6
+ #
7
+ # This is deliberately **independent of the admin/storefront UI translation
8
+ # set** (`Spree.available_locales`, which reflects which `spree_i18n` UI
9
+ # bundles happen to be installed). In a headless setup the storefront ships
10
+ # its own UI translations, so the languages a merchant can store content in
11
+ # must not be constrained by which admin chrome translations exist.
12
+ #
13
+ # Codes follow BCP-47 casing (lowercase language, uppercase region —
14
+ # `pt-BR`, `zh-CN`) so the browser's `Intl.DisplayNames` resolves a clean
15
+ # localized label for each. The list is the full ISO 639-1 base-language set
16
+ # plus the regional variants that matter for commerce (British vs. US
17
+ # English, Brazilian vs. European Portuguese, Simplified vs. Traditional
18
+ # Chinese, etc.).
19
+ #
20
+ # The underlying translation tables accept any locale string, so this list
21
+ # governs only what the locale pickers offer — it is not a hard storage
22
+ # constraint.
23
+ ALL = %w[
24
+ aa ab ae af ak am an ar as av ay az
25
+ ba be bg bh bi bm bn bo br bs
26
+ ca ce ch co cr cs cu cv cy
27
+ da de de-AT de-CH dv dz
28
+ ee el en en-AU en-CA en-GB en-IN en-NZ eo es es-419 es-MX et eu
29
+ fa ff fi fj fo fr fr-CA fy
30
+ ga gd gl gn gu gv
31
+ ha he hi ho hr ht hu hy hz
32
+ ia id ie ig ii ik io is it iu
33
+ ja jv
34
+ ka kg ki kj kk kl km kn ko kr ks ku kv kw ky
35
+ la lb lg li ln lo lt lu lv
36
+ mg mh mi mk ml mn mr ms mt my
37
+ na nb nd ne ng nl nn no nr nv ny
38
+ oc oj om or os
39
+ pa pi pl ps pt pt-BR pt-PT
40
+ qu
41
+ rm rn ro ru rw
42
+ sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw
43
+ ta te tg th ti tk tl tn to tr ts tt tw ty
44
+ ug uk ur uz
45
+ ve vi vo
46
+ wa wo
47
+ xh
48
+ yi yo
49
+ za zh-CN zh-HK zh-TW zu
50
+ ].freeze
51
+ end
52
+ end
@@ -39,6 +39,7 @@ module Spree
39
39
  id: subscriber.prefixed_id,
40
40
  email: subscriber.email,
41
41
  verification_token: subscriber.verification_token,
42
+ unsubscribe_token: subscriber.generate_token_for(:unsubscribe),
42
43
  store_id: current_store.prefixed_id,
43
44
  customer_id: subscriber.user&.prefixed_id
44
45
  }
@@ -7,6 +7,12 @@ module Spree
7
7
  # from other stores are preserved. This prevents accidental data loss when a store
8
8
  # admin updates product categories in their store without affecting other stores.
9
9
  #
10
+ # Variant removal is opt-in: only variants explicitly listed in the
11
+ # `removed_variant_ids` param are marked for destruction (or collected for
12
+ # discontinuation when they have completed orders). Variants merely absent from
13
+ # `variants_attributes` are left untouched, so a partially rendered or broken
14
+ # form can never silently mass-delete variants.
15
+ #
10
16
  # @example
11
17
  # service = Spree::Products::PrepareNestedAttributes.new(
12
18
  # product,
@@ -28,10 +34,13 @@ module Spree
28
34
  end
29
35
 
30
36
  def call
37
+ extract_removed_variant_ids
38
+
31
39
  if params[:variants_attributes]
32
40
  params[:variants_attributes].each do |key, variant_params|
33
41
  existing_variant = variant_params[:id].presence && @product.variants.find_by(id: variant_params[:id])
34
- variants_to_remove.delete(variant_params[:id]) if variant_params[:id].present?
42
+ # a re-submitted variant always wins over a removal request
43
+ variants_to_remove.delete(variant_params[:id].to_s) if variant_params[:id].present?
35
44
 
36
45
  variant_params.delete(:price) # remove legacy price param
37
46
 
@@ -72,17 +81,20 @@ module Spree
72
81
  # ensure the product is owned by a store
73
82
  params[:store_id] = store.id if params[:store_id].blank? && product.store_id.blank?
74
83
 
75
- # Add empty list for option_type_ids and mark variants as removed if there are no variants and options
76
- if params[:variants_attributes].blank? && variants_to_remove.any? && !params.key?(:option_type_ids)
77
- params[:option_type_ids] = []
78
- params[:variants_attributes] = {}
84
+ # The variants matrix was emptied: no variant rows re-submitted, removals sent instead.
85
+ # Option types are detached only when the removal list covers every variant, so a
86
+ # partial (possibly broken) submission never turns the product into a simple one.
87
+ # Variants kept alive by discontinuation still count as removed from the matrix,
88
+ # so the detachment can't hinge on any of them yielding a `_destroy` row.
89
+ if params[:variants_attributes].blank? && variants_to_remove.any? && can_remove_variants?
90
+ attributes = removed_variants_attributes
79
91
 
80
- populate_variants_to_discontinue
81
- variant_ids_to_destroy.each_with_index do |variant_id, index|
82
- params[:variants_attributes][index.to_s] = { id: variant_id, _destroy: '1' }
83
- end
92
+ params[:option_type_ids] = [] if removing_all_variants? && !params.key?(:option_type_ids)
84
93
 
85
- params[:variants_attributes].permit!
94
+ if attributes.any?
95
+ params[:variants_attributes] = attributes
96
+ params[:variants_attributes].permit!
97
+ end
86
98
  end
87
99
 
88
100
  params
@@ -116,8 +128,24 @@ module Spree
116
128
  @product_option_types_to_remove ||= product.product_option_type_ids
117
129
  end
118
130
 
131
+ # Pulls `removed_variant_ids` out of the params so it never reaches Product#update.
132
+ # Must run before any `variants_to_remove` access.
133
+ def extract_removed_variant_ids
134
+ @removed_variant_ids = Array(params.delete(:removed_variant_ids)).map(&:to_s)
135
+ end
136
+
137
+ # Only variants the client explicitly asked to remove, and only ones that
138
+ # actually belong to this product.
119
139
  def variants_to_remove
120
- @variants_to_remove ||= product.variant_ids.map(&:to_s)
140
+ @variants_to_remove ||= (@removed_variant_ids || []).uniq & all_variant_ids
141
+ end
142
+
143
+ def all_variant_ids
144
+ @all_variant_ids ||= product.variant_ids.map(&:to_s)
145
+ end
146
+
147
+ def removing_all_variants?
148
+ (all_variant_ids - variants_to_remove).empty?
121
149
  end
122
150
 
123
151
  def can_update_prices?
@@ -142,7 +170,7 @@ module Spree
142
170
  populate_variants_to_discontinue
143
171
 
144
172
  attributes = {}
145
- last_index = params[:variants_attributes].keys.map(&:to_i).max
173
+ last_index = params[:variants_attributes].presence&.keys&.map(&:to_i)&.max || -1
146
174
  variant_ids_to_destroy.each_with_index do |variant_id, index|
147
175
  attributes[(last_index + 1 + index).to_s] = { id: variant_id, _destroy: '1' }
148
176
  end
@@ -8,7 +8,7 @@ module Spree
8
8
  ActiveRecord::Base.transaction do
9
9
  return failure(shipment) unless shipment.update(shipment_attributes)
10
10
 
11
- if shipment_attributes.key?(:selected_shipping_rate_id)
11
+ if shipment_attributes.key?(:selected_shipping_rate_id) || shipment_attributes.key?(:selected_delivery_rate_id)
12
12
  order = shipment.order
13
13
 
14
14
  # Changing the selected Shipping Rate won't update the cost (for now)
@@ -65,7 +65,10 @@ module Spree
65
65
  next unless variant&.should_track_inventory?
66
66
 
67
67
  stock_item = select_stock_item(variant)
68
- next if stock_item.nil? || stock_item.backorderable?
68
+ # Backorderable and pre-order variants oversell past count_on_hand,
69
+ # so a reservation capped at on-hand stock would wrongly reject them;
70
+ # their cap is enforced by Stock::Quantifier#can_supply? instead.
71
+ next if stock_item.nil? || stock_item.backorderable? || variant.preorder?
69
72
 
70
73
  [line_item, stock_item]
71
74
  end
@@ -33,6 +33,9 @@ module Spree
33
33
  product_ids = products.pluck(:id)
34
34
  taxon_ids.each { |id| Spree::Taxon.reset_counters(id, :classifications) }
35
35
  product_ids.each { |id| Spree::Product.reset_counters(id, :classifications) }
36
+ # Recompute the descendant-inclusive products_count for the taxons and
37
+ # their ancestors (bulk insert skips Classification callbacks).
38
+ Spree::Taxon.recalculate_products_count(taxon_ids)
36
39
 
37
40
  # clear cache & index products
38
41
  Spree::Product.where(id: product_ids).touch_all
@@ -48,6 +48,9 @@ module Spree
48
48
  # update counter caches
49
49
  taxon_ids.each { |id| Spree::Taxon.reset_counters(id, :classifications) }
50
50
  product_ids.each { |id| Spree::Product.reset_counters(id, :classifications) }
51
+ # Recompute the descendant-inclusive products_count for the taxons and
52
+ # their ancestors (delete_all skips Classification callbacks).
53
+ Spree::Taxon.recalculate_products_count(taxon_ids)
51
54
 
52
55
  # clear cache & index products
53
56
  Spree::Product.where(id: product_ids).touch_all
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class AdminUserEmailSubscriber < Spree::Subscriber
5
+ subscribes_to 'admin_user.password_reset_requested'
6
+
7
+ def handle(event)
8
+ user = Spree.admin_user_class.find_by(email: event.payload['email'])
9
+ return unless user
10
+
11
+ store = find_store(event)
12
+
13
+ AdminUserMailer.password_reset_email(
14
+ user,
15
+ event.payload['reset_token'],
16
+ store,
17
+ redirect_url: event.payload['redirect_url']
18
+ ).deliver_later
19
+ end
20
+
21
+ private
22
+
23
+ def find_store(event)
24
+ Spree::Store.find_by_prefix_id(event.payload['store_id']) ||
25
+ Spree::Current.store ||
26
+ Spree::Store.default
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ # Emails the import's owner when processing finishes — including after a
5
+ # failed-rows retry pass, which publishes `import.completed` again.
6
+ # App-created imports (API key without an admin creator) have no recipient
7
+ # and send nothing.
8
+ #
9
+ # We use async: false because this subscriber only enqueues the mail job.
10
+ class ImportEmailSubscriber < Spree::Subscriber
11
+ subscribes_to 'import.completed', async: false
12
+
13
+ on 'import.completed', :send_import_done_email
14
+
15
+ def send_import_done_email(event)
16
+ import_id = event.payload['id']
17
+ return unless import_id
18
+
19
+ import = Spree::Import.find_by_prefix_id(import_id)
20
+ return unless import
21
+ return if import.user&.email.blank?
22
+
23
+ Spree::ImportMailer.import_done(import).deliver_later
24
+ end
25
+ end
26
+ end
@@ -1,46 +1,43 @@
1
1
  <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
2
- <html>
2
+ <html lang="<%= I18n.locale %>">
3
3
  <head>
4
4
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
5
5
  <meta name="x-apple-disable-message-reformatting"/>
6
6
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
7
+ <meta name="color-scheme" content="light"/>
8
+ <meta name="supported-color-schemes" content="light"/>
7
9
 
8
10
  <%= render partial: 'spree/shared/base_mailer_stylesheets' %>
11
+ <%# Per-email styles — templates add their own via `content_for :head` %>
12
+ <%= yield :head %>
9
13
  </head>
10
- <body>
11
- <table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
14
+ <body class="email-bg" style="margin: 0; padding: 0; -webkit-text-size-adjust: none;" bgcolor="#FFFFFF">
15
+ <table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#FFFFFF">
12
16
  <tr>
13
17
  <td align="center">
14
18
  <table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
15
- <%= render partial: 'spree/shared/base_mailer_header' %>
16
19
  <!-- Email Body -->
17
20
  <tr>
18
- <td class="email-body" width="570" cellpadding="0" cellspacing="0">
19
- <table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
20
- <!-- Body content -->
21
- <tbody>
21
+ <td class="email-card" bgcolor="#F6F6F6">
22
+ <%= render partial: 'spree/shared/base_mailer_header' %>
23
+ <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
22
24
  <tr>
23
- <td class="content-cell">
24
- <div class="f-fallback">
25
- <%= yield %>
26
- </div>
25
+ <td class="content-cell f-fallback" align="left">
26
+ <%= yield %>
27
27
  </td>
28
28
  </tr>
29
- </tbody>
30
29
  </table>
31
30
  </td>
32
31
  </tr>
32
+ <!-- Email Footer -->
33
33
  <tr>
34
- <td>
34
+ <td class="email-footer" align="center">
35
35
  <%= render partial: 'spree/shared/base_mailer_footer' %>
36
36
  </td>
37
37
  </tr>
38
38
  </table>
39
39
  </td>
40
40
  </tr>
41
- <tr>
42
- <td height="60px">&nbsp;</td>
43
- </tr>
44
41
  </table>
45
42
  </body>
46
43
  </html>
@@ -0,0 +1,7 @@
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('admin_user_mailer.confirmation_email.action') %></h1>
3
+ <p class="greeting"><%= Spree.t('admin_user_mailer.confirmation_email.greeting', name: @user.first_name.presence || @user.email) %></p>
4
+ <p><%= Spree.t('admin_user_mailer.confirmation_email.instructions', store_name: @current_store.name) %></p>
5
+ <%= render 'spree/shared/mailer_button', url: @confirmation_url, label: Spree.t('admin_user_mailer.confirmation_email.action') %>
6
+ <p class="sub"><%= Spree.t('admin_user_mailer.confirmation_email.ignore_notice') %></p>
7
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('admin_user_mailer.password_reset_email.action') %></h1>
3
+ <p class="greeting"><%= Spree.t('admin_user_mailer.password_reset_email.greeting', name: @user.first_name.presence || @user.email) %></p>
4
+ <p><%= Spree.t('admin_user_mailer.password_reset_email.instructions', store_name: @current_store.name) %></p>
5
+ <%= render 'spree/shared/mailer_button', url: @reset_url, label: Spree.t('admin_user_mailer.password_reset_email.action') %>
6
+ <p class="sub"><%= Spree.t('admin_user_mailer.password_reset_email.expiry_notice') %></p>
7
+ <p class="sub"><%= Spree.t('admin_user_mailer.password_reset_email.ignore_notice') %></p>
8
+ <% end %>
@@ -1,15 +1,7 @@
1
- <h1>
2
- <%= Spree.t('export_mailer.greeting', user_name: @export.user&.first_name) %>
3
- </h1>
4
- <p>
5
- <%= Spree.t('export_mailer.export_done.message') %>
6
- </p>
7
- <% if spree.respond_to?(:admin_export_url) %>
8
- <p>
9
- <%= link_to @export.attachment.filename, spree.admin_export_url(@export, host: @current_store.formatted_url) %>
10
- </p>
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('export_mailer.greeting', user_name: @export.user&.first_name) %></h1>
3
+ <p><%= Spree.t('export_mailer.export_done.message') %></p>
4
+ <% if @export.results_url.present? %>
5
+ <%= render 'spree/shared/mailer_button', url: @export.results_url, label: @export.attachment.filename %>
6
+ <% end %>
11
7
  <% end %>
12
- <p>
13
- <%= Spree.t('export_mailer.thanks') %><br />
14
- <%= "#{current_store.name}" %>
15
- </p>
@@ -0,0 +1,12 @@
1
+ <%= render layout: 'spree/shared/mailer_hero' do %>
2
+ <h1><%= Spree.t('import_mailer.greeting', user_name: @import.user&.first_name) %></h1>
3
+ <p><%= Spree.t('import_mailer.import_done.message', completed_count: @import.rows_status_counts['completed'] || 0) %></p>
4
+ <% failed_count = @import.rows_status_counts['failed'] || 0 %>
5
+ <% if failed_count > 0 %>
6
+ <p><strong><%= Spree.t('import_mailer.import_done.failed_message', failed_count: failed_count) %></strong></p>
7
+ <% end %>
8
+ <% results_url = @import.results_page_url %>
9
+ <% if results_url.present? %>
10
+ <%= render 'spree/shared/mailer_button', url: results_url, label: Spree.t('import_mailer.import_done.view_results') %>
11
+ <% end %>
12
+ <% end %>