spree_core 5.5.4 → 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 (136) 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/models/spree/webhook_delivery.rb +0 -22
  68. data/app/presenters/spree/data_feeds/google_presenter.rb +1 -28
  69. data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
  70. data/app/services/spree/carts/complete.rb +3 -0
  71. data/app/services/spree/fulfillments/create.rb +259 -0
  72. data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
  73. data/app/services/spree/locales.rb +52 -0
  74. data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
  75. data/app/services/spree/shipments/update.rb +1 -1
  76. data/app/services/spree/stock_reservations/reserve.rb +4 -1
  77. data/app/services/spree/taxons/add_products.rb +3 -0
  78. data/app/services/spree/taxons/remove_products.rb +3 -0
  79. data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
  80. data/app/subscribers/spree/import_email_subscriber.rb +26 -0
  81. data/app/views/layouts/spree/base_mailer.html.erb +14 -17
  82. data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
  83. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
  84. data/app/views/spree/export_mailer/export_done.html.erb +6 -14
  85. data/app/views/spree/import_mailer/import_done.html.erb +12 -0
  86. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
  87. data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
  88. data/app/views/spree/report_mailer/report_done.html.erb +6 -14
  89. data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
  90. data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
  91. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
  92. data/app/views/spree/shared/_mailer_button.html.erb +10 -0
  93. data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
  94. data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
  95. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
  96. data/config/initializers/spree_store_setup_tasks.rb +23 -0
  97. data/config/locales/en.yml +42 -992
  98. data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
  99. data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
  100. data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
  101. data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
  102. data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
  103. data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
  104. data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
  105. data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
  106. data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
  107. data/db/sample_data/promotions.rb +1 -1
  108. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
  109. data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
  110. data/lib/spree/core/configuration.rb +1 -0
  111. data/lib/spree/core/controller_helpers/locale.rb +4 -1
  112. data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
  113. data/lib/spree/core/dependencies.rb +3 -0
  114. data/lib/spree/core/engine.rb +24 -0
  115. data/lib/spree/core/preferences/preferable.rb +6 -1
  116. data/lib/spree/core/previews/admin_user_preview.rb +22 -0
  117. data/lib/spree/core/previews/export_preview.rb +37 -0
  118. data/lib/spree/core/previews/invitation_preview.rb +47 -0
  119. data/lib/spree/core/previews/preview_data.rb +52 -0
  120. data/lib/spree/core/previews/report_preview.rb +40 -0
  121. data/lib/spree/core/previews/webhook_preview.rb +34 -0
  122. data/lib/spree/core/version.rb +1 -1
  123. data/lib/spree/core.rb +22 -1
  124. data/lib/spree/permitted_attributes.rb +5 -4
  125. data/lib/spree/testing_support/factories/export_factory.rb +4 -0
  126. data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
  127. data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
  128. data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
  129. data/lib/spree/translations.rb +113 -0
  130. data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
  131. data/lib/tasks/role_users.rake +21 -0
  132. data/lib/tasks/single_store_associations.rake +65 -0
  133. data/lib/tasks/taxons.rake +62 -1
  134. metadata +47 -6
  135. data/app/models/concerns/spree/webhook_payload_redaction.rb +0 -88
  136. 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
@@ -7,11 +7,6 @@ module Spree
7
7
  belongs_to :webhook_endpoint, class_name: 'Spree::WebhookEndpoint'
8
8
  delegate :url, to: :webhook_endpoint
9
9
 
10
- # Credentials stripped from the persisted payload. Never written to the
11
- # database — held in memory on a single delivery instance, assigned by
12
- # WebhookDeliveryJob when it runs. See {Spree::WebhookPayloadRedaction}.
13
- attr_accessor :payload_secrets
14
-
15
10
  validates :event_name, presence: true
16
11
  validates :payload, presence: true
17
12
 
@@ -69,23 +64,6 @@ module Spree
69
64
  webhook_endpoint.check_auto_disable! unless is_success
70
65
  end
71
66
 
72
- # Payload as it should be sent over the wire.
73
- #
74
- # Re-attaches any credentials withheld from the persisted column. Once this
75
- # record has been reloaded from the database those secrets are gone for
76
- # good, so a redelivery sends the redacted placeholder — single-use tokens
77
- # are not replayable anyway.
78
- #
79
- # This is deliberate: keeping a recoverable copy would put the credential
80
- # back at rest, which is what redaction exists to prevent. If a delivery is
81
- # lost (worker crash between creation and delivery), the customer requests
82
- # a new reset, which mints a fresh token.
83
- #
84
- # @return [Hash]
85
- def deliverable_payload
86
- Spree::WebhookPayloadRedaction.merge(payload, payload_secrets)
87
- end
88
-
89
67
  # Create a new delivery with the same payload and queue it.
90
68
  # Used to retry failed deliveries manually.
91
69
  #
@@ -3,22 +3,6 @@ require 'nokogiri'
3
3
  module Spree
4
4
  module DataFeeds
5
5
  class GooglePresenter < BasePresenter
6
- # Optional Google Merchant Center product attributes sourced from
7
- # metafields. See https://support.google.com/merchants/answer/7052112
8
- OPTIONAL_ATTRIBUTES = %w[
9
- brand gtin mpn identifier_exists condition adult multipack is_bundle
10
- age_group color gender material pattern size size_type size_system
11
- product_length product_width product_height product_weight
12
- google_product_category product_type sale_price sale_price_effective_date
13
- cost_of_goods_sold unit_pricing_measure unit_pricing_base_measure
14
- shipping shipping_label shipping_weight shipping_length shipping_width
15
- shipping_height ships_from_country transit_time_label max_handling_time
16
- min_handling_time tax tax_category energy_efficiency_class
17
- min_energy_efficiency_class max_energy_efficiency_class
18
- gtin_source expiration_date custom_label_0 custom_label_1 custom_label_2
19
- custom_label_3 custom_label_4 mobile_link additional_image_link
20
- ].freeze
21
-
22
6
  # @return [String] RSS XML feed for Google Merchant Center
23
7
  def call
24
8
  builder = Nokogiri::XML::Builder.new do |xml|
@@ -75,21 +59,10 @@ module Spree
75
59
 
76
60
  def build_optional_attributes(xml, product)
77
61
  product.public_metafields.each do |metafield|
78
- key = metafield.metafield_definition.key.parameterize.underscore
79
- next unless OPTIONAL_ATTRIBUTES.include?(key)
80
-
81
- append_g_element(xml, key, metafield.value)
62
+ xml['g'].send(metafield.metafield_definition.key.parameterize.underscore, metafield.value)
82
63
  end
83
64
  end
84
65
 
85
- def append_g_element(xml, name, value)
86
- parent = xml.parent
87
- node = Nokogiri::XML::Node.new(name, parent.document)
88
- node.namespace = parent.namespace_scopes.find { |ns| ns.prefix == 'g' }
89
- node.content = value
90
- parent << node
91
- end
92
-
93
66
  def format_title(product, variant)
94
67
  parts = [product.name]
95
68
  variant.option_values.each do |option_value|
@@ -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