spree_core 5.5.3 → 5.6.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +12 -2
  3. data/app/helpers/spree/currency_helper.rb +1 -4
  4. data/app/helpers/spree/locale_helper.rb +18 -13
  5. data/app/helpers/spree/localized_names_helper.rb +25 -0
  6. data/app/mailers/spree/admin_user_mailer.rb +71 -0
  7. data/app/mailers/spree/base_mailer.rb +73 -11
  8. data/app/mailers/spree/export_mailer.rb +7 -7
  9. data/app/mailers/spree/import_mailer.rb +19 -0
  10. data/app/mailers/spree/invitation_mailer.rb +16 -11
  11. data/app/mailers/spree/report_mailer.rb +6 -6
  12. data/app/mailers/spree/webhook_mailer.rb +7 -6
  13. data/app/models/concerns/spree/admin_user_methods.rb +44 -0
  14. data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
  15. data/app/models/concerns/spree/presentation_translatable.rb +39 -0
  16. data/app/models/concerns/spree/product_scopes.rb +21 -3
  17. data/app/models/concerns/spree/single_store_resource.rb +5 -0
  18. data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
  19. data/app/models/concerns/spree/stores/markets.rb +9 -1
  20. data/app/models/concerns/spree/stores/setup.rb +37 -23
  21. data/app/models/concerns/spree/translatable_resource.rb +95 -5
  22. data/app/models/concerns/spree/user_methods.rb +27 -2
  23. data/app/models/spree/ability.rb +5 -4
  24. data/app/models/spree/allowed_origin.rb +31 -0
  25. data/app/models/spree/category.rb +12 -2
  26. data/app/models/spree/channel/gating.rb +67 -0
  27. data/app/models/spree/channel.rb +1 -0
  28. data/app/models/spree/classification.rb +12 -0
  29. data/app/models/spree/country.rb +22 -1
  30. data/app/models/spree/credit_card.rb +29 -0
  31. data/app/models/spree/currency.rb +47 -0
  32. data/app/models/spree/current.rb +12 -1
  33. data/app/models/spree/customer_group.rb +1 -0
  34. data/app/models/spree/export.rb +19 -0
  35. data/app/models/spree/exports/coupon_codes.rb +1 -2
  36. data/app/models/spree/import.rb +84 -1
  37. data/app/models/spree/import_row.rb +7 -1
  38. data/app/models/spree/imports/product_translations.rb +6 -0
  39. data/app/models/spree/locale.rb +104 -0
  40. data/app/models/spree/option_type.rb +11 -13
  41. data/app/models/spree/option_value.rb +5 -17
  42. data/app/models/spree/order.rb +19 -2
  43. data/app/models/spree/payment_method.rb +6 -5
  44. data/app/models/spree/policy.rb +2 -1
  45. data/app/models/spree/product.rb +23 -2
  46. data/app/models/spree/promotion/rules/channel.rb +37 -0
  47. data/app/models/spree/promotion/rules/market.rb +37 -0
  48. data/app/models/spree/promotion.rb +5 -3
  49. data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
  50. data/app/models/spree/role_user.rb +4 -0
  51. data/app/models/spree/search_provider/meilisearch.rb +22 -9
  52. data/app/models/spree/setup_task.rb +12 -0
  53. data/app/models/spree/setup_tasks.rb +86 -0
  54. data/app/models/spree/shipment.rb +38 -1
  55. data/app/models/spree/shipment_handler.rb +1 -1
  56. data/app/models/spree/stock/availability_validator.rb +1 -1
  57. data/app/models/spree/stock/quantifier.rb +21 -1
  58. data/app/models/spree/stock_item.rb +2 -1
  59. data/app/models/spree/stock_location.rb +4 -1
  60. data/app/models/spree/store.rb +45 -9
  61. data/app/models/spree/store_payment_method.rb +5 -1
  62. data/app/models/spree/store_promotion.rb +4 -0
  63. data/app/models/spree/taxon.rb +112 -9
  64. data/app/models/spree/taxonomy.rb +1 -1
  65. data/app/models/spree/translations/batch.rb +104 -0
  66. data/app/models/spree/variant.rb +29 -2
  67. data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
  68. data/app/services/spree/carts/complete.rb +3 -0
  69. data/app/services/spree/fulfillments/create.rb +259 -0
  70. data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
  71. data/app/services/spree/locales.rb +52 -0
  72. data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
  73. data/app/services/spree/shipments/update.rb +1 -1
  74. data/app/services/spree/stock_reservations/reserve.rb +4 -1
  75. data/app/services/spree/taxons/add_products.rb +3 -0
  76. data/app/services/spree/taxons/remove_products.rb +3 -0
  77. data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
  78. data/app/subscribers/spree/import_email_subscriber.rb +26 -0
  79. data/app/views/layouts/spree/base_mailer.html.erb +14 -17
  80. data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
  81. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
  82. data/app/views/spree/export_mailer/export_done.html.erb +6 -14
  83. data/app/views/spree/import_mailer/import_done.html.erb +12 -0
  84. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
  85. data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
  86. data/app/views/spree/report_mailer/report_done.html.erb +6 -14
  87. data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
  88. data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
  89. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
  90. data/app/views/spree/shared/_mailer_button.html.erb +10 -0
  91. data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
  92. data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
  93. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
  94. data/config/initializers/spree_store_setup_tasks.rb +23 -0
  95. data/config/locales/en.yml +42 -992
  96. data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
  97. data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
  98. data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
  99. data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
  100. data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
  101. data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
  102. data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
  103. data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
  104. data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
  105. data/db/sample_data/promotions.rb +1 -1
  106. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
  107. data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
  108. data/lib/spree/core/configuration.rb +1 -0
  109. data/lib/spree/core/controller_helpers/locale.rb +4 -1
  110. data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
  111. data/lib/spree/core/dependencies.rb +3 -0
  112. data/lib/spree/core/engine.rb +24 -0
  113. data/lib/spree/core/preferences/preferable.rb +6 -1
  114. data/lib/spree/core/previews/admin_user_preview.rb +22 -0
  115. data/lib/spree/core/previews/export_preview.rb +37 -0
  116. data/lib/spree/core/previews/invitation_preview.rb +47 -0
  117. data/lib/spree/core/previews/preview_data.rb +52 -0
  118. data/lib/spree/core/previews/report_preview.rb +40 -0
  119. data/lib/spree/core/previews/webhook_preview.rb +34 -0
  120. data/lib/spree/core/version.rb +1 -1
  121. data/lib/spree/core.rb +22 -1
  122. data/lib/spree/permitted_attributes.rb +5 -4
  123. data/lib/spree/testing_support/factories/export_factory.rb +4 -0
  124. data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
  125. data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
  126. data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
  127. data/lib/spree/translations.rb +113 -0
  128. data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
  129. data/lib/tasks/role_users.rake +21 -0
  130. data/lib/tasks/single_store_associations.rake +65 -0
  131. data/lib/tasks/taxons.rake +62 -1
  132. metadata +47 -5
  133. data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
@@ -0,0 +1,104 @@
1
+ module Spree
2
+ module Translations
3
+ # Atomically upserts translations across MANY records of (possibly)
4
+ # different translatable resource types in one operation — the domain
5
+ # behind +POST /api/v3/admin/translations/batch+.
6
+ #
7
+ # The input is a flat list of independent registry writes, each naming its
8
+ # own +resource_type+ + +resource_id+ (no nested parent-owns-children
9
+ # payload), so there's no per-model branching. Records are resolved within
10
+ # the current store, then upserted in a single transaction: any failure
11
+ # rolls back the whole batch and surfaces the offending entry's index via a
12
+ # typed {EntryError}.
13
+ #
14
+ # @example
15
+ # Spree::Translations::Batch.new(entries).process! do |record|
16
+ # authorize!(:update, record)
17
+ # end
18
+ class Batch
19
+ include ActiveModel::Model
20
+
21
+ # Raised when the batch payload is empty (or not a list of entries).
22
+ class EmptyError < StandardError; end
23
+
24
+ # Raised when an entry can't be processed — unknown/non-translatable
25
+ # resource type, a record missing in the current store, or an invalid
26
+ # save. Carries the entry +index+ so the caller can map the error back to
27
+ # the offending row.
28
+ class EntryError < StandardError
29
+ attr_reader :index
30
+
31
+ def initialize(message, index)
32
+ @index = index
33
+ super(message)
34
+ end
35
+ end
36
+
37
+ # @return [Array<Spree.base_class>] records written by the last successful {#process!}
38
+ attr_reader :records
39
+
40
+ # @param entries [Array<Hash>] [{ resource_type:, resource_id:, values: { locale => { field => value } } }]
41
+ def initialize(entries)
42
+ @entries = Array(entries)
43
+ @records = []
44
+ end
45
+
46
+ # Distinct +write_<resource>+ scopes a caller needs to authorize this
47
+ # batch — one per resource type present. Lets the API layer gate an
48
+ # API-key request without re-deriving scope names.
49
+ # @return [Array<String>]
50
+ def required_scopes
51
+ @entries.map { |entry| "write_#{entry[:resource_type].to_s.pluralize}" }.uniq
52
+ end
53
+
54
+ # Upserts every entry in one transaction. Yields each resolved record
55
+ # before it is written so the caller can authorize it; a raised error in
56
+ # the block (or any entry failure) rolls back the whole batch.
57
+ #
58
+ # @yieldparam record [Spree.base_class] the resolved record, pre-write
59
+ # @raise [EmptyError] when there are no entries
60
+ # @raise [EntryError] when an entry can't be resolved or saved
61
+ # @return [Array<Spree.base_class>] the written records
62
+ def process!
63
+ raise EmptyError if @entries.empty?
64
+
65
+ @records = []
66
+ ActiveRecord::Base.transaction do
67
+ @entries.each_with_index do |entry, index|
68
+ record = resolve_record!(entry, index)
69
+ yield record if block_given?
70
+ upsert!(record, entry[:values], index)
71
+ @records << record
72
+ end
73
+ end
74
+ @records
75
+ end
76
+
77
+ private
78
+
79
+ def upsert!(record, values, index)
80
+ record.upsert_translations(values)
81
+ rescue ActiveRecord::RecordInvalid => e
82
+ raise EntryError.new(e.record.errors.full_messages.join(', '), index)
83
+ end
84
+
85
+ def resolve_record!(entry, index)
86
+ klass = resource_class(entry[:resource_type])
87
+ raise EntryError.new("Unknown translatable resource type: #{entry[:resource_type]}", index) if klass.nil?
88
+
89
+ relation = klass.respond_to?(:for_store) ? klass.for_store(Spree::Store.current) : klass
90
+ relation.find_by_prefix_id!(entry[:resource_id])
91
+ rescue ActiveRecord::RecordNotFound
92
+ raise EntryError.new("Resource not found: #{entry[:resource_id]}", index)
93
+ end
94
+
95
+ # Memoized per instance so a batch of N entries doesn't rebuild the
96
+ # registry map N times. A Batch is request-scoped, so dev-mode class
97
+ # reloads are still picked up on the next request.
98
+ def resource_class(token)
99
+ @resource_class_map ||= Hash.new { |h, t| h[t] = Spree::Translations.resource_class(t) }
100
+ @resource_class_map[token]
101
+ end
102
+ end
103
+ end
104
+ end
@@ -13,7 +13,7 @@ module Spree
13
13
 
14
14
  publishes_lifecycle_events
15
15
 
16
- MEMOIZED_METHODS = %w(purchasable in_stock on_sale backorderable tax_category options_text compare_at_price)
16
+ MEMOIZED_METHODS = %w(in_stock on_sale backorderable tax_category options_text compare_at_price)
17
17
 
18
18
  DIMENSION_UNITS = %w[mm cm in ft]
19
19
  WEIGHT_UNITS = %w[g kg lb oz]
@@ -88,6 +88,8 @@ module Spree
88
88
  validates :dimensions_unit, inclusion: { in: DIMENSION_UNITS }, allow_blank: true
89
89
  validates :weight_unit, inclusion: { in: WEIGHT_UNITS }, allow_blank: true
90
90
 
91
+ validates :backorder_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
92
+
91
93
  after_create :create_stock_items
92
94
  after_create :set_master_out_of_stock, unless: :is_master?
93
95
  after_commit :clear_line_items_cache, on: :update
@@ -238,6 +240,19 @@ module Spree
238
240
  !discontinued? && product.available?
239
241
  end
240
242
 
243
+ # Returns true if the variant is sold as a pre-order: the product is active
244
+ # and not deleted, the variant is flagged preorderable and not discontinued,
245
+ # and the "ships by" date is open-ended or still in the future.
246
+ # Pre-order relaxes only the publish-date embargo, not the rest of the
247
+ # availability rules. Like backorder, a pre-order can make the variant
248
+ # purchasable beyond on-hand stock; the oversell cap is +backorder_limit+
249
+ # (empty ⇒ unlimited), and it adds the ship-by promise.
250
+ # @return [Boolean] true if the variant is a pre-order
251
+ def preorder?
252
+ !discontinued? && preorderable? && product.active? && !product.deleted? &&
253
+ (preorder_ships_at.nil? || preorder_ships_at > Time.current)
254
+ end
255
+
241
256
  # Returns true if the variant is in stock or backorderable.
242
257
  # @return [Boolean] true if the variant is in stock or backorderable
243
258
  def in_stock_or_backorderable?
@@ -647,7 +662,19 @@ module Spree
647
662
  alias is_backorderable? backorderable?
648
663
 
649
664
  def purchasable?
650
- @purchasable ||= in_stock? || backorderable?
665
+ in_stock? || oversellable_now?
666
+ end
667
+
668
+ # Whether the variant can currently be bought by overselling — via
669
+ # backorder or pre-order. Unlimited when +backorder_limit+ is nil (empty =
670
+ # no cap); with a limit, purchasable only while the oversell allowance
671
+ # remains.
672
+ #
673
+ # @return [Boolean]
674
+ def oversellable_now?
675
+ return false unless backorderable? || preorder?
676
+
677
+ backorder_limit.nil? || can_supply?(1)
651
678
  end
652
679
 
653
680
  # Shortcut method to determine if inventory tracking is enabled for this variant
@@ -58,6 +58,9 @@ module Spree
58
58
  status: product.status,
59
59
  sku: product.sku,
60
60
  in_stock: product.in_stock?,
61
+ # True when the product has an active pre-order variant, so a
62
+ # scheduled (future-published) launch still surfaces in search.
63
+ preorder: product.preorder?,
61
64
  store_ids: Array(product.store_id).map(&:to_s),
62
65
  channel_ids: channel_ids_for_store,
63
66
  discontinue_on: product.discontinue_on&.to_i || 0,
@@ -10,6 +10,9 @@ module Spree
10
10
  def call(cart:)
11
11
  return success(cart) if cart.completed?
12
12
  return failure(cart, 'Order is canceled') if cart.canceled?
13
+ # Enforced here (not only in the controller) so every completion path —
14
+ # API, payment-session webhook — honors the channel's guest-checkout gate.
15
+ return failure(cart, Spree.t(:guest_checkout_not_allowed)) if cart.guest_checkout_disallowed?
13
16
 
14
17
  cart.with_lock do
15
18
  process_payments!(cart) if cart.payment_required?
@@ -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
@@ -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