spree_core 6.0.0.rc3 → 6.0.0.rc5

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/app/mailers/spree/email_template_mailer.rb +5 -2
  3. data/app/models/concerns/spree/has_external_references.rb +7 -6
  4. data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
  5. data/app/models/concerns/spree/purchase/addresses.rb +95 -32
  6. data/app/models/concerns/spree/purchase/taxation.rb +44 -3
  7. data/app/models/concerns/spree/translatable_resource.rb +49 -0
  8. data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
  9. data/app/models/spree/ability.rb +8 -0
  10. data/app/models/spree/address.rb +6 -0
  11. data/app/models/spree/cart.rb +4 -6
  12. data/app/models/spree/company.rb +1 -1
  13. data/app/models/spree/data_request.rb +34 -1
  14. data/app/models/spree/delivery_rate.rb +12 -4
  15. data/app/models/spree/delivery_rate_provider/base.rb +1 -1
  16. data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
  17. data/app/models/spree/fulfillment.rb +1 -0
  18. data/app/models/spree/gateway/bogus.rb +7 -0
  19. data/app/models/spree/line_item.rb +3 -8
  20. data/app/models/spree/oauth_access_grant.rb +11 -0
  21. data/app/models/spree/oauth_access_token.rb +16 -0
  22. data/app/models/spree/oauth_application.rb +97 -0
  23. data/app/models/spree/oauth_consent_request.rb +23 -0
  24. data/app/models/spree/oauth_protected_resource.rb +21 -0
  25. data/app/models/spree/oauth_redirect.rb +12 -0
  26. data/app/models/spree/option_type.rb +5 -0
  27. data/app/models/spree/option_value.rb +5 -0
  28. data/app/models/spree/order.rb +6 -2
  29. data/app/models/spree/payment.rb +14 -2
  30. data/app/models/spree/payment_capture_event.rb +2 -0
  31. data/app/models/spree/policy.rb +6 -0
  32. data/app/models/spree/price.rb +16 -33
  33. data/app/models/spree/price_rule.rb +3 -16
  34. data/app/models/spree/price_rules/market_rule.rb +0 -6
  35. data/app/models/spree/pricing_provider/base.rb +1 -1
  36. data/app/models/spree/product.rb +1 -1
  37. data/app/models/spree/purchase_order_item.rb +1 -1
  38. data/app/models/spree/return.rb +1 -0
  39. data/app/models/spree/sales_context.rb +162 -0
  40. data/app/models/spree/search_provider/database.rb +1 -1
  41. data/app/models/spree/stock/estimator.rb +4 -20
  42. data/app/models/spree/store.rb +8 -0
  43. data/app/models/spree/tax_exemption/item_override.rb +14 -0
  44. data/app/models/spree/tax_exemption.rb +26 -0
  45. data/app/models/spree/tax_exemption_certificate.rb +13 -2
  46. data/app/models/spree/tax_exemption_reason.rb +62 -0
  47. data/app/models/spree/tax_line.rb +5 -4
  48. data/app/models/spree/tax_provider/base.rb +24 -4
  49. data/app/models/spree/tax_provider/internal.rb +36 -29
  50. data/app/models/spree/tax_rate.rb +9 -35
  51. data/app/models/spree/translations/batch.rb +14 -4
  52. data/app/services/spree/carts/destroy.rb +7 -2
  53. data/app/services/spree/carts/price_items.rb +2 -8
  54. data/app/services/spree/carts/split_by_seller.rb +6 -1
  55. data/app/services/spree/carts/update.rb +35 -11
  56. data/app/services/spree/data_requests/confirm.rb +37 -0
  57. data/app/services/spree/data_requests/create.rb +61 -13
  58. data/app/services/spree/email_templates/preview.rb +24 -3
  59. data/app/services/spree/seeds/store_resources.rb +1 -0
  60. data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
  61. data/app/services/spree/tax/resolve_exemptions.rb +11 -2
  62. data/app/services/spree/update_check.rb +8 -1
  63. data/app/tools/spree/agent_tools/create_export.rb +116 -0
  64. data/app/tools/spree/agent_tools/create_import.rb +117 -0
  65. data/app/tools/spree/agent_tools/create_media.rb +81 -0
  66. data/app/tools/spree/agent_tools/create_resource.rb +36 -0
  67. data/app/tools/spree/agent_tools/curate_products.rb +90 -0
  68. data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
  69. data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
  70. data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
  71. data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
  72. data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
  73. data/app/tools/spree/agent_tools/get_resource.rb +97 -0
  74. data/app/tools/spree/agent_tools/import_tool.rb +80 -0
  75. data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
  76. data/app/tools/spree/agent_tools/query_report.rb +124 -0
  77. data/app/tools/spree/agent_tools/resource_write.rb +99 -0
  78. data/app/tools/spree/agent_tools/search_resources.rb +155 -0
  79. data/app/tools/spree/agent_tools/set_stock.rb +52 -0
  80. data/app/tools/spree/agent_tools/update_resource.rb +42 -0
  81. data/app/tools/spree/agent_tools/upload_file.rb +104 -0
  82. data/app/workflows/spree/carts/complete.rb +115 -2
  83. data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
  84. data/app/workflows/spree/customers/anonymize.rb +38 -17
  85. data/app/workflows/spree/data_requests/fulfill.rb +20 -6
  86. data/app/workflows/spree/orders/cancel.rb +8 -0
  87. data/app/workflows/spree/orders/complete.rb +8 -0
  88. data/app/{services → workflows}/spree/orders/create.rb +50 -26
  89. data/app/workflows/spree/purchase_orders/create.rb +3 -0
  90. data/app/workflows/spree/returns/refund.rb +4 -0
  91. data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
  92. data/app/workflows/spree/variants/create.rb +7 -4
  93. data/config/locales/en.yml +18 -0
  94. data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
  95. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
  96. data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
  97. data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
  98. data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
  99. data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
  100. data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
  101. data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
  102. data/db/sample_data/orders.rb +1 -1
  103. data/lib/spree/agent_tool.rb +188 -0
  104. data/lib/spree/agent_tools/api_dispatch.rb +204 -0
  105. data/lib/spree/agent_tools/context.rb +191 -0
  106. data/lib/spree/agent_tools/default_catalog.rb +180 -0
  107. data/lib/spree/agent_tools/membership_map.rb +61 -0
  108. data/lib/spree/agent_tools/record_summary.rb +219 -0
  109. data/lib/spree/agent_tools/registry.rb +131 -0
  110. data/lib/spree/agent_tools/resource_map.rb +352 -0
  111. data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
  112. data/lib/spree/agent_tools/workflow_schema.rb +279 -0
  113. data/lib/spree/agent_tools/workflow_tool.rb +654 -0
  114. data/lib/spree/agent_tools.rb +24 -0
  115. data/lib/spree/core/configuration.rb +1 -1
  116. data/lib/spree/core/controller_helpers/store.rb +3 -13
  117. data/lib/spree/core/dependencies.rb +9 -0
  118. data/lib/spree/core/engine.rb +37 -1
  119. data/lib/spree/core/model_extensions.rb +111 -0
  120. data/lib/spree/core/permissions/default_catalog.rb +6 -1
  121. data/lib/spree/core/pricing/price_resolution.rb +4 -4
  122. data/lib/spree/core/version.rb +1 -1
  123. data/lib/spree/core.rb +27 -2
  124. data/lib/spree/service_module.rb +11 -0
  125. data/lib/spree/testing_support/factories/data_request_factory.rb +4 -0
  126. data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
  127. data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
  128. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
  129. data/lib/tasks/markets.rake +47 -0
  130. data/lib/tasks/tax_exemption_reasons.rake +48 -0
  131. metadata +69 -7
  132. data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
@@ -0,0 +1,11 @@
1
+ module Spree
2
+ class OauthAccessGrant < Spree.base_class
3
+ include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant
4
+
5
+ self.table_name = 'spree_oauth_access_grants'
6
+
7
+ belongs_to :application,
8
+ class_name: 'Spree::OauthApplication',
9
+ optional: false
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ module Spree
2
+ # The credential a client sends. Its resource owner is a real user, so a
3
+ # call carries that person's own permissions resolved fresh on every
4
+ # request — narrowing a role or deleting the user takes effect immediately,
5
+ # without touching the token. This is what an API key cannot do: a key is a
6
+ # standing grant with nobody behind it.
7
+ class OauthAccessToken < Spree.base_class
8
+ include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken
9
+
10
+ self.table_name = 'spree_oauth_access_tokens'
11
+
12
+ belongs_to :application,
13
+ class_name: 'Spree::OauthApplication',
14
+ optional: true
15
+ end
16
+ end
@@ -0,0 +1,97 @@
1
+ module Spree
2
+ # A registered OAuth client: an MCP client today, a marketplace app or a
3
+ # merchant-approved integration later. Clients are pre-registered rather
4
+ # than dynamically registered — RFC 7591 is deprecated, and the consumer
5
+ # clients worth supporting need no registration endpoint.
6
+ class OauthApplication < Spree.base_class
7
+ include ::Doorkeeper::Orm::ActiveRecord::Mixins::Application
8
+
9
+ self.table_name = 'spree_oauth_applications'
10
+
11
+ has_prefix_id :oauthapp
12
+
13
+ belongs_to :store, class_name: 'Spree::Store'
14
+
15
+ has_many :access_grants,
16
+ class_name: 'Spree::OauthAccessGrant',
17
+ foreign_key: :application_id,
18
+ dependent: :destroy
19
+ has_many :access_tokens,
20
+ class_name: 'Spree::OauthAccessToken',
21
+ foreign_key: :application_id,
22
+ dependent: :destroy
23
+
24
+ # Tokens that can still produce access.
25
+ #
26
+ # Not simply `not_expired`: an access token lasts hours while the refresh
27
+ # token beside it lasts far longer, so a client whose access has lapsed
28
+ # can mint another at will. Listing only unexpired access would hide a
29
+ # connection from the screen that exists to revoke it — the one place a
30
+ # merchant can actually stop it.
31
+ has_many :live_access_tokens,
32
+ -> { where(revoked_at: nil) },
33
+ class_name: 'Spree::OauthAccessToken',
34
+ foreign_key: :application_id,
35
+ inverse_of: :application,
36
+ dependent: nil
37
+
38
+ # Whether anything can still reach the store with this registration.
39
+ #
40
+ # The screen lists every registered client, so a row with no live token
41
+ # may be one nobody has connected yet or one whose access was revoked —
42
+ # and an empty permission list reads the same either way. A merchant
43
+ # pressing Revoke has to see that it worked.
44
+ #
45
+ # @return [Boolean]
46
+ def connected?
47
+ live_access_tokens.any?
48
+ end
49
+
50
+ # What this application may currently do, as permission keys.
51
+ #
52
+ # Read from the tokens rather than the registration: a client is
53
+ # registered once and granted per consent, so the registration's own
54
+ # `scopes` column stays empty.
55
+ #
56
+ # @return [Array<String>]
57
+ def live_token_scopes
58
+ live_access_tokens.flat_map { |token| token.scopes.to_a }.uniq.sort
59
+ end
60
+
61
+ # When this application last obtained a token that still works.
62
+ #
63
+ # Not the last call it made: a token is minted once and then used for
64
+ # hours, and nothing records a call. Reporting this as "last used" would
65
+ # let a merchant read an actively running agent as idle, which is the
66
+ # opposite of what the connected-apps screen is for.
67
+ #
68
+ # @return [ActiveSupport::TimeWithZone, nil]
69
+ def last_authorized_at
70
+ live_access_tokens.map(&:created_at).max
71
+ end
72
+
73
+ # Who approved this application's access, and when.
74
+ #
75
+ # Read from the oldest live token, because that is the consent still in
76
+ # force — a later token refreshes an existing grant rather than replacing
77
+ # who gave it. On the screen that decides whether to revoke, "Claude can
78
+ # read your orders" is only half the story without the person who allowed
79
+ # it.
80
+ #
81
+ # @return [Object, nil] a {Spree.admin_user_class} record
82
+ def authorized_by
83
+ oldest_live_token&.resource_owner
84
+ end
85
+
86
+ # @return [ActiveSupport::TimeWithZone, nil]
87
+ def authorized_at
88
+ oldest_live_token&.created_at
89
+ end
90
+
91
+ private
92
+
93
+ def oldest_live_token
94
+ live_access_tokens.min_by(&:created_at)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ # What a client is asking a merchant to approve, as the consent screen
3
+ # needs it. Never persisted — approving it writes an
4
+ # {Spree::OauthAccessGrant} instead.
5
+ class OauthConsentRequest
6
+ include ActiveModel::Model
7
+ include ActiveModel::Attributes
8
+
9
+ attribute :client_name, :string
10
+ # Every permission the requested scopes imply, expanded, so the screen
11
+ # lists what is actually being handed over rather than the shorthand the
12
+ # client asked for.
13
+ attribute :scopes, default: -> { [] }
14
+ # Of those, the ones this person may hand over. A grant can only narrow
15
+ # the approver's own authority, so anything they do not hold is not
16
+ # offered.
17
+ attribute :grantable_scopes, default: -> { [] }
18
+ attribute :resource, :string
19
+ attribute :redirect_uri, :string
20
+
21
+ validates :client_name, :redirect_uri, presence: true
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ module Spree
2
+ # RFC 9728 metadata: which authorization server guards a protected surface
3
+ # and what it will accept. Never persisted — assembled per request, since
4
+ # the origin a client reached is the one it can use.
5
+ class OauthProtectedResource
6
+ include ActiveModel::Model
7
+ include ActiveModel::Attributes
8
+
9
+ attribute :resource, :string
10
+ attribute :authorization_servers, default: -> { [] }
11
+ # Exactly what the authorization server accepts. Advertising one scope
12
+ # more than it knows turns every connection into "the requested scope is
13
+ # invalid", because a client with no scope challenge asks for everything
14
+ # listed here.
15
+ attribute :scopes_supported, default: -> { [] }
16
+ attribute :bearer_methods_supported, default: -> { ['header'] }
17
+ attribute :resource_documentation, :string
18
+
19
+ validates :resource, presence: true
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ module Spree
2
+ # Where to send the browser once the merchant has decided, carrying either
3
+ # the authorization code or the refusal. Never persisted.
4
+ class OauthRedirect
5
+ include ActiveModel::Model
6
+ include ActiveModel::Attributes
7
+
8
+ attribute :redirect_uri, :string
9
+
10
+ validates :redirect_uri, presence: true
11
+ end
12
+ end
@@ -12,6 +12,11 @@ module Spree
12
12
 
13
13
  TRANSLATABLE_FIELDS = Spree::LabelTranslatable::TRANSLATABLE_FIELDS
14
14
 
15
+ # Option types are managed under the catalog, so the products permission guards them — see Admin::OptionTypesController.
16
+ def self.translation_write_permission
17
+ 'write_products'
18
+ end
19
+
15
20
  # Option values are translated alongside their type — the translations
16
21
  # endpoint nests their matrices so the editor fetches both in one read.
17
22
  def self.translatable_children
@@ -7,6 +7,11 @@ module Spree
7
7
  include Spree::Metadata
8
8
  include Spree::LabelTranslatable
9
9
 
10
+ # Values are written through their option type, so they ride the same permission.
11
+ def self.translation_write_permission
12
+ 'write_products'
13
+ end
14
+
10
15
  TRANSLATABLE_FIELDS = Spree::LabelTranslatable::TRANSLATABLE_FIELDS
11
16
 
12
17
  #
@@ -51,9 +51,10 @@ module Spree
51
51
  include Spree::NumberIdentifier
52
52
 
53
53
  publishes_lifecycle_events
54
- publishes_events :approved, :canceled, :delivered, :fulfilled, :paid, :placed, :shipped,
54
+ publishes_events :approved, :canceled, :delivered, :fulfilled, :paid, :placed,
55
55
  :resend_confirmation_email, :resend_digital_links_email
56
56
  publishes_event :completed, deprecated_alias_of: 'order.placed'
57
+ publishes_event :shipped, deprecated_alias_of: 'order.fulfilled'
57
58
  include Spree::HasCustomFields
58
59
  include Spree::Metadata
59
60
  include Spree::HasExternalReferences
@@ -151,7 +152,7 @@ module Spree
151
152
  self.whitelisted_ransackable_attributes = %w[
152
153
  completed_at email number status payment_status payment_state fulfillment_status shipment_state delivery_total
153
154
  total item_total total_quantity considered_risky channel_id currency coupon_code customer_id seller_id
154
- order_group_id po_number
155
+ order_group_id po_number market_id locale
155
156
  ]
156
157
  self.whitelisted_ransackable_scopes = %w[complete incomplete refunded partially_refunded search]
157
158
  self.ransackable_scope_types = %w[complete incomplete refunded partially_refunded].index_with('boolean')
@@ -920,6 +921,9 @@ module Spree
920
921
 
921
922
  shipment_ids = fulfillments.map(&:id)
922
923
  DeliveryRate.where(fulfillment_id: shipment_ids).delete_all
924
+ # A connector's key for a fulfillment that is going away would otherwise
925
+ # outlive it and block the key from naming the rebuilt one.
926
+ Spree::ExternalReference.where(resource_type: Spree::Fulfillment.name, resource_id: shipment_ids).delete_all if shipment_ids.any?
923
927
 
924
928
  fulfillments.delete_all
925
929
 
@@ -326,16 +326,28 @@ module Spree
326
326
  true
327
327
  end
328
328
 
329
+ # Matches +Payment.risky+: any result other than a match counts, whatever
330
+ # message the gateway sent with it.
329
331
  def is_cvv_risky?
330
332
  return false if cvv_response_code == 'M'
331
333
  return false if cvv_response_code.nil?
332
- return false if cvv_response_message.present?
333
334
 
334
335
  true
335
336
  end
336
337
 
338
+ # The payment's page in the provider's dashboard. Only https links are
339
+ # returned, since staff open it from the admin and a gateway may build it
340
+ # from values the provider sent back.
341
+ #
342
+ # @return [String, nil]
337
343
  def gateway_dashboard_payment_url
338
- payment_method.try(:gateway_dashboard_payment_url, self)
344
+ url = payment_method.try(:gateway_dashboard_payment_url, self)
345
+ return if url.blank?
346
+
347
+ uri = URI.parse(url)
348
+ url if uri.is_a?(URI::HTTPS) && uri.host.present?
349
+ rescue URI::InvalidURIError
350
+ nil
339
351
  end
340
352
 
341
353
  def captured_amount
@@ -4,6 +4,8 @@ module Spree
4
4
 
5
5
  belongs_to :payment, class_name: 'Spree::Payment'
6
6
 
7
+ delegate :currency, to: :payment
8
+
7
9
  def display_amount
8
10
  Spree::Money.new(amount, currency: payment.currency)
9
11
  end
@@ -4,6 +4,12 @@ module Spree
4
4
 
5
5
  extend FriendlyId
6
6
  include Spree::TranslatableResource
7
+
8
+ # Policies are store settings — see Admin::PoliciesController.
9
+ def self.translation_write_permission
10
+ 'write_settings'
11
+ end
12
+
7
13
  include Spree::SanitizableRichText
8
14
 
9
15
  UNIQUENESS_SCOPE = %i[owner_id owner_type].freeze
@@ -9,7 +9,6 @@ module Spree
9
9
 
10
10
  has_prefix_id :price
11
11
 
12
- include Spree::VatPriceCalculation
13
12
  include Spree::StorePreferences
14
13
  include Spree::CanonicalDecimals
15
14
 
@@ -135,44 +134,28 @@ module Spree
135
134
  alias_attribute :price, :amount
136
135
  alias_attribute :compare_at_price, :compare_at_amount
137
136
 
138
- # Whether this figure is the merchant's answer for the buyer's geography
139
- # already. A price list narrowed by market or country states its prices for
140
- # that geography, so they are charged exactly as entered — restating one would
141
- # net it out with the *home* rate and destroy the price point the merchant
142
- # chose. Lists narrowed by anything else (a quantity, a group) were not set
143
- # for a country, so their prices restate like any other.
144
- #
145
- # @return [Boolean]
146
- def final_for_destination?
147
- rules = price_list&.price_rules.to_a
148
- return false unless rules.any?(&:geographic?)
149
-
150
- # Under 'any' the list can win on a non-geographic rule alone, so a
151
- # geographic rule being present does not mean this buyer's geography is why
152
- # the price applied. Under 'all' every rule matched, so it does.
153
- price_list.match_policy == 'all' || rules.all?(&:geographic?)
154
- end
155
-
156
- def price_including_vat_for(price_options)
157
- return price if final_for_destination?
158
-
159
- options = price_options.merge(tax_category: variant.tax_category)
160
- gross_amount(price, options)
137
+ # @deprecated Prices are charged as entered for every buyer since 6.0. Use {#price}; removed in 6.1.
138
+ def price_including_vat_for(_price_options = {})
139
+ Spree::Deprecation.warn('Spree::Price#price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #price instead.')
140
+ price
161
141
  end
162
142
 
163
- def compare_at_price_including_vat_for(price_options)
164
- return compare_at_price if final_for_destination?
165
-
166
- options = price_options.merge(tax_category: variant.tax_category)
167
- gross_amount(compare_at_price, options)
143
+ # @deprecated Use {#compare_at_price}; removed in 6.1.
144
+ def compare_at_price_including_vat_for(_price_options = {})
145
+ Spree::Deprecation.warn('Spree::Price#compare_at_price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #compare_at_price instead.')
146
+ compare_at_price
168
147
  end
169
148
 
170
- def display_price_including_vat_for(price_options)
171
- Spree::Money.new(price_including_vat_for(price_options), currency: currency)
149
+ # @deprecated Use {#display_price}; removed in 6.1.
150
+ def display_price_including_vat_for(_price_options = {})
151
+ Spree::Deprecation.warn('Spree::Price#display_price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #display_price instead.')
152
+ display_price
172
153
  end
173
154
 
174
- def display_compare_at_price_including_vat_for(price_options)
175
- Spree::Money.new(compare_at_price_including_vat_for(price_options), currency: currency)
155
+ # @deprecated Use {#display_compare_at_price}; removed in 6.1.
156
+ def display_compare_at_price_including_vat_for(_price_options = {})
157
+ Spree::Deprecation.warn('Spree::Price#display_compare_at_price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #display_compare_at_price instead.')
158
+ display_compare_at_price
176
159
  end
177
160
 
178
161
  # returns the name of the price in a format of variant name and currency
@@ -16,18 +16,6 @@ module Spree
16
16
  raise NotImplementedError, "#{self.class.name} must implement #applicable?"
17
17
  end
18
18
 
19
- # Whether this rule narrows its price list by geography. A list carrying one
20
- # states the price for that geography outright, so its prices are charged as
21
- # entered and never restated for the buyer's VAT rate
22
- # (Spree::VatPriceCalculation). A rule that narrows by anything else — a
23
- # quantity, a group, a channel — leaves its prices open to restatement, since
24
- # they were not set for a country.
25
- #
26
- # @return [Boolean]
27
- def geographic?
28
- false
29
- end
30
-
31
19
  # Whether this rule narrows by something about the purchase rather than
32
20
  # about the buyer. A catalog-owned price list has already been matched on
33
21
  # audience by the time the resolver holds it — the catalog assignment is
@@ -45,10 +33,9 @@ module Spree
45
33
  false
46
34
  end
47
35
 
48
- # Instance-side reader, so rules are classified the same way
49
- # `geographic?` classifies them (`rules.any?(&:geographic?)`). A kind
50
- # whose answer depends on how it is configured overrides this, exactly
51
- # as MarketRule does for `geographic?`.
36
+ # Instance-side reader, so rules can be classified per row
37
+ # (`price_rules.select(&:contextual?)`). A kind whose answer depends on how it is
38
+ # configured overrides this.
52
39
  #
53
40
  # @return [Boolean]
54
41
  def contextual?
@@ -23,12 +23,6 @@ module Spree
23
23
  preferred_market_ids.map(&:to_s).include?(context.market.id.to_s)
24
24
  end
25
25
 
26
- # An empty market list matches every buyer (see #applicable?), so it names
27
- # no geography — and that is the state a freshly created rule row is in.
28
- def geographic?
29
- preferred_market_ids.present?
30
- end
31
-
32
26
  def self.description
33
27
  'Apply pricing based on the market'
34
28
  end
@@ -6,7 +6,7 @@ module Spree
6
6
  # A provider returns a {Spree::Price}: {Internal} returns the persisted
7
7
  # catalog row, an external provider an **unsaved, readonly** instance. The
8
8
  # Active Record shape is deliberately the contract, so everything
9
- # downstream — tax-inclusive restatement, +discounted?+, the serializers —
9
+ # downstream — the line item, +discounted?+, the serializers —
10
10
  # keeps working without knowing where the number came from. +readonly!+ is
11
11
  # what stops an external answer being written back into the catalog.
12
12
  #
@@ -887,7 +887,7 @@ module Spree
887
887
  next unless variant.should_track_inventory?
888
888
  next if variant.sku.blank? && variant != default_variant
889
889
 
890
- variant.stock_levels.each do |stock_level|
890
+ variant.stock_levels.sort_by(&:id).each do |stock_level|
891
891
  location = stock_level.stock_location
892
892
  next if location.nil? || location == default_stock_location
893
893
  next unless location.store_id == store.id && location_owner_ids.include?(location.seller_id)
@@ -11,7 +11,7 @@ module Spree
11
11
  belongs_to :purchase_order, class_name: 'Spree::PurchaseOrder', inverse_of: :items, touch: true
12
12
 
13
13
  validates :quantity_ordered, numericality: { greater_than: 0, only_integer: true }
14
- validates :unit_cost, numericality: { greater_than_or_equal_to: 0 }
14
+ validates :unit_cost, presence: true, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
15
15
  validates :variant_id, uniqueness: { scope: :purchase_order_id }
16
16
 
17
17
  delegate :currency, to: :purchase_order, allow_nil: true
@@ -19,6 +19,7 @@ module Spree
19
19
  include Spree::SingleStoreResource
20
20
  include Spree::HasStatus
21
21
  include Spree::HasCustomFields
22
+ include Spree::HasExternalReferences
22
23
  include Spree::Metadata
23
24
  include Spree::PostSale::Taxation
24
25
 
@@ -0,0 +1,162 @@
1
+ module Spree
2
+ # Who buys, on which channel, in which market: what a buyer can be sold, and
3
+ # at what price, depends on all of these together. A market fixes the
4
+ # currency and the languages a sale may use.
5
+ class SalesContext
6
+ include ActiveModel::Model
7
+ include ActiveModel::Attributes
8
+
9
+ attribute :store
10
+ attribute :market
11
+ attribute :channel
12
+ attribute :customer
13
+ attribute :company
14
+ attribute :country
15
+ attribute :currency, :string
16
+ attribute :locale, :string
17
+ # A placed sale keeps the company it was stamped with, even none, so only
18
+ # a sale still being put together looks up the customer's company.
19
+ attribute :resolve_company, :boolean, default: true
20
+
21
+ validate :currency_sold_in_a_market
22
+ validate :currency_matches_market
23
+ validate :locale_supported_by_market
24
+
25
+ # @param order [Spree::Order]
26
+ # @return [Spree::SalesContext]
27
+ def self.from_order(order)
28
+ new(
29
+ store: order.store, market: order.market, channel: order.channel, customer: order.customer,
30
+ company: order.resolved_company, resolve_company: false, country: order.tax_country,
31
+ currency: order.currency, locale: order.locale
32
+ )
33
+ end
34
+
35
+ # The market chosen for the sale, else one selling the requested currency,
36
+ # else the store's default.
37
+ #
38
+ # @return [Spree::Market, nil]
39
+ def market
40
+ chosen = super
41
+ return chosen if chosen
42
+ return @resolved_market if defined?(@resolved_market)
43
+
44
+ @resolved_market = resolve_market
45
+ end
46
+
47
+ def channel
48
+ super || store&.default_channel
49
+ end
50
+
51
+ # @return [String] the requested currency, else the market's, else the store's
52
+ def currency
53
+ requested_currency || market&.currency || store&.default_currency
54
+ end
55
+
56
+ # @return [String, nil] the requested language, else the market's default
57
+ def locale
58
+ super.presence || market&.default_locale
59
+ end
60
+
61
+ # The company chosen for the sale, else the one the customer stands in
62
+ # alone — the same answer pricing and catalog visibility reach.
63
+ #
64
+ # @return [Spree::Company, nil]
65
+ def company
66
+ chosen = super
67
+ return chosen if chosen || !resolve_company
68
+ return @standing_company if defined?(@standing_company)
69
+
70
+ @standing_company =
71
+ if customer.is_a?(Spree.customer_class) && store
72
+ Spree::Company.sole_standing_for(store: store, customer: customer)
73
+ end
74
+ end
75
+
76
+ # Variants the buyer may order on the channel: published there and inside
77
+ # their catalogs.
78
+ #
79
+ # @return [ActiveRecord::Relation<Spree::Variant>]
80
+ def orderable_variants
81
+ @orderable_variants ||= Spree::Variant.for_products(
82
+ Spree.products_for_context_service.call(
83
+ store: store, channel: channel, customer: customer, company: company,
84
+ base: store.products.not_discontinued_on(channel)
85
+ ).value
86
+ )
87
+ end
88
+
89
+ # Orderable variants the buyer can be quoted in the sale's currency: a base
90
+ # price, or a price from a list that applies to them, so a product sold to
91
+ # a buyer only under their agreement is offered to that buyer alone.
92
+ #
93
+ # @return [ActiveRecord::Relation<Spree::Variant>]
94
+ def sellable_variants
95
+ prices = Spree::Price.where(variant_id: orderable_variants.select(:id), currency: currency)
96
+ .where.not(amount: nil)
97
+ .where(price_list_id: [nil, *applicable_price_list_ids])
98
+
99
+ orderable_variants.where(id: prices.select(:variant_id))
100
+ end
101
+
102
+ # @return [Spree::Pricing::Context] the context prices are resolved in
103
+ def pricing_context
104
+ @pricing_context ||= Spree::Pricing::Context.new(
105
+ currency: currency, store: store, country: country, market: market,
106
+ channel: channel, user: customer, company: company
107
+ )
108
+ end
109
+
110
+ private
111
+
112
+ def requested_currency
113
+ attributes['currency'].presence&.upcase
114
+ end
115
+
116
+ def resolve_market
117
+ return unless store
118
+
119
+ candidates = [(Spree::Current.market if Spree::Current.store == store), store.default_market].compact
120
+ return candidates.first unless requested_currency
121
+
122
+ candidates.find { |candidate| candidate.currency == requested_currency } ||
123
+ store.markets.find_by(currency: requested_currency)
124
+ end
125
+
126
+ # Catalog price lists the buyer's catalogs carry, and standalone lists
127
+ # whose rules match the sale, judged the way the internal pricing provider
128
+ # judges them.
129
+ def applicable_price_list_ids
130
+ @applicable_price_list_ids ||= begin
131
+ catalogs = Spree::Catalog.groups_for_buyer(store: store, customer: customer, company: company, channel: channel)
132
+ catalog_lists = catalogs.flatten.filter_map(&:price_list).select do |list|
133
+ list.currently_active? && list.contextual_rules_applicable?(pricing_context)
134
+ end
135
+ standalone_lists = Spree::PriceList.for_context(pricing_context).select { |list| list.applicable?(pricing_context) }
136
+
137
+ (catalog_lists + standalone_lists).map(&:id).uniq
138
+ end
139
+ end
140
+
141
+ def currency_sold_in_a_market
142
+ return if requested_currency.nil? || market || store.nil? || !store.markets.exists?
143
+
144
+ errors.add(:currency, :currency_not_supported_by_store, message: I18n.t('spree.currency_not_supported_by_store'))
145
+ end
146
+
147
+ def currency_matches_market
148
+ return if market.nil? || currency == market.currency
149
+
150
+ errors.add(
151
+ :currency, :currency_does_not_match_market,
152
+ message: I18n.t('spree.currency_does_not_match_market', currency: market.currency)
153
+ )
154
+ end
155
+
156
+ def locale_supported_by_market
157
+ return if market.nil? || locale.blank? || market.supported_locales_list.include?(locale)
158
+
159
+ errors.add(:locale, :locale_not_supported_by_market, message: I18n.t('spree.locale_not_supported_by_market'))
160
+ end
161
+ end
162
+ end
@@ -135,7 +135,7 @@ module Spree
135
135
  end
136
136
  }.join(',')
137
137
 
138
- scope.ransack({ s: ransack_sort }, auth_object: @audience).result
138
+ scope.ransack({ s: ransack_sort }, auth_object: @audience).result.with_translation_fallback_order
139
139
  end
140
140
  end
141
141