spree_core 6.0.0.rc4 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc7dbcacac8f33d70f236f52c52070ee495d7d4e366797055c17d4b119eec6d4
4
- data.tar.gz: 3a2e290f02c8d3c9f367c2b4d649d94f69e58449a3ca47179ea58480a3cb1375
3
+ metadata.gz: 9e7960e9257c5de15c7d9e69d8916c669c5ab082fccd02e17c5ee9c9d9d378c8
4
+ data.tar.gz: 3c160cd58beb2d3fc3c99bead42bc0d3f365952ae7c2b2c103d92004303f7d13
5
5
  SHA512:
6
- metadata.gz: ec7a1dc11ffe7aaa8b4cdd82e8e050f39d2d38fedb81c2b3a70901c3db5dbedd0b51bfad12164d2e28962b231ffff777c9ff22a884acc78e631e19a4741ab283
7
- data.tar.gz: 4e7f9a59e472892e07722d15a598ca76dc1257c76c1cc969c77dcba8a73b7d78ceccfe88a61c9aa2fb084f633064256df3493bc2b91e79cecb86a9ca6a545259
6
+ metadata.gz: b396fb4c3d553e372dab904f57cb794015b4c2670028fea33c775fc343ac844db1d0d2733a9c47cfb2450898408ad4696ba312114719f7b3d1ad55716149f674
7
+ data.tar.gz: 89e9eb2e33742ec34f5c89e5e732544a868e4241ba5886cfa357e0ca43929107d895024ff6e936952c3648cf51ac379e7839ac2d211dc2515697d2ff52d8e7a7
@@ -3,8 +3,8 @@ module Spree
3
3
  # The full address surface shared by Spree::Cart and Spree::Order:
4
4
  # the bill/ship associations and their public aliases, default filling
5
5
  # from the customer's saved addresses, address-row deduplication on
6
- # write, ownership-guarded address_id assignment, promotion of checkout
7
- # addresses to the customer's defaults, and the ship→bill cloning
6
+ # write, ownership-guarded address_id assignment and deletion, promotion
7
+ # of checkout addresses to the customer's defaults, and the ship→bill cloning
8
8
  # predicate. The shipping address is canonical: use_shipping copies it
9
9
  # onto the billing address; the reverse use_billing direction is a
10
10
  # deprecated 6.1-removal bridge. Clone callbacks are wired per model.
@@ -12,8 +12,13 @@ module Spree
12
12
  extend ActiveSupport::Concern
13
13
 
14
14
  included do
15
- belongs_to :bill_address, class_name: 'Spree::Address', optional: true, dependent: :destroy
16
- belongs_to :ship_address, class_name: 'Spree::Address', optional: true, dependent: :destroy
15
+ belongs_to :bill_address, class_name: 'Spree::Address', optional: true
16
+ belongs_to :ship_address, class_name: 'Spree::Address', optional: true
17
+
18
+ # Stands in for `dependent: :destroy`, which would take any attached
19
+ # row down with the purchase. See {#owns_address?} for why only some
20
+ # of them may go.
21
+ after_destroy :destroy_own_addresses
17
22
 
18
23
  # The writers these generate are shadowed by the hand-written ones
19
24
  # below, which deduplicate address rows. The declarations still earn
@@ -40,9 +45,12 @@ module Spree
40
45
  before_validation :clone_shipping_address, if: :use_shipping?
41
46
  end
42
47
 
48
+ # Whether both addresses are present and match. With neither present
49
+ # there is nothing to call the same.
50
+ #
43
51
  # @return [Boolean]
44
52
  def shipping_eq_billing_address?
45
- bill_address == ship_address
53
+ ship_address.present? && bill_address == ship_address
46
54
  end
47
55
 
48
56
  # Whether checkout must collect a customer shipping address. Selected
@@ -90,6 +98,68 @@ module Spree
90
98
  use_shipping.in?([true, 'true', '1'])
91
99
  end
92
100
 
101
+ # The address behind an id a client may select, or nil when the id names
102
+ # one this purchase has no business shipping to. Takes a prefixed id or a
103
+ # raw primary key, so the services that resolve a client-supplied id and
104
+ # the writers below share one rule rather than each inventing their own.
105
+ #
106
+ # Two books qualify. The buyer's own is the ordinary case. The company's
107
+ # is the B2B one: a purchase for a node ships to that node's sites, and
108
+ # to its ancestors' — the same self-and-ancestors chain the node's
109
+ # default address is prefilled from, so a division can pick the
110
+ # headquarters address it already inherits.
111
+ #
112
+ # @param id [Integer, String, nil]
113
+ # @return [Spree::Address, nil]
114
+ def selectable_address(id)
115
+ address = ::Spree::Address.not_deleted.find_by_param(id) || ::Spree::Address.not_deleted.find_by(id: id)
116
+ return if address.nil?
117
+
118
+ return address if customer_id.present? && address.customer_owned? && address.owner_id == customer_id
119
+
120
+ company = readable_company
121
+ return if company.nil? || address.owner_type != 'Spree::Company'
122
+
123
+ company.self_and_ancestors.any? { |node| node.id == address.owner_id } ? address : nil
124
+ end
125
+
126
+ # Everything a checkout address picker may offer for this purchase: the
127
+ # buyer's own book plus the book of the company it names, that node's
128
+ # own entries and the ones it inherits, limited to the countries the
129
+ # purchase's market sells to. A subset of what {#selectable_address}
130
+ # accepts, so a picker built from it never offers an address the
131
+ # checkout cannot deliver to.
132
+ #
133
+ # @return [ActiveRecord::Relation<Spree::Address>]
134
+ def selectable_addresses
135
+ books = []
136
+ books << ::Spree::Address.not_deleted.where(owner_type: ::Spree.customer_class.to_s, owner_id: customer_id) if customer_id.present?
137
+ books << readable_company&.address_book
138
+
139
+ addresses = books.compact.reduce { |left, right| left.or(right) } || ::Spree::Address.none
140
+ return addresses if market.nil?
141
+
142
+ addresses.where(country_code: market.market_countries.select(:country_code))
143
+ end
144
+
145
+ # Whether an attached address row is this purchase's own to delete.
146
+ #
147
+ # Only the ownerless rows are: the one a guest typed in, and the
148
+ # snapshot completion copies onto the order. Everything else is an entry
149
+ # in somebody's address book — the buyer's, a company node's, a seller's
150
+ # — which the purchase is merely pointing at. A cart may name a company
151
+ # site every other member ships from, so taking an attached row down
152
+ # with the cart would let one member empty the shared book.
153
+ #
154
+ # A business snapshot keeps its +owner_type+ to stay addressed to a
155
+ # company rather than to a person, so ownership is the id, not the type.
156
+ #
157
+ # @param address [Spree::Address, nil]
158
+ # @return [Boolean]
159
+ def owns_address?(address)
160
+ address.present? && address.owner_id.blank?
161
+ end
162
+
93
163
  # Fills any blank bill/ship address from the customer's valid saved
94
164
  # defaults. The ship address is skipped when no physical delivery is
95
165
  # required, so shipping-address validations never fire on digital-only
@@ -174,6 +244,26 @@ module Spree
174
244
 
175
245
  private
176
246
 
247
+ # The company whose book this purchase may read: only the one the
248
+ # client named, never one inferred from a sole membership. A cart asks
249
+ # again rather than trusting the column, which nothing clears when a
250
+ # membership is revoked; an order's authority is staff or a completed
251
+ # cart, so it keeps what it was given.
252
+ #
253
+ # @return [Spree::Company, nil]
254
+ def readable_company
255
+ return company if company.nil? || !is_a?(Spree::Cart)
256
+
257
+ company if customer&.standing_for?(company)
258
+ end
259
+
260
+ # Both slots often hold the same row, so destroy it once.
261
+ def destroy_own_addresses
262
+ [bill_address, ship_address].compact.uniq.each do |address|
263
+ address.destroy if owns_address?(address)
264
+ end
265
+ end
266
+
177
267
  def items_require_shipping_address?
178
268
  # Answered by the profile kind, resolved once per profile.
179
269
  profile_requires_address = Hash.new do |cache, profile|
@@ -187,33 +277,6 @@ module Spree
187
277
  end
188
278
  end
189
279
 
190
- # The address behind an id a client may select, or nil when the id names
191
- # one this purchase has no business shipping to.
192
- #
193
- # Two books qualify. The buyer's own is the ordinary case. The company's
194
- # is the B2B one: a purchase for a node ships to that node's sites, and
195
- # to its ancestors' — the same self-and-ancestors chain the node's
196
- # default address is prefilled from, so a division can pick the
197
- # headquarters address it already inherits.
198
- #
199
- # Standing is not re-checked here: a cart cannot name a company its
200
- # customer lacks standing over, so reaching this point already means the
201
- # buyer may act for the node.
202
- #
203
- # @param id [Integer, String, nil]
204
- # @return [Spree::Address, nil]
205
- def selectable_address(id)
206
- address = ::Spree::Address.find_by(id: id)
207
- return nil if address.nil?
208
-
209
- return address if customer_id.present? && address.customer_owned? && address.owner_id == customer_id
210
-
211
- company = resolved_company
212
- return nil if company.nil? || address.owner_type != 'Spree::Company'
213
-
214
- company.self_and_ancestors.any? { |node| node.id == address.owner_id } ? address : nil
215
- end
216
-
217
280
  # Reuse never reaches past what the buyer already owns: a signed-in buyer
218
281
  # may name any entry of their own address book, and a guest — who keeps
219
282
  # no book — may only edit the row already in the slot being written.
@@ -32,6 +32,7 @@ module Spree
32
32
  reset_token
33
33
  unsubscribe_token
34
34
  verification_token
35
+ confirmation_token
35
36
  download_url
36
37
  external_client_secret
37
38
  client_secret
@@ -167,6 +167,9 @@ module Spree
167
167
 
168
168
  # Reference data every staff member can read regardless of keys — address
169
169
  # forms need countries/states, and the dashboard shell reads the store.
170
+ # The back office also reads the store's markets and channels: its
171
+ # currencies and languages come from the markets, and list filters offer
172
+ # both.
170
173
  #
171
174
  # The store grant is unrestricted only for the store's own back office.
172
175
  # Another panel reads the one store it operates under and no other, so a
@@ -180,6 +183,11 @@ module Spree
180
183
  else
181
184
  can :read, Spree::Store, id: @store.id
182
185
  end
186
+
187
+ return unless store_resource?
188
+
189
+ can :read, Spree::Market, store_id: @resource.id
190
+ can :read, Spree::Channel, store_id: @resource.id
183
191
  end
184
192
 
185
193
  end
@@ -343,6 +343,12 @@ module Spree
343
343
  def snapshot
344
344
  dup.tap do |copy|
345
345
  copy.owner = nil
346
+
347
+ if business_owned?
348
+ copy.company = company.presence || owner&.name
349
+ copy.owner_type = owner_type
350
+ end
351
+
346
352
  copy.label = nil
347
353
  copy.deleted_at = nil
348
354
  end
@@ -284,12 +284,9 @@ module Spree
284
284
  #
285
285
  # @return [ActiveRecord::Relation<Spree::Variant>]
286
286
  def orderable_variants
287
- products = Spree.products_for_context_service.call(
288
- store: store, channel: channel, customer: customer, company: resolved_company,
289
- base: store.products.not_discontinued_on(channel)
290
- ).value
291
-
292
- Spree::Variant.for_products(products)
287
+ Spree::SalesContext.new(
288
+ store: store, channel: channel, customer: customer, company: resolved_company, resolve_company: false
289
+ ).orderable_variants
293
290
  end
294
291
 
295
292
  # Removes out-of-stock/discontinued items and populates warnings
@@ -245,7 +245,7 @@ module Spree
245
245
  #
246
246
  # @return [ActiveRecord::Relation]
247
247
  def address_book
248
- Spree::Address.where(owner_type: 'Spree::Company', owner_id: self_and_ancestors.map(&:id))
248
+ Spree::Address.not_deleted.where(owner_type: 'Spree::Company', owner_id: self_and_ancestors.map(&:id))
249
249
  end
250
250
 
251
251
  private
@@ -20,9 +20,12 @@ module Spree
20
20
 
21
21
  publishes_lifecycle_events
22
22
  publishes_event :completed
23
+ publishes_event :confirmation_requested,
24
+ serializer: 'Spree::Api::V3::DataRequestConfirmationRequestedEventSerializer',
25
+ credential: 'write_customers'
23
26
 
24
27
  include Spree::HasStatus
25
- has_status :pending, :processing, :completed, :failed, default: :pending
28
+ has_status :awaiting_confirmation, :pending, :processing, :completed, :failed, :lapsed, default: :pending
26
29
 
27
30
  ACCESS = 'access'.freeze
28
31
  ERASURE = 'erasure'.freeze
@@ -33,11 +36,23 @@ module Spree
33
36
  # about them is not left lying around.
34
37
  DEFAULT_EXPIRY = 7.days
35
38
 
39
+ # How long a request a customer raised waits for confirmation from its
40
+ # email address before it lapses.
41
+ CONFIRMATION_WINDOW = 24.hours
42
+
36
43
  # Addresses the finished export. Plaintext like the digital-link token:
37
44
  # the URL is the credential, it is only ever sent to the address that
38
45
  # asked, and it stops working when the request expires.
39
46
  has_secure_token :download_token
40
47
 
48
+ # Proves that whoever confirms reads mail at the address the request
49
+ # names. Bound to
50
+ # the download token, so an erasure, which clears it, voids every link
51
+ # already sent.
52
+ generates_token_for :confirmation do
53
+ download_token
54
+ end
55
+
41
56
  belongs_to :customer, class_name: Spree.customer_class.to_s
42
57
  # Null when the subject asked for it themselves; set when staff acted on a
43
58
  # request that arrived by email.
@@ -52,6 +67,8 @@ module Spree
52
67
  scope :access, -> { where(kind: ACCESS) }
53
68
  scope :erasure, -> { where(kind: ERASURE) }
54
69
  scope :in_progress, -> { with_status(:pending, :processing) }
70
+ scope :confirmable, -> { awaiting_confirmation.where(requested_at: CONFIRMATION_WINDOW.ago..) }
71
+ scope :past_confirmation_window, -> { awaiting_confirmation.where(requested_at: ...CONFIRMATION_WINDOW.ago) }
55
72
  scope :recent_first, -> { order(requested_at: :desc) }
56
73
 
57
74
  self.whitelisted_ransackable_attributes = %w[number kind status email requested_at completed_at]
@@ -89,6 +106,22 @@ module Spree
89
106
  expires_at.present? && expires_at.past?
90
107
  end
91
108
 
109
+ # Whether the request is still waiting for confirmation, within the window
110
+ # to receive it, and still names the account's address.
111
+ # @return [Boolean]
112
+ def confirmable?
113
+ awaiting_confirmation? && requested_at > CONFIRMATION_WINDOW.ago && addressed_to_customer?
114
+ end
115
+
116
+ # Whether the request names the address the account has now. The work
117
+ # matches guest rows by the account's address when it runs, so a request
118
+ # for an address the account has since left would reach rows it never
119
+ # named.
120
+ # @return [Boolean]
121
+ def addressed_to_customer?
122
+ customer.present? && email.to_s.casecmp?(customer.email.to_s)
123
+ end
124
+
92
125
  private
93
126
 
94
127
  def set_defaults
@@ -152,7 +152,7 @@ module Spree
152
152
  self.whitelisted_ransackable_attributes = %w[
153
153
  completed_at email number status payment_status payment_state fulfillment_status shipment_state delivery_total
154
154
  total item_total total_quantity considered_risky channel_id currency coupon_code customer_id seller_id
155
- order_group_id po_number
155
+ order_group_id po_number market_id locale
156
156
  ]
157
157
  self.whitelisted_ransackable_scopes = %w[complete incomplete refunded partially_refunded search]
158
158
  self.ransackable_scope_types = %w[complete incomplete refunded partially_refunded].index_with('boolean')
@@ -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
@@ -22,10 +22,11 @@ module Spree
22
22
  # core doesn't know about registers its reason here. The bar for a new
23
23
  # value: it distinguishes a treatment that reporting or invoicing has to
24
24
  # tell apart, not merely a different amount.
25
- class_attribute :taxability_reasons,
26
- default: %w[standard_rated reduced_rated zero_rated reverse_charge
27
- intra_community_supply export customer_exempt product_exempt
28
- not_collecting not_subject_to_tax]
25
+ BUILT_IN_TAXABILITY_REASONS = %w[standard_rated reduced_rated zero_rated reverse_charge
26
+ intra_community_supply export customer_exempt product_exempt
27
+ not_collecting not_subject_to_tax].freeze
28
+
29
+ class_attribute :taxability_reasons, default: BUILT_IN_TAXABILITY_REASONS.dup
29
30
 
30
31
  # Provider payloads (jurisdiction breakdowns, external ids) read nil-safe.
31
32
  attribute :data, default: -> { {} }
@@ -52,9 +52,14 @@ module Spree
52
52
  success(cart: cart)
53
53
  end
54
54
 
55
+ # Lets go of everything the cart was only borrowing, so the cascade that
56
+ # follows reaches nothing but the cart's own rows. An entry in somebody's
57
+ # address book stays in it, and a row a fulfillment or a completed order
58
+ # still points at is detached rather than soft-deleted out from under
59
+ # them.
55
60
  def clear_addresses(cart:)
56
- cart.ship_address = nil unless cart.ship_address&.can_be_deleted?
57
- cart.bill_address = nil unless cart.bill_address&.can_be_deleted?
61
+ cart.ship_address = nil unless cart.owns_address?(cart.ship_address) && cart.ship_address.can_be_deleted?
62
+ cart.bill_address = nil unless cart.owns_address?(cart.bill_address) && cart.bill_address.can_be_deleted?
58
63
 
59
64
  success(cart: cart)
60
65
  end
@@ -206,10 +206,7 @@ module Spree
206
206
  end
207
207
 
208
208
  def resolve_address_id(prefixed_id)
209
- return unless cart.customer
210
-
211
- decoded = Spree::Address.decode_prefixed_id(prefixed_id)
212
- decoded ? cart.customer.addresses.find_by(id: decoded)&.id : nil
209
+ cart.selectable_address(prefixed_id)&.id
213
210
  end
214
211
 
215
212
  def assign_market
@@ -0,0 +1,37 @@
1
+ module Spree
2
+ module DataRequests
3
+ # Confirms a request the customer raised, from the link emailed to the
4
+ # address it names, and queues the work.
5
+ #
6
+ # Confirming again is answered with the request as it stands, so a second
7
+ # click on the same link changes nothing.
8
+ class Confirm
9
+ prepend Spree::ServiceModule::Base
10
+
11
+ # @param data_request [Spree::DataRequest]
12
+ # @return [Spree::ServiceModule::Result] the confirmed request
13
+ def call(data_request:)
14
+ newly_confirmed = false
15
+
16
+ data_request.with_lock do
17
+ newly_confirmed = data_request.confirmable?
18
+
19
+ if newly_confirmed
20
+ data_request.update!(status: 'pending')
21
+ elsif data_request.awaiting_confirmation?
22
+ data_request.update!(status: 'lapsed')
23
+ end
24
+ end
25
+
26
+ if data_request.lapsed?
27
+ data_request.errors.add(:base, I18n.t('spree.data_request_errors.confirmation_invalid'))
28
+ return failure(data_request)
29
+ end
30
+
31
+ Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id) if newly_confirmed
32
+
33
+ success(data_request)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -7,9 +7,15 @@ module Spree
7
7
  # whole history, and a customer holding the button down should not be able
8
8
  # to queue a hundred of them. It is also the honest answer — the request
9
9
  # they already made is still being worked on.
10
+ #
11
+ # A request the customer raised themselves waits for confirmation from its
12
+ # email address instead of being queued, because it reaches guest orders
13
+ # placed at that address. Asking again while it waits sends the email again.
10
14
  class Create
11
15
  prepend Spree::ServiceModule::Base
12
16
 
17
+ CONFIRMATION_EVENT = 'data_request.confirmation_requested'.freeze
18
+
13
19
  # @param store [Spree::Store]
14
20
  # @param customer [Spree::Customer]
15
21
  # @param kind [String] Spree::DataRequest::ACCESS or ERASURE
@@ -19,8 +25,12 @@ module Spree
19
25
  # caller fulfils the request itself — the admin export renders the file
20
26
  # inline for a waiting staffer, and queueing would additionally email
21
27
  # the customer a copy of something they did not ask for
28
+ # @param confirm_by_email [Boolean] hold the request until it is
29
+ # confirmed from its email address; true for the customer's own requests
30
+ # @param redirect_url [String, nil] the storefront page the confirmation
31
+ # link opens, already checked against the store's allowed origins
22
32
  # @return [Spree::ServiceModule::Result]
23
- def call(store:, customer:, kind:, requested_by: nil, process: true)
33
+ def call(store:, customer:, kind:, requested_by: nil, process: true, confirm_by_email: false, redirect_url: nil)
24
34
  # Nothing useful is left to answer with: an export would return the
25
35
  # tombstone and email a link to an address that no longer receives,
26
36
  # and a second erasure has nothing to erase.
@@ -38,7 +48,9 @@ module Spree
38
48
  # of the same person's whole history; taking the lock closes it without
39
49
  # a partial unique index, which MySQL does not support.
40
50
  customer.with_lock do
41
- existing = in_flight_request(store: store, customer: customer, kind: kind)
51
+ lapse_unconfirmed_requests(store: store, customer: customer, kind: kind, staff_request: !confirm_by_email)
52
+ existing = in_flight_request(store: store, customer: customer, kind: kind,
53
+ include_unconfirmed: confirm_by_email)
42
54
 
43
55
  unless existing
44
56
  data_request = Spree::DataRequest.new(
@@ -46,20 +58,27 @@ module Spree
46
58
  customer: customer,
47
59
  kind: kind.to_s,
48
60
  email: customer.email,
49
- requested_by: requested_by
61
+ requested_by: requested_by,
62
+ status: confirm_by_email ? 'awaiting_confirmation' : 'pending'
50
63
  )
51
64
  data_request.save
52
65
  end
53
66
  end
54
67
 
55
- return success(existing) if existing
56
- return failure(data_request) unless data_request.persisted?
68
+ return failure(data_request) if data_request && !data_request.persisted?
57
69
 
58
- # Enqueued after the lock is released: the job may start immediately,
59
- # and it should not contend for the row this flow still holds.
60
- Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id) if process
70
+ request = existing || data_request
61
71
 
62
- success(data_request)
72
+ # Both happen after the lock is released: the job may start at once and
73
+ # should not contend for the row this flow holds, and the email must
74
+ # not go out before the row is committed.
75
+ if request.awaiting_confirmation?
76
+ request_confirmation(request, redirect_url: redirect_url)
77
+ elsif data_request && process
78
+ Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id)
79
+ end
80
+
81
+ success(request)
63
82
  end
64
83
 
65
84
  private
@@ -67,10 +86,39 @@ module Spree
67
86
  # Only the person's own requests. A staff member exporting from the admin
68
87
  # opens their own row and answers it inline, and handing that back here
69
88
  # would close the customer's request with a file they can never download.
70
- def in_flight_request(store:, customer:, kind:)
71
- Spree::DataRequest.where(store_id: store.id, customer_id: customer.id, kind: kind.to_s,
72
- requested_by_id: nil).
73
- in_progress.recent_first.first
89
+ # A request still waiting for confirmation is returned only to the
90
+ # customer: staff have checked who they are dealing with, so theirs
91
+ # proceeds on its own row (see lapse_unconfirmed_requests).
92
+ def in_flight_request(store:, customer:, kind:, include_unconfirmed:)
93
+ requests = Spree::DataRequest.where(store_id: store.id, customer_id: customer.id, kind: kind.to_s,
94
+ requested_by_id: nil)
95
+ open_requests = include_unconfirmed ? requests.in_progress.or(requests.confirmable) : requests.in_progress
96
+
97
+ open_requests.recent_first.first
98
+ end
99
+
100
+ # Lapses what can no longer be confirmed: requests past the window, and
101
+ # requests naming an address the account has since moved away from, so
102
+ # asking again emails the current address. A staff request of the same
103
+ # kind replaces the customer's unconfirmed one, which would otherwise
104
+ # run the same work a second time once confirmed.
105
+ def lapse_unconfirmed_requests(store:, customer:, kind:, staff_request:)
106
+ unconfirmed = Spree::DataRequest.where(store_id: store.id, customer_id: customer.id).awaiting_confirmation
107
+ stale = unconfirmed.past_confirmation_window.or(unconfirmed.where.not(email: customer.email))
108
+ stale = stale.or(unconfirmed.where(kind: kind.to_s)) if staff_request
109
+
110
+ stale.find_each { |data_request| data_request.update!(status: 'lapsed') }
111
+ end
112
+
113
+ # The token travels in a credential event, which reaches only webhook
114
+ # endpoints that name it and keeps the token out of the delivery log.
115
+ def request_confirmation(data_request, redirect_url:)
116
+ payload = data_request.event_payload_for(
117
+ CONFIRMATION_EVENT,
118
+ confirmation_token: data_request.generate_token_for(:confirmation),
119
+ redirect_url: redirect_url.presence
120
+ )
121
+ data_request.publish_event(CONFIRMATION_EVENT, payload)
74
122
  end
75
123
  end
76
124
  end
@@ -212,9 +212,15 @@ module Spree
212
212
  # redacting that would overwrite the shared address for every other
213
213
  # member and every other order. Only rows this person owns, or rows
214
214
  # nobody owns, are this erasure's to rewrite.
215
+ #
216
+ # Ownership is the id alone, never the type. A snapshot taken from a
217
+ # company site keeps its owner_type so the copy stays addressed to a
218
+ # business rather than to a person, so matching on the type too would
219
+ # skip exactly those copies — and an erasure that quietly covers less
220
+ # than it did raises nothing.
215
221
  snapshots = Spree::Address.where(id: address_ids)
216
222
  mine = snapshots.where(owner_type: customer.class.base_class.to_s, owner_id: customer.id)
217
- unowned = snapshots.where(owner_type: nil, owner_id: nil)
223
+ unowned = snapshots.where(owner_id: nil)
218
224
 
219
225
  mine.or(unowned).find_each do |address|
220
226
  redact_address(address, deleted: false)
@@ -407,7 +413,9 @@ module Spree
407
413
  end
408
414
 
409
415
  # The token goes too, so a link already emailed stops resolving rather
410
- # than waiting out its expiry.
416
+ # than waiting out its expiry. Confirmation links are bound to it, so
417
+ # a request still waiting for one can never be confirmed.
418
+ requests.awaiting_confirmation.update_all(status: 'lapsed', updated_at: Time.current)
411
419
  requests.update_all(
412
420
  email: customer.email,
413
421
  download_token: nil,
@@ -458,22 +466,35 @@ module Spree
458
466
 
459
467
  # @param address [Spree::Address]
460
468
  # @param deleted [Boolean] whether to soft-delete it as well
469
+ # What the person left on an address, rewritten; the address itself kept
470
+ # readable enough to answer a tax enquiry.
471
+ #
472
+ # A snapshot copied from a company site is the narrower case. The dock
473
+ # and the trading name on it are the business's, not this person's, and
474
+ # erasing them would take the merchant's record of a lawful sale with
475
+ # them — so only the contact details go. A business address is allowed
476
+ # to name nobody, which is why those fields are emptied rather than
477
+ # stamped: writing "Redacted" where a company never named anyone would
478
+ # invent a person who was not there.
461
479
  def redact_address(address, deleted:)
462
- attributes = {
463
- first_name: REDACTED_NAME,
464
- last_name: REDACTED_NAME,
465
- address1: REDACTED_NAME,
466
- address2: nil,
467
- phone: nil,
468
- alternative_phone: nil,
469
- company: nil,
470
- label: nil,
471
- latitude: nil,
472
- longitude: nil,
473
- postal_code: truncated_postal_code(address.postal_code),
474
- metadata: {},
475
- updated_at: Time.current
476
- }
480
+ attributes =
481
+ if address.business_owned?
482
+ { first_name: nil, last_name: nil }
483
+ else
484
+ {
485
+ first_name: REDACTED_NAME,
486
+ last_name: REDACTED_NAME,
487
+ address1: REDACTED_NAME,
488
+ address2: nil,
489
+ company: nil,
490
+ label: nil,
491
+ latitude: nil,
492
+ longitude: nil,
493
+ postal_code: truncated_postal_code(address.postal_code)
494
+ }
495
+ end
496
+
497
+ attributes.merge!(phone: nil, alternative_phone: nil, metadata: {}, updated_at: Time.current)
477
498
  attributes[:deleted_at] = Time.current if deleted && address.deleted_at.nil?
478
499
 
479
500
  address.update_columns(attributes)
@@ -36,6 +36,7 @@ module Spree
36
36
  super
37
37
 
38
38
  step :ensure_pending
39
+ step :ensure_addressed_to_customer
39
40
  step :mark_processing
40
41
 
41
42
  run_hooks :before_fulfill
@@ -56,6 +57,15 @@ module Spree
56
57
  failure(data_request, I18n.t('spree.data_request_errors.not_pending'))
57
58
  end
58
59
 
60
+ # An account already erased has a replaced address by design, and
61
+ # erase_customer counts that as done.
62
+ def ensure_addressed_to_customer
63
+ customer = data_request.customer
64
+ return if customer.nil? || customer.anonymized? || data_request.addressed_to_customer?
65
+
66
+ failure(data_request, I18n.t('spree.data_request_errors.address_changed'))
67
+ end
68
+
59
69
  def mark_processing
60
70
  data_request.update!(status: 'processing')
61
71
  end
@@ -78,21 +88,25 @@ module Spree
78
88
  # anonymizer starts after the check and finishes its attachment scan
79
89
  # before this attaches, stranding a full personal-data file it will
80
90
  # never see.
91
+ #
92
+ # The address is checked again under the same lock, so it cannot change
93
+ # between the check and the export reading it.
81
94
  return attach_export unless customer
82
95
 
83
- refused = false
96
+ refusal = nil
84
97
 
85
98
  customer.with_lock do
86
- if customer.reload.anonymized?
87
- refused = true
99
+ customer.reload
100
+ if customer.anonymized?
101
+ refusal = 'spree.data_request_errors.already_anonymized'
102
+ elsif !data_request.addressed_to_customer?
103
+ refusal = 'spree.data_request_errors.address_changed'
88
104
  else
89
105
  attach_export
90
106
  end
91
107
  end
92
108
 
93
- return unless refused
94
-
95
- failure(data_request, I18n.t('spree.data_request_errors.already_anonymized'))
109
+ failure(data_request, I18n.t(refusal)) if refusal
96
110
  end
97
111
 
98
112
  def attach_export
@@ -28,6 +28,14 @@ module Spree
28
28
 
29
29
  return failure(:store_is_required) if store.nil?
30
30
 
31
+ @sales_context = Spree::SalesContext.new(
32
+ store: store,
33
+ market: (resolve_market if @params[:market_id].present?),
34
+ currency: @params[:currency],
35
+ locale: @params[:locale]
36
+ )
37
+ return refuse_sales_context unless @sales_context.valid?
38
+
31
39
  order = nil
32
40
  ApplicationRecord.transaction do
33
41
  order = build_order
@@ -60,8 +68,9 @@ module Spree
60
68
  customer: @customer,
61
69
  created_by: @created_by,
62
70
  email: @params[:email] || @customer&.email,
63
- currency: @params[:currency].presence&.upcase || @store.default_currency,
64
- locale: @params[:locale] || Spree::Current.locale,
71
+ market: @sales_context.market,
72
+ currency: @sales_context.currency,
73
+ locale: @sales_context.locale || Spree::Current.locale,
65
74
  customer_note: @params[:customer_note],
66
75
  po_number: @params[:po_number],
67
76
  internal_note: @params[:internal_note],
@@ -74,7 +83,6 @@ module Spree
74
83
  # signed id, so it assigns like any other attribute.
75
84
  attrs[:po_document_signed_id] = @params[:po_document_signed_id] if @params[:po_document_signed_id].present?
76
85
 
77
- attrs[:market] = resolve_market if @params[:market_id].present?
78
86
  attrs[:channel] = resolve_channel if @params[:channel_id].present?
79
87
  attrs[:company] = resolve_company if @params[:company_id].present?
80
88
  attrs[:preferred_stock_location] = resolve_preferred_stock_location if @params[:preferred_stock_location_id].present?
@@ -83,6 +91,12 @@ module Spree
83
91
  @store.orders.new(attrs)
84
92
  end
85
93
 
94
+ def refuse_sales_context
95
+ order = @store.orders.new
96
+ order.errors.merge!(@sales_context.errors)
97
+ failure(order, order.errors.full_messages.to_sentence)
98
+ end
99
+
86
100
  def resolve_market
87
101
  @store.markets.find_by_param!(@params[:market_id])
88
102
  end
@@ -100,34 +114,44 @@ module Spree
100
114
  end
101
115
 
102
116
  def assign_addresses(order)
103
- if @params[:use_customer_default_address] && @customer
104
- @customer.association(:bill_address).load_target
105
- @customer.association(:ship_address).load_target
106
- order.bill_address = @customer.bill_address&.snapshot
107
- order.ship_address = @customer.ship_address&.snapshot
108
- end
117
+ assign_default_addresses(order) if @params[:use_customer_default_address]
109
118
 
110
119
  assign_address(order, :ship_address, @params[:shipping_address_id], @params[:shipping_address])
111
120
  assign_address(order, :bill_address, @params[:billing_address_id], @params[:billing_address])
121
+ order.use_shipping = @params[:use_shipping]
122
+ end
123
+
124
+ def assign_default_addresses(order)
125
+ company = order.company
126
+
127
+ if company.present?
128
+ order.bill_address = company.default_billing_address&.snapshot
129
+ order.ship_address = company.default_shipping_address&.snapshot
130
+ return
131
+ end
132
+
133
+ return if @customer.nil?
134
+
135
+ @customer.association(:bill_address).load_target
136
+ @customer.association(:ship_address).load_target
137
+ order.bill_address = @customer.bill_address&.snapshot
138
+ order.ship_address = @customer.ship_address&.snapshot
112
139
  end
113
140
 
114
141
  def assign_address(order, association, address_id, address_attrs)
115
142
  if address_id.present?
116
- address = resolve_user_address(address_id)
143
+ address = order.selectable_address(address_id)
117
144
  order.public_send(:"#{association}_id=", address.id) if address
118
145
  elsif address_attrs.present?
119
146
  order.public_send(:"#{association}_attributes=", address_attrs)
120
147
  end
121
148
  end
122
149
 
123
- def resolve_user_address(address_id)
124
- return unless @customer
125
-
126
- @customer.addresses.find_by_param(address_id)
127
- end
128
-
129
150
  def add_items(order)
130
- result = Spree::Orders::UpsertItems.call(order: order, items: @params[:items])
151
+ result = Spree::Orders::UpsertItems.call(
152
+ order: order, items: @params[:items],
153
+ orderable_variants: Spree::SalesContext.from_order(order).orderable_variants
154
+ )
131
155
  return if result.success?
132
156
 
133
157
  propagate_step_failure!(order, result, fallback: 'Failed to add items to order')
@@ -765,6 +765,7 @@ en:
765
765
  created_at: Created at
766
766
  credit_card: Credit Card
767
767
  currency: Currency
768
+ currency_does_not_match_market: must be %{currency}, the currency of the chosen market
768
769
  currency_not_supported_by_store: is not supported by this store
769
770
  custom_field_definitions: Custom Field Definitions
770
771
  custom_fields: Custom Fields
@@ -773,7 +774,9 @@ en:
773
774
  anonymize_failed: The erasure could not be completed and nothing was changed
774
775
  customers: Customers
775
776
  data_request_errors:
777
+ address_changed: The account's email address changed after this request was made
776
778
  already_anonymized: This customer's personal data has already been erased
779
+ confirmation_invalid: This confirmation link has expired or is no longer valid
777
780
  download_unavailable: This download link has expired or is no longer valid
778
781
  not_pending: This request has already been processed
779
782
  default: Default
@@ -1016,6 +1019,7 @@ en:
1016
1019
  line_items: Line items
1017
1020
  link: Link
1018
1021
  locale: Locale
1022
+ locale_not_supported_by_market: is not offered by the chosen market
1019
1023
  locale_not_supported_by_store: is not supported by this store
1020
1024
  locales_not_listed: 'includes locales Spree does not support: %{codes}'
1021
1025
  manual: Manual
@@ -506,6 +506,7 @@ module Spree
506
506
  Spree::Address,
507
507
  Spree::Claim,
508
508
  Spree::Collection,
509
+ Spree::Company,
509
510
  Spree::CreditCard,
510
511
  Spree::Exchange,
511
512
  Spree::GiftCard,
@@ -521,6 +522,7 @@ module Spree
521
522
  Spree::Product,
522
523
  Spree::ProductType,
523
524
  Spree::Promotion,
525
+ Spree::PurchaseOrder,
524
526
  Spree::Refund,
525
527
  Spree::Return,
526
528
  Spree::Fulfillment,
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '6.0.0.rc4'.freeze
2
+ VERSION = '6.0.0.rc5'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
@@ -9,6 +9,10 @@ FactoryBot.define do
9
9
  kind { Spree::DataRequest::ERASURE }
10
10
  end
11
11
 
12
+ trait :awaiting_confirmation do
13
+ status { 'awaiting_confirmation' }
14
+ end
15
+
12
16
  trait :completed do
13
17
  status { 'completed' }
14
18
  completed_at { Time.current }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0.rc4
4
+ version: 6.0.0.rc5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Schofield
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-10-19 00:00:00.000000000 Z
13
+ date: 2026-10-22 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -1193,6 +1193,7 @@ files:
1193
1193
  - app/models/spree/rich_text_sanitizer.rb
1194
1194
  - app/models/spree/role.rb
1195
1195
  - app/models/spree/role_user.rb
1196
+ - app/models/spree/sales_context.rb
1196
1197
  - app/models/spree/saved_report.rb
1197
1198
  - app/models/spree/search_provider.rb
1198
1199
  - app/models/spree/search_provider/base.rb
@@ -1358,6 +1359,7 @@ files:
1358
1359
  - app/services/spree/coupon_codes/bulk_generate.rb
1359
1360
  - app/services/spree/coupon_codes/coupon_codes_handler.rb
1360
1361
  - app/services/spree/customers/data_export.rb
1362
+ - app/services/spree/data_requests/confirm.rb
1361
1363
  - app/services/spree/data_requests/create.rb
1362
1364
  - app/services/spree/deliveries/create.rb
1363
1365
  - app/services/spree/deliveries/destroy.rb
@@ -2369,9 +2371,9 @@ licenses:
2369
2371
  - BSD-3-Clause
2370
2372
  metadata:
2371
2373
  bug_tracker_uri: https://github.com/spree/spree/issues
2372
- changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.0.rc4
2374
+ changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.0.rc5
2373
2375
  documentation_uri: https://docs.spreecommerce.org/
2374
- source_code_uri: https://github.com/spree/spree/tree/v6.0.0.rc4
2376
+ source_code_uri: https://github.com/spree/spree/tree/v6.0.0.rc5
2375
2377
  post_install_message:
2376
2378
  rdoc_options: []
2377
2379
  require_paths: