spree_core 6.0.0.beta1 → 6.0.0.beta3

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 (164) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/payments/handle_webhook_job.rb +1 -0
  3. data/app/jobs/spree/sample_data/load_job.rb +18 -0
  4. data/app/jobs/spree/seller_transfers/execute_pending_job.rb +1 -0
  5. data/app/mailers/spree/admin_user_mailer.rb +2 -2
  6. data/app/models/concerns/spree/acted_by.rb +210 -0
  7. data/app/models/concerns/spree/actor.rb +51 -0
  8. data/app/models/concerns/spree/admin_user_methods.rb +48 -17
  9. data/app/models/concerns/spree/customer_methods.rb +17 -1
  10. data/app/models/concerns/spree/has_number.rb +3 -2
  11. data/app/models/concerns/spree/prefixed_id.rb +4 -1
  12. data/app/models/concerns/spree/purchase/payment_processing.rb +1 -1
  13. data/app/models/concerns/spree/purchase/totals.rb +3 -1
  14. data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
  15. data/app/models/concerns/spree/translatable_resource.rb +6 -7
  16. data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
  17. data/app/models/spree/adjusters/promotion.rb +1 -1
  18. data/app/models/spree/api_key.rb +5 -3
  19. data/app/models/spree/authentication/lockout.rb +34 -0
  20. data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
  21. data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
  22. data/app/models/spree/catalog.rb +50 -13
  23. data/app/models/spree/claim.rb +3 -1
  24. data/app/models/spree/commission_line.rb +4 -2
  25. data/app/models/spree/commission_rule.rb +1 -1
  26. data/app/models/spree/coupon_code.rb +14 -2
  27. data/app/models/spree/current.rb +56 -13
  28. data/app/models/spree/exchange.rb +3 -1
  29. data/app/models/spree/export.rb +8 -5
  30. data/app/models/spree/exports/customers.rb +2 -2
  31. data/app/models/spree/fulfillment_group.rb +127 -0
  32. data/app/models/spree/gift_card.rb +9 -0
  33. data/app/models/spree/invitation.rb +18 -5
  34. data/app/models/spree/line_item.rb +1 -1
  35. data/app/models/spree/number_generators/sequential.rb +2 -2
  36. data/app/models/spree/option_value.rb +7 -0
  37. data/app/models/spree/order.rb +89 -18
  38. data/app/models/spree/order_group.rb +78 -6
  39. data/app/models/spree/payment_source.rb +1 -1
  40. data/app/models/spree/payment_split.rb +24 -0
  41. data/app/models/spree/price.rb +74 -0
  42. data/app/models/spree/pricing_provider/internal.rb +37 -15
  43. data/app/models/spree/product.rb +4 -0
  44. data/app/models/spree/product_option_type.rb +11 -0
  45. data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
  46. data/app/models/spree/promotion/rules/category.rb +14 -9
  47. data/app/models/spree/promotion/rules/product.rb +17 -1
  48. data/app/models/spree/promotion.rb +1 -1
  49. data/app/models/spree/promotion_action.rb +12 -1
  50. data/app/models/spree/promotion_handler/coupon.rb +50 -5
  51. data/app/models/spree/promotion_rule.rb +14 -0
  52. data/app/models/spree/refund.rb +2 -1
  53. data/app/models/spree/return.rb +18 -7
  54. data/app/models/spree/return_line_item.rb +19 -0
  55. data/app/models/spree/search_provider/database.rb +10 -2
  56. data/app/models/spree/seller_payout.rb +3 -1
  57. data/app/models/spree/seller_transfer.rb +3 -1
  58. data/app/models/spree/stock_receipt.rb +7 -2
  59. data/app/models/spree/store.rb +4 -0
  60. data/app/models/spree/store_credit.rb +4 -0
  61. data/app/models/spree/translations/batch.rb +4 -2
  62. data/app/models/spree/user_identity.rb +10 -6
  63. data/app/models/spree/variant.rb +8 -3
  64. data/app/models/spree/webhook_delivery.rb +28 -0
  65. data/app/models/spree/webhook_endpoint.rb +14 -0
  66. data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
  67. data/app/services/spree/carts/split_by_seller.rb +22 -12
  68. data/app/services/spree/companies/add_member.rb +8 -2
  69. data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
  70. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  71. data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
  72. data/app/services/spree/order_groups/allocate_payments.rb +3 -0
  73. data/app/services/spree/orders/approve.rb +3 -2
  74. data/app/services/spree/orders/build_fulfillments.rb +6 -0
  75. data/app/services/spree/orders/update_statuses.rb +8 -2
  76. data/app/services/spree/prices/bulk_upsert.rb +186 -47
  77. data/app/services/spree/sample_data/import_builder.rb +1 -1
  78. data/app/services/spree/sample_data/loader.rb +54 -40
  79. data/app/services/spree/seeds/all.rb +1 -26
  80. data/app/services/spree/seeds/allowed_origins.rb +5 -4
  81. data/app/services/spree/seeds/api_keys.rb +10 -9
  82. data/app/services/spree/seeds/channels.rb +9 -8
  83. data/app/services/spree/seeds/commission_rates.rb +21 -20
  84. data/app/services/spree/seeds/customer_groups.rb +5 -4
  85. data/app/services/spree/seeds/digital_delivery.rb +7 -8
  86. data/app/services/spree/seeds/payment_methods.rb +12 -11
  87. data/app/services/spree/seeds/product_types.rb +7 -8
  88. data/app/services/spree/seeds/returns_environment.rb +12 -11
  89. data/app/services/spree/seeds/roles.rb +8 -7
  90. data/app/services/spree/seeds/saved_reports.rb +19 -20
  91. data/app/services/spree/seeds/seller_requirements.rb +5 -4
  92. data/app/services/spree/seeds/store_resources.rb +41 -0
  93. data/app/services/spree/seeds/store_scoped.rb +15 -0
  94. data/app/services/spree/seeds/tax_categories.rb +10 -9
  95. data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
  96. data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
  97. data/app/subscribers/spree/order_status_subscriber.rb +15 -7
  98. data/app/workflows/spree/carts/add_item.rb +5 -7
  99. data/app/workflows/spree/carts/complete.rb +20 -64
  100. data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
  101. data/app/workflows/spree/claims/create.rb +31 -1
  102. data/app/workflows/spree/claims/resolve.rb +7 -11
  103. data/app/workflows/spree/exchanges/create.rb +11 -0
  104. data/app/workflows/spree/exchanges/fulfill.rb +21 -13
  105. data/app/workflows/spree/orders/cancel.rb +3 -2
  106. data/app/workflows/spree/orders/complete.rb +99 -2
  107. data/app/workflows/spree/price_lists/update.rb +17 -0
  108. data/app/workflows/spree/refunds/create.rb +1 -1
  109. data/app/workflows/spree/refunds/order_payments.rb +55 -1
  110. data/app/workflows/spree/returns/approve.rb +1 -1
  111. data/app/workflows/spree/returns/create.rb +11 -17
  112. data/app/workflows/spree/returns/refund.rb +16 -25
  113. data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
  114. data/app/workflows/spree/sellers/invite.rb +1 -1
  115. data/config/locales/en.yml +8 -119
  116. data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
  117. data/db/migrate/20260916000001_order_operation_actors_polymorphic.rb +30 -0
  118. data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
  119. data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
  120. data/db/sample_data/channels.rb +1 -1
  121. data/db/sample_data/collections.rb +1 -1
  122. data/db/sample_data/custom_field_definitions.rb +1 -1
  123. data/db/sample_data/digital_products.rb +1 -1
  124. data/db/sample_data/fulfillment.rb +1 -1
  125. data/db/sample_data/options.rb +4 -2
  126. data/db/sample_data/orders.rb +9 -10
  127. data/db/sample_data/payment_methods.rb +18 -18
  128. data/db/sample_data/product_type_categories.rb +1 -1
  129. data/db/sample_data/product_types.rb +2 -2
  130. data/db/sample_data/promotions.rb +2 -4
  131. data/db/sample_data/wholesale.rb +1 -1
  132. data/lib/generators/spree/api_resource/api_resource_generator.rb +121 -6
  133. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +8 -2
  134. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +3 -0
  135. data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
  136. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
  137. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +4 -1
  138. data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
  139. data/lib/generators/spree/model/model_generator.rb +40 -1
  140. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +9 -0
  141. data/lib/generators/spree/model/templates/model.rb.tt +10 -4
  142. data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
  143. data/lib/spree/core/configuration.rb +25 -25
  144. data/lib/spree/core/engine.rb +35 -8
  145. data/lib/spree/core/preferences/runtime_configuration.rb +76 -2
  146. data/lib/spree/core/version.rb +1 -1
  147. data/lib/spree/core.rb +27 -0
  148. data/lib/spree/iso_data.rb +23 -4
  149. data/lib/spree/store_scope_guard.rb +16 -1
  150. data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
  151. data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
  152. data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
  153. data/lib/spree/testing_support/factories/user_factory.rb +0 -1
  154. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  155. data/lib/tasks/backfill_actor_types.rake +35 -0
  156. data/lib/tasks/sample_data.rake +8 -2
  157. data/lib/tasks/store_settings.rake +1 -0
  158. data/lib/tasks/upgrade.rake +6 -21
  159. metadata +23 -9
  160. /data/{app/models → lib}/spree/checkout/default_requirements.rb +0 -0
  161. /data/{app/models → lib}/spree/checkout/registry.rb +0 -0
  162. /data/{app/models → lib}/spree/checkout/requirement.rb +0 -0
  163. /data/{app/models → lib}/spree/checkout/requirements.rb +0 -0
  164. /data/{app/models → lib}/spree/checkout/step.rb +0 -0
@@ -2,16 +2,11 @@ module Spree
2
2
  module Seeds
3
3
  class DigitalDelivery
4
4
  prepend Spree::ServiceModule::Base
5
-
6
- def call
7
- Spree::Store.all.find_each do |store|
8
- create_for(store)
9
- end
10
- end
5
+ include StoreScoped
11
6
 
12
7
  private
13
8
 
14
- def create_for(store)
9
+ def seed(store)
15
10
  # Digital goods are a distinct product set, so they get their own
16
11
  # profile; assigning a product to it is what makes it digital.
17
12
  profile = Spree::DeliveryProfiles::Digital.find_by(store: store) ||
@@ -22,7 +17,11 @@ module Spree
22
17
  digital_delivery_method.delivery_profile = profile
23
18
  digital_delivery_method.storefront_visible = true
24
19
  digital_delivery_method.fulfillment_provider = 'Spree::FulfillmentProvider::Digital'
25
- digital_delivery_method.calculator ||= Spree::Calculator::Shipping::DigitalDelivery.create!
20
+ # Explicit, because the calculator's own default is the default
21
+ # store's currency rather than this store's.
22
+ digital_delivery_method.calculator ||= Spree::Calculator::Shipping::DigitalDelivery.create!(
23
+ preferred_currency: store.default_currency
24
+ )
26
25
  digital_delivery_method.save!
27
26
  end
28
27
  end
@@ -2,19 +2,20 @@ module Spree
2
2
  module Seeds
3
3
  class PaymentMethods
4
4
  prepend Spree::ServiceModule::Base
5
+ include StoreScoped
5
6
 
6
- def call
7
- Spree::Store.all.find_each do |store|
8
- payment_method = store.payment_methods.find_or_initialize_by(
9
- type: 'Spree::PaymentMethod::StoreCredit'
10
- )
11
- next if payment_method.persisted?
7
+ private
12
8
 
13
- payment_method.name = Spree.t(:store_credit_name)
14
- payment_method.description = Spree.t(:store_credit_name)
15
- payment_method.active = true
16
- payment_method.save!
17
- end
9
+ def seed(store)
10
+ payment_method = store.payment_methods.find_or_initialize_by(
11
+ type: 'Spree::PaymentMethod::StoreCredit'
12
+ )
13
+ return if payment_method.persisted?
14
+
15
+ payment_method.name = Spree.t(:store_credit_name)
16
+ payment_method.description = Spree.t(:store_credit_name)
17
+ payment_method.active = true
18
+ payment_method.save!
18
19
  end
19
20
  end
20
21
  end
@@ -2,18 +2,17 @@ module Spree
2
2
  module Seeds
3
3
  class ProductTypes
4
4
  prepend Spree::ServiceModule::Base
5
+ include StoreScoped
5
6
 
6
- def call
7
- Spree::Store.all.find_each do |store|
8
- Spree::ProductType.where(store: store).find_or_create_by!(name: I18n.t('spree.seed.product_types.default'))
9
- Spree::ProductType.where(store: store).find_or_create_by!(name: I18n.t('spree.seed.product_types.digital')) do |product_type|
10
- product_type.delivery_profile = digital_profile_for(store)
11
- end
7
+ private
8
+
9
+ def seed(store)
10
+ Spree::ProductType.where(store: store).find_or_create_by!(name: I18n.t('spree.seed.product_types.default'))
11
+ Spree::ProductType.where(store: store).find_or_create_by!(name: I18n.t('spree.seed.product_types.digital')) do |product_type|
12
+ product_type.delivery_profile = digital_profile_for(store)
12
13
  end
13
14
  end
14
15
 
15
- private
16
-
17
16
  def digital_profile_for(store)
18
17
  Spree::DeliveryProfiles::Digital.find_by(store: store) ||
19
18
  Spree::DeliveryProfiles::Digital.create!(store: store, name: I18n.t('spree.seed.delivery_profiles.digital'))
@@ -2,6 +2,7 @@ module Spree
2
2
  module Seeds
3
3
  class ReturnsEnvironment
4
4
  prepend Spree::ServiceModule::Base
5
+ include StoreScoped
5
6
 
6
7
  RETURN_REASONS = [
7
8
  'Better price available',
@@ -37,18 +38,18 @@ module Spree
37
38
  'Item not as described'
38
39
  ].freeze
39
40
 
40
- def call
41
- # Every vocabulary is store-owned, so every store gets its own.
42
- Spree::Store.all.find_each do |store|
43
- # Through the model helper, not a literal — the workflows look this
44
- # row up by the same constant, so a hard-coded string here would
45
- # silently create a second, unfindable reason if the name ever moves.
46
- Spree::RefundReason.return_processing_reason(store)
41
+ private
47
42
 
48
- RETURN_REASONS.each { |name| Spree::ReturnReason.where(store: store).find_or_create_by!(name: name) }
49
- CLAIM_REASONS.each { |name| Spree::ClaimReason.where(store: store).find_or_create_by!(name: name) }
50
- ORDER_CANCELLATION_REASONS.each { |name| Spree::OrderCancellationReason.where(store: store).find_or_create_by!(name: name) }
51
- end
43
+ # Every vocabulary is store-owned, so every store gets its own.
44
+ def seed(store)
45
+ # Through the model helper, not a literal — the workflows look this
46
+ # row up by the same constant, so a hard-coded string here would
47
+ # silently create a second, unfindable reason if the name ever moves.
48
+ Spree::RefundReason.return_processing_reason(store)
49
+
50
+ RETURN_REASONS.each { |name| Spree::ReturnReason.where(store: store).find_or_create_by!(name: name) }
51
+ CLAIM_REASONS.each { |name| Spree::ClaimReason.where(store: store).find_or_create_by!(name: name) }
52
+ ORDER_CANCELLATION_REASONS.each { |name| Spree::OrderCancellationReason.where(store: store).find_or_create_by!(name: name) }
52
53
  end
53
54
  end
54
55
  end
@@ -1,15 +1,16 @@
1
1
  module Spree
2
2
  module Seeds
3
+ # A role belongs to what it governs, so every store gets its own
4
+ # immutable admin.
3
5
  class Roles
4
6
  prepend Spree::ServiceModule::Base
7
+ include StoreScoped
5
8
 
6
- # A role belongs to what it governs, so every store gets its own
7
- # immutable admin.
8
- def call
9
- Spree::Store.find_each do |store|
10
- admin = Spree::Role.default_admin_role(store)
11
- admin.update_column(:mutable, false) if admin.read_attribute(:mutable)
12
- end
9
+ private
10
+
11
+ def seed(store)
12
+ admin = Spree::Role.default_admin_role(store)
13
+ admin.update_column(:mutable, false) if admin.read_attribute(:mutable)
13
14
  end
14
15
  end
15
16
  end
@@ -6,6 +6,7 @@ module Spree
6
6
  # only never-created ones are added.
7
7
  class SavedReports
8
8
  prepend Spree::ServiceModule::Base
9
+ include StoreScoped
9
10
 
10
11
  REPORTS = [
11
12
  { key: 'sales_over_time',
@@ -70,31 +71,29 @@ module Spree
70
71
  'time_range' => { 'preset' => 'last_4_weeks' } } }
71
72
  ].freeze
72
73
 
73
- def call
74
- Spree::Store.find_each do |store|
75
- # Compared case-insensitively like the model's uniqueness rule, so a
76
- # merchant's own "top products" never makes a re-seed raise.
77
- existing = store.saved_reports.pluck(:name).map(&:downcase).to_set
74
+ private
75
+
76
+ def seed(store)
77
+ # Compared case-insensitively like the model's uniqueness rule, so a
78
+ # merchant's own "top products" never makes a re-seed raise.
79
+ existing = store.saved_reports.pluck(:name).map(&:downcase).to_set
78
80
 
79
- REPORTS.each do |report|
80
- # Every locale's name for this report, not just the current one:
81
- # the name is translated, so a store seeded in one language and
82
- # re-seeded in another would otherwise recognise none of its own
83
- # built-ins and create a second full set.
84
- next if known_names(report[:key]).intersect?(existing)
81
+ REPORTS.each do |report|
82
+ # Every locale's name for this report, not just the current one:
83
+ # the name is translated, so a store seeded in one language and
84
+ # re-seeded in another would otherwise recognise none of its own
85
+ # built-ins and create a second full set.
86
+ next if known_names(report[:key]).intersect?(existing)
85
87
 
86
- store.saved_reports.create!(
87
- name: Spree.t("reporting.seeds.#{report[:key]}.name"),
88
- description: Spree.t("reporting.seeds.#{report[:key]}.description"),
89
- query: report[:query],
90
- seeded: true
91
- )
92
- end
88
+ store.saved_reports.create!(
89
+ name: Spree.t("reporting.seeds.#{report[:key]}.name"),
90
+ description: Spree.t("reporting.seeds.#{report[:key]}.description"),
91
+ query: report[:query],
92
+ seeded: true
93
+ )
93
94
  end
94
95
  end
95
96
 
96
- private
97
-
98
97
  # The downcased name this report carries in every locale core ships.
99
98
  def known_names(key)
100
99
  @known_names ||= {}
@@ -8,11 +8,12 @@ module Spree
8
8
  # a licence) is theirs to add, and there is no sensible default for it.
9
9
  class SellerRequirements
10
10
  prepend Spree::ServiceModule::Base
11
+ include StoreScoped
11
12
 
12
- def call
13
- Spree::Store.find_each do |store|
14
- Spree::SellerRequirement.provision_defaults(store)
15
- end
13
+ private
14
+
15
+ def seed(store)
16
+ Spree::SellerRequirement.provision_defaults(store)
16
17
  end
17
18
  end
18
19
  end
@@ -0,0 +1,41 @@
1
+ module Spree
2
+ module Seeds
3
+ # Seeds what a single store needs to trade, without touching any other
4
+ # store.
5
+ #
6
+ # Country-shaped defaults (market, warehouse, delivery zones, pickup) are
7
+ # not part of it: the caller runs Spree::Stores::ProvisionDefaults
8
+ # afterwards with the merchant's answers.
9
+ class StoreResources
10
+ prepend Spree::ServiceModule::Base
11
+
12
+ # @param store [Spree::Store] the store to seed, already persisted
13
+ # @return [Spree::ServiceModule::Result]
14
+ def call(store:)
15
+ # A nil store would fall through to every seed's all-stores mode.
16
+ raise ArgumentError, 'store is required' if store.nil?
17
+
18
+ Spree::Events.disable do
19
+ ActiveRecord::Base.no_touching do
20
+ TaxCategories.call(store: store)
21
+ Channels.call(store: store)
22
+ Roles.call(store: store)
23
+ DigitalDelivery.call(store: store)
24
+ PaymentMethods.call(store: store)
25
+ ProductTypes.call(store: store)
26
+ CustomerGroups.call(store: store)
27
+ ReturnsEnvironment.call(store: store)
28
+ # The marketplace's catch-all rate, at the bottom of the list so
29
+ # narrower rates added later resolve ahead of it.
30
+ CommissionRates.call(store: store)
31
+ SellerRequirements.call(store: store)
32
+ # Binds to the wholesale channel that Channels creates above.
33
+ ApiKeys.call(store: store)
34
+ SavedReports.call(store: store)
35
+ AllowedOrigins.call(store: store)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,15 @@
1
+ module Spree
2
+ module Seeds
3
+ # Shared entry point for seeds whose rows belong to a store. Including
4
+ # classes implement a private `seed(store)` that seeds exactly one store
5
+ # and is safe to re-run.
6
+ module StoreScoped
7
+ # @param store [Spree::Store, nil] the store to seed; every store when nil
8
+ def call(store: nil)
9
+ return seed(store) if store
10
+
11
+ Spree::Store.find_each { |each_store| seed(each_store) }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -2,17 +2,18 @@ module Spree
2
2
  module Seeds
3
3
  class TaxCategories
4
4
  prepend Spree::ServiceModule::Base
5
+ include StoreScoped
5
6
 
6
- def call
7
- Spree::Store.find_each do |store|
8
- # The block form only runs on create, so a store that already had a
9
- # "Default" category with the flag unset kept no default at all — and
10
- # the default is what taxes an item carrying no category of its own.
11
- default = store.tax_categories.find_or_create_by!(name: 'Default')
12
- default.update!(is_default: true) unless default.is_default?
7
+ private
13
8
 
14
- store.tax_categories.find_or_create_by!(name: 'Non-taxable')
15
- end
9
+ def seed(store)
10
+ # The block form only runs on create, so a store that already had a
11
+ # "Default" category with the flag unset kept no default at all — and
12
+ # the default is what taxes an item carrying no category of its own.
13
+ default = store.tax_categories.find_or_create_by!(name: 'Default')
14
+ default.update!(is_default: true) unless default.is_default?
15
+
16
+ store.tax_categories.find_or_create_by!(name: 'Non-taxable')
16
17
  end
17
18
  end
18
19
  end
@@ -10,7 +10,7 @@ module Spree
10
10
  # @param stock_location [Spree::StockLocation]
11
11
  # @param variants_scope [ActiveRecord::Relation]
12
12
  # @return [Spree::ServiceModule::Base::Result]
13
- def call(stock_location:, variants_scope: Spree::Variant)
13
+ def call(stock_location:, variants_scope: nil)
14
14
  Spree::Deprecation.warn('Spree::StockLocations::StockItems::Create is deprecated and will be removed in Spree 6.1. Use Spree::StockLocations::StockLevels::Create instead.')
15
15
  Spree::StockLocations::StockLevels::Create.call(stock_location: stock_location, variants_scope: variants_scope)
16
16
  end
@@ -4,8 +4,13 @@ module Spree
4
4
  class Create
5
5
  prepend Spree::ServiceModule::Base
6
6
 
7
- def call(stock_location:, variants_scope: Spree::Variant)
8
- prepared_stock_levels = variants_scope.ids.map do |variant_id|
7
+ # @param stock_location [Spree::StockLocation]
8
+ # @param variants_scope [ActiveRecord::Relation, nil] defaults to what the
9
+ # location may stock; always narrowed to it, so no caller can seed rows
10
+ # for another store's or another seller's variants
11
+ def call(stock_location:, variants_scope: nil)
12
+ variant_ids = propagatable_variants(stock_location, variants_scope).ids
13
+ prepared_stock_levels = variant_ids.map do |variant_id|
9
14
  Hash[
10
15
  'stock_location_id', stock_location.id,
11
16
  'variant_id', variant_id,
@@ -16,9 +21,20 @@ module Spree
16
21
  end
17
22
  if prepared_stock_levels.any?
18
23
  stock_location.stock_levels.insert_all(prepared_stock_levels)
19
- variants_scope.touch_all
24
+ # By id: MySQL refuses an UPDATE whose own table appears in a subquery.
25
+ Spree::Variant.where(id: variant_ids).touch_all
20
26
  end
21
27
  end
28
+
29
+ private
30
+
31
+ # A location stocks its own store's variants, and a seller's location
32
+ # only the variants that seller sells.
33
+ def propagatable_variants(stock_location, variants_scope)
34
+ scope = Spree::Variant.where(id: (variants_scope || Spree::Variant).select(:id))
35
+ scope = scope.for_seller(stock_location.seller_id) if stock_location.seller_id.present?
36
+ scope.joins(:product).where(Spree::Product.table_name => { store_id: stock_location.store_id })
37
+ end
22
38
  end
23
39
  end
24
40
  end
@@ -1,6 +1,6 @@
1
1
  module Spree
2
2
  # The trigger side of derive-then-persist statuses: any payment, refund,
3
- # fulfillment or return change recomputes the owning order's
3
+ # store credit, fulfillment or return change recomputes the owning order's
4
4
  # payment_status / fulfillment_status through the single writer
5
5
  # (Spree::Orders::UpdateStatuses). Synchronous so API responses right
6
6
  # after a capture/void/fulfill already carry the fresh status. Cart-owned
@@ -15,12 +15,15 @@ module Spree
15
15
  subscribes_to 'payment.created', 'payment.updated', 'payment.deleted',
16
16
  'payment.completed', 'payment.captured', 'payment.voided',
17
17
  'refund.created', 'refund.updated',
18
+ 'store_credit.created', 'store_credit.updated', 'store_credit.deleted',
18
19
  'fulfillment.created', 'fulfillment.updated', 'fulfillment.deleted',
19
20
  'return.received', 'return.refunded', 'return.canceled',
20
21
  async: false
21
22
 
22
23
  # Events whose payload changes what the customer has actually paid, so
23
24
  # payment_total has to be re-summed before statuses derive from it.
25
+ # Store credit is not one of them: the money stayed with the store, and
26
+ # payment_total is what came in through payments.
24
27
  MONEY_EVENTS = %w[
25
28
  payment.created payment.updated payment.deleted payment.completed
26
29
  payment.voided refund.created refund.updated
@@ -63,19 +66,24 @@ module Spree
63
66
  resource_class = {
64
67
  'payment' => Spree::Payment,
65
68
  'refund' => Spree::Refund,
69
+ 'store_credit' => Spree::StoreCredit,
66
70
  'fulfillment' => Spree::Fulfillment,
67
71
  'return' => Spree::Return
68
72
  }[event.resource_type]
69
73
  return if resource_class.nil?
70
74
 
71
- record = resource_class.find_by_prefix_id(event.payload['id'])
72
- # None of these models are paranoid, so a .deleted event arrives with
73
- # its row already gone and a payload carrying no owner — nothing can
74
- # lead back to the order. The models whose deletion changes order
75
- # money recalculate from their own after_destroy instead.
75
+ # A paranoid model's .deleted event leaves the row in place but out of
76
+ # default scope, and a credit that has been withdrawn is exactly the
77
+ # kind of change the order has to learn about.
78
+ record = (resource_class.try(:with_deleted) || resource_class).
79
+ find_by_prefix_id(event.payload['id'])
80
+ # Neither Payment nor Fulfillment is paranoid, so their .deleted events
81
+ # arrive with the row already gone and a payload carrying no owner —
82
+ # nothing can lead back to the order. The models whose deletion changes
83
+ # order money recalculate from their own after_destroy instead.
76
84
  return if record.nil?
77
85
 
78
- record.try(:owner) || record.try(:order) ||
86
+ record.try(:owner) || record.try(:order) || record.try(:refunded_order) ||
79
87
  record.try(:payment)&.try(:owner)
80
88
  end
81
89
  end
@@ -1,9 +1,6 @@
1
1
  module Spree
2
2
  module Carts
3
3
  class AddItem < Spree::Workflow
4
- # Line item attributes a caller may set through `options`.
5
- ITEM_OPTIONS = [:id, :variant_id, :quantity].freeze
6
-
7
4
  hooks :validate, :after_item_added
8
5
 
9
6
  # Hook handlers read these plus the argument readers. Both are nil
@@ -110,10 +107,11 @@ module Spree
110
107
 
111
108
  if @line_item.nil?
112
109
  # `LineItem#options=` mass-assigns, so the caller's hash is narrowed
113
- # first — `options` must not be a way to set arbitrary attributes.
114
- # Extensions widen this through the model's permitted attributes.
115
- writable = ITEM_OPTIONS +
116
- ::Spree::LineItem.additional_permitted_attributes.flat_map { |a| a.is_a?(Hash) ? a.keys : a }
110
+ # to the attributes extensions declare. The variant and quantity are
111
+ # never among them: both were checked and priced above, and an
112
+ # `options` value replacing them would sell something else.
113
+ writable = ::Spree::LineItem.additional_permitted_attributes.flat_map { |a| a.is_a?(Hash) ? a.keys : a }.
114
+ map(&:to_sym) - [:id, :variant_id, :quantity]
117
115
 
118
116
  opts = item_options.symbolize_keys.slice(*writable).
119
117
  merge(currency: cart.currency).
@@ -207,6 +207,13 @@ module Spree
207
207
  # typed money lines re-pointed, fulfillments + selected rates, address
208
208
  # copies); money records (payments, sessions, reservations, coupon
209
209
  # codes) re-point — external transaction references must never fork.
210
+ #
211
+ # The cart's metadata is copied whole: storefronts and checkout
212
+ # requirements keep what they collected about the purchase there, and it
213
+ # has to outlive the cart. The order is new at this point, so there is
214
+ # nothing on it to merge with — the cart's hash simply becomes the
215
+ # order's. A deep copy, so a later edit to either record can never reach
216
+ # the other through a shared nested hash.
210
217
  def create_draft_order!(cart)
211
218
  order = nil
212
219
  ApplicationRecord.transaction do
@@ -228,6 +235,7 @@ module Spree
228
235
  po_number: cart.po_number,
229
236
  gift_card: cart.gift_card,
230
237
  last_ip_address: cart.last_ip_address,
238
+ metadata: cart.metadata.to_h.deep_dup,
231
239
  ship_address: cart.ship_address&.snapshot,
232
240
  bill_address: cart.bill_address&.snapshot
233
241
  )
@@ -407,82 +415,30 @@ module Spree
407
415
  # resumes like any other finalize failure.
408
416
  def finalize!(cart, order)
409
417
  @order = order
410
- step :split_by_seller
411
- step :allocate_payment_splits
412
418
  step :complete_orders
413
419
  step :complete_cart
414
420
  step :mark_coupon_codes_used
415
421
  external_step :commit_tax
416
422
  end
417
423
 
418
- # Divides the paid draft order into one order per seller, under a group.
419
- # A cart of one partition — all first-party, or all one seller — is left
420
- # exactly as it is: no group, no splits, and the order it already built
421
- # is the order the customer gets.
424
+ # Places what the checkout produced, through the order-side workflow
425
+ # that owns placement — including dividing the basket between sellers,
426
+ # which an order raised from the admin needs just as much and which only
427
+ # that workflow can give both of them.
422
428
  #
423
- # The draft order built in PREPARE becomes the group's first child rather
424
- # than being replaced. It is the record the payment was taken against and
425
- # the row carrying the unique cart_id that makes completion replayable,
426
- # so it is kept and narrowed to its own partition; the remaining
427
- # partitions become new siblings beside it.
428
- def split_by_seller
429
- @order_group = cart.order_group
430
- return if order_group.present?
431
- return if order.placed?
432
-
433
- partitions = Spree::Carts::PartitionBySeller.call(purchase: order).value
434
-
435
- if partitions.one?
436
- # Nothing to divide, but the sale still belongs to whoever made it:
437
- # a basket entirely from one seller is that seller's order, and the
438
- # column is what their own order list reads.
439
- order.update_columns(seller_id: partitions.first.seller_id) if order.seller_id != partitions.first.seller_id
440
- return
441
- end
442
-
443
- return if partitions.empty?
429
+ # It answers with the group when the basket divided, and the first of
430
+ # its children is the order this checkout carries on with. No child holds
431
+ # the confirmation: the customer is confirmed from the group.
432
+ def complete_orders
433
+ result = Spree.order_complete_workflow.call(order: order, payment_pending: payment_pending)
434
+ failure(cart, code: 'completion_failed', message: result.error) if result.failure?
444
435
 
445
- result = Spree::Carts::SplitBySeller.call(cart: cart, order: order, partitions: partitions)
446
- failure(cart, code: 'split_failed', message: result.error) if result.failure?
436
+ return unless result.value.is_a?(Spree::OrderGroup)
447
437
 
448
438
  @order_group = result.value
449
- # Ordered, because which child comes first decides which one carries
450
- # the confirmation email — that must not depend on how the rows come
451
- # back.
452
439
  @order = order_group.orders.order(:id).first
453
440
  end
454
441
 
455
- def allocate_payment_splits
456
- return if order_group.nil?
457
-
458
- result = Spree::OrderGroups::AllocatePayments.call(group: order_group)
459
- failure(cart, code: 'split_failed', message: result.error) if result.failure?
460
- end
461
-
462
- # Places what the checkout produced — the children of a split, or the one
463
- # order otherwise. Each child publishes its own order.placed, which is
464
- # what gets every seller their own commission lines with no marketplace
465
- # code in the commission engine.
466
- #
467
- # A split checkout's payments were already taken against the whole basket
468
- # and moved onto the group, so the children place without processing them
469
- # again. One purchase means one confirmation email: the first child
470
- # carries the notification and its siblings place silently.
471
- def complete_orders
472
- split = order_group.present?
473
-
474
- placed_orders.each_with_index do |child, index|
475
- result = Spree.order_complete_workflow.call(
476
- order: child,
477
- payment_pending: split,
478
- notify_customer: split && !index.zero? ? false : nil
479
- )
480
- failure(cart, code: 'completion_failed', message: result.error) if result.failure?
481
- end
482
-
483
- order_group&.publish_event('order_group.completed')
484
- end
485
-
486
442
  # Tells the tax engine the sale is final. A no-op for the built-in
487
443
  # provider, whose rows are already the record; external engines file the
488
444
  # document that a tax return is later built from. Outside the
@@ -499,7 +455,7 @@ module Spree
499
455
  # otherwise. The single answer to "which orders came out of here", so
500
456
  # placement and tax filing can never disagree about the set.
501
457
  def placed_orders
502
- order_group.present? ? order_group.orders.to_a : [order]
458
+ order_group.present? ? order_group.orders.to_a.sort_by(&:id) : [order]
503
459
  end
504
460
 
505
461
  def complete_cart
@@ -137,7 +137,9 @@ module Spree
137
137
  # @return [BigDecimal]
138
138
  def paid_so_far
139
139
  if cart.is_a?(Spree::Order) && cart.grouped?
140
- return cart.payment_splits.to_a.sum(&:net_captured_amount)
140
+ # Reloaded: a share moved since this instance loaded them would be
141
+ # re-summed at its old figure and persisted back over the correction.
142
+ return cart.payment_splits.reload.to_a.sum(&:net_captured_amount)
141
143
  end
142
144
 
143
145
  cart.payments.completed.includes(:refunds).inject(0) do |sum, payment|
@@ -39,6 +39,9 @@ module Spree
39
39
  failure(order, :no_items_to_claim) if items.blank?
40
40
  end
41
41
 
42
+ # Bounded by what earlier claims left: each claim can pay out what was
43
+ # paid for its units, so claiming the same units again would pay twice.
44
+ # A denied or canceled claim settled nothing and frees its units.
42
45
  def normalize_items
43
46
  @normalized_items = items.map do |item|
44
47
  line_item = item[:line_item]
@@ -46,13 +49,40 @@ module Spree
46
49
 
47
50
  failure(order, :invalid_quantity) unless quantity.positive?
48
51
  failure(order, :item_not_on_order) unless line_item&.order_id == order.id
49
- failure(order, :invalid_quantity) if quantity > line_item.quantity.to_i
50
52
 
51
53
  item.merge(line_item: line_item, quantity: quantity)
52
54
  end
55
+
56
+ ensure_claimable_quantities
57
+ end
58
+
59
+ def ensure_claimable_quantities
60
+ requested = Hash.new(0)
61
+
62
+ @normalized_items.each do |item|
63
+ line_item = item[:line_item]
64
+ failure(order, :invalid_quantity) if item[:quantity] > claimable_quantity_for(line_item) - requested[line_item.id]
65
+
66
+ requested[line_item.id] += item[:quantity]
67
+ end
68
+ end
69
+
70
+ def claimable_quantity_for(line_item)
71
+ claimed = Spree::ClaimLineItem.
72
+ joins(:claim).
73
+ where(line_item_id: line_item.id).
74
+ where.not(Spree::Claim.table_name => { status: %w[denied canceled] }).
75
+ sum(:quantity)
76
+
77
+ line_item.quantity.to_i - claimed
53
78
  end
54
79
 
55
80
  def build_claim
81
+ # Again under the order's row lock, so two requests racing for the same
82
+ # units cannot both pass the check above.
83
+ Spree::Order.lock.find(order.id)
84
+ ensure_claimable_quantities
85
+
56
86
  @claim = order.claims.new(
57
87
  store: order.store,
58
88
  reason: reason,