spree_core 5.6.0.rc2 → 5.6.0.rc4

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/rich_text_helper.rb +37 -0
  3. data/app/jobs/spree/imports/process_group_job.rb +46 -0
  4. data/app/models/concerns/spree/publishable.rb +29 -1
  5. data/app/models/concerns/spree/webhook_payload_redaction.rb +88 -0
  6. data/app/models/spree/address.rb +7 -0
  7. data/app/models/spree/api_key.rb +22 -0
  8. data/app/models/spree/calculator/default_tax.rb +31 -2
  9. data/app/models/spree/channel.rb +20 -2
  10. data/app/models/spree/line_item.rb +23 -0
  11. data/app/models/spree/order.rb +10 -0
  12. data/app/models/spree/shipment.rb +9 -0
  13. data/app/models/spree/tax_rate.rb +7 -0
  14. data/app/models/spree/webhook_delivery.rb +22 -0
  15. data/app/presenters/spree/data_feeds/google_presenter.rb +28 -1
  16. data/app/services/spree/addresses/update.rb +32 -25
  17. data/app/services/spree/sample_data/loader.rb +7 -0
  18. data/app/services/spree/seeds/all.rb +2 -0
  19. data/app/services/spree/seeds/api_keys.rb +8 -4
  20. data/app/services/spree/seeds/channels.rb +25 -0
  21. data/app/services/spree/seeds/customer_groups.rb +18 -0
  22. data/config/locales/en.yml +4 -0
  23. data/db/migrate/20260719000001_add_channel_id_to_spree_api_keys.rb +5 -0
  24. data/db/sample_data/channels.rb +12 -1
  25. data/db/sample_data/wholesale.rb +59 -0
  26. data/lib/generators/spree/dummy/templates/rails/database.yml +1 -0
  27. data/lib/spree/core/version.rb +1 -1
  28. data/lib/spree/events.rb +31 -7
  29. data/lib/spree/permitted_attributes.rb +1 -1
  30. data/lib/spree/testing_support/lifecycle_events.rb +12 -0
  31. metadata +10 -5
  32. data/app/services/spree/products/prepare_nested_attributes.rb +0 -265
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df2eef5a82cf40bca9b5c518b663153584a72dc1dadf05880a5c2ce344766394
4
- data.tar.gz: 48771116e279ccb8c5ed4f83896a60e8063b74cb8785ff51953c9d8446a804f2
3
+ metadata.gz: 81bf3080a8d43c9bc6a6d76561c9fae751c9282a1e8b30dfb388ca6abe2511d7
4
+ data.tar.gz: 423cf415d52c180297db3e26b581ea4078cfcf0e7816fc3084d12034b3d74a87
5
5
  SHA512:
6
- metadata.gz: 3a27318377cb47ae53d0405a9a034642119446d4507f03c68966cd550cf41430dcdb0cc3ff7c67f8bf1435b695816c26f92b73e525f2ead42eaf83070c019f40
7
- data.tar.gz: 1ac22bf54e39352eede750b630b7e2d6ca6a0d4713c776f0efd20e9cf552f3689b24b154c0694db3ae0edabba37a0bc1c7ad91aa5a0e84cca62aa5cc459d48f7
6
+ metadata.gz: 351598372ce3b7d938815929ac76d050a45679aad1a4734c07a5642f932b81e0e688e0903538e2446c30516545083d2c6d52963b82598650d6e998606aeb8341
7
+ data.tar.gz: 258324d4c6e23afbe5702782daf6e19811af60136b4f93bbd99d27824830ee7211426fde12f1330175132b4c82c281cfc6295ca0d7cd6f2cc400cce5e3f7e729
@@ -0,0 +1,37 @@
1
+ module Spree
2
+ # Converts stored rich text HTML (Tiptap output persisted in plain text
3
+ # columns, e.g. +Spree::Product#description+) into a faithful plain-text
4
+ # rendering for API +description+/+body+ fields.
5
+ #
6
+ # Tiptap serializes blocks with no whitespace between them
7
+ # (+<p>a</p><p>b</p>+), so a naive tag strip glues adjacent blocks into a
8
+ # single run ("ab") and loses every paragraph and line break. This maps
9
+ # block boundaries and +<br>+ to newlines before stripping tags, keeping the
10
+ # text readable for storefronts, feeds, search indexing, and meta tags.
11
+ #
12
+ # Newlines already present in the markup (pretty-printed source from the
13
+ # legacy admin's TinyMCE code view, imports, seeds) render as a single space
14
+ # in a browser and are treated the same way here — only +<br>+ and block
15
+ # boundaries produce line breaks.
16
+ module RichTextHelper
17
+ # Closing block tags whose boundaries become line breaks.
18
+ BLOCK_BOUNDARY = %r{</(?:p|div|li|h[1-6]|blockquote|tr|ul|ol|table|section|article|header|footer|pre)>}i
19
+
20
+ # Hard line breaks.
21
+ LINE_BREAK = %r{<br\s*/?>}i
22
+
23
+ # @param html [String, nil] rich text HTML
24
+ # @return [String] plain text with paragraph and line breaks preserved
25
+ def self.to_plain_text(html)
26
+ return '' if html.blank?
27
+
28
+ with_breaks = html.gsub(/\s+/, ' ').gsub(LINE_BREAK, "\n").gsub(BLOCK_BOUNDARY, "\\0\n")
29
+
30
+ Nokogiri::HTML.fragment(with_breaks).text
31
+ .gsub(/[^\S\n]+/, ' ') # collapse runs of non-newline whitespace
32
+ .gsub(/ *\n */, "\n") # trim spaces hugging newlines
33
+ .gsub(/\n{3,}/, "\n\n") # cap consecutive blank lines
34
+ .strip
35
+ end
36
+ end
37
+ end
@@ -1,6 +1,22 @@
1
1
  module Spree
2
2
  module Imports
3
3
  class ProcessGroupJob < Spree::Imports::BaseJob
4
+ # A large import fans out into many group jobs; without a cap they
5
+ # occupy every worker thread as one frees up, starving checkout-critical
6
+ # queues that share the pool. Cap concurrent groups per import at 75%
7
+ # of JOB_THREADS (at least 1), or SPREE_IMPORT_JOB_CONCURRENCY when set
8
+ # (0 disables the cap) — excess jobs wait as blocked executions, not in
9
+ # worker threads. Both values are read by the process enqueueing the
10
+ # import, so on a split web/worker deployment set the explicit override
11
+ # on the web service. The semaphore duration must outlive the slowest
12
+ # group; expiry just lifts the cap, it doesn't lose jobs. Solid
13
+ # Queue-specific (no-op on other adapters).
14
+ import_concurrency = ENV['SPREE_IMPORT_JOB_CONCURRENCY'].presence&.to_i ||
15
+ [Integer(ENV.fetch('JOB_THREADS', 3)) * 3 / 4, 1].max
16
+ if import_concurrency.positive? && respond_to?(:limits_concurrency)
17
+ limits_concurrency to: import_concurrency, key: ->(import_id, _row_ids) { import_id }, duration: 30.minutes
18
+ end
19
+
4
20
  # Rows are loaded in slices so a large group never holds every ImportRow
5
21
  # (and its raw CSV data) in memory for the whole job.
6
22
  ROWS_BATCH_SIZE = 100
@@ -12,6 +28,9 @@ module Spree
12
28
  mappings = import.mappings.mapped.to_a
13
29
  schema_fields = import.schema_fields
14
30
  large = import.large_import?
31
+ grouped = import.group_column.present? && mappings.any? { |m| m.schema_field == import.group_column }
32
+ started_at = Time.current
33
+ processed_rows = []
15
34
 
16
35
  row_ids.each_slice(ROWS_BATCH_SIZE) do |ids|
17
36
  # Skip rows already completed on a prior attempt so retries don't double-process them.
@@ -25,18 +44,45 @@ module Spree
25
44
  Spree::Events.disable do
26
45
  rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
27
46
  end
47
+ elsif grouped
48
+ # A group is one product plus its variants: per-record lifecycle
49
+ # events (variant.created, price.created, product.updated per
50
+ # touch) are noise to subscribers — one product event is published
51
+ # for the whole group below. import_row.* events still flow.
52
+ Spree::Events.disable_lifecycle do
53
+ rows.each { |row| row.process!(mappings: mappings, schema_fields: schema_fields) }
54
+ end
28
55
  else
29
56
  rows.each do |row|
30
57
  row.process!(mappings: mappings, schema_fields: schema_fields)
31
58
  end
32
59
  end
60
+ processed_rows.concat(rows)
33
61
  end
34
62
 
63
+ publish_group_events(processed_rows, started_at) if grouped
35
64
  check_import_completion(import, large)
36
65
  end
37
66
 
38
67
  private
39
68
 
69
+ # One event per product touched by this group: `product.created` when the
70
+ # product came into existence during this run, `product.updated` otherwise
71
+ # (including retries of a group whose first attempt created it).
72
+ def publish_group_events(rows, started_at)
73
+ products = rows.select { |row| row.status == 'completed' }.filter_map do |row|
74
+ item = row.item
75
+ next item if item.is_a?(Spree::Product)
76
+
77
+ item.product if item.respond_to?(:product)
78
+ end.uniq
79
+
80
+ products.each do |product|
81
+ event = product.created_at >= started_at ? 'product.created' : 'product.updated'
82
+ product.publish_event(event)
83
+ end
84
+ end
85
+
40
86
  # Completion is row-state-derived so retry-induced over-increments of the counter
41
87
  # stay harmless. The counter pre-check just shortcuts the row scan for workers
42
88
  # that obviously can't be the last group to finish. `in_flight` excludes orphaned
@@ -39,6 +39,18 @@ module Spree
39
39
  included do
40
40
  class_attribute :publish_events, default: true
41
41
  class_attribute :lifecycle_events_enabled, default: false
42
+
43
+ after_update :mark_real_update_for_events
44
+ end
45
+
46
+ # A bare `touch` (variant → product, price → variant, asset → variants)
47
+ # writes no attribute changes — the resulting update event would carry
48
+ # nothing subscribers haven't already been told. Track it here so the
49
+ # commit callback can tell a touch-only commit from a real update;
50
+ # `saved_changes` can't (a touch replays the previous save's changeset).
51
+ def touch(*names, time: nil)
52
+ @_spree_touched_for_events = true
53
+ super
42
54
  end
43
55
 
44
56
  class_methods do
@@ -228,7 +240,21 @@ module Spree
228
240
  end
229
241
 
230
242
  def should_publish_events?
231
- self.class.publish_events && Spree::Events.enabled?
243
+ self.class.publish_events && Spree::Events.lifecycle_enabled?
244
+ end
245
+
246
+ # True when this commit was caused only by `touch` — no real save ran
247
+ # (`after_update` fires for saves, never for touches). Flags reset every
248
+ # commit so a later genuine update publishes normally.
249
+ def touch_only_update?
250
+ touched = @_spree_touched_for_events
251
+ updated = @_spree_updated_for_events
252
+ @_spree_touched_for_events = @_spree_updated_for_events = nil
253
+ touched && !updated
254
+ end
255
+
256
+ def mark_real_update_for_events
257
+ @_spree_updated_for_events = true
232
258
  end
233
259
 
234
260
  def publish_create_event
@@ -236,6 +262,8 @@ module Spree
236
262
  end
237
263
 
238
264
  def publish_update_event
265
+ return if touch_only_update?
266
+
239
267
  publish_event("#{event_prefix}.updated")
240
268
  end
241
269
 
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ # Keeps single-use credentials out of the persisted webhook delivery log.
5
+ #
6
+ # Some events must carry a live credential to their subscriber — a storefront
7
+ # that owns its transactional emails needs the real password reset token to
8
+ # build the email. Delivering it over TLS to a merchant-configured endpoint is
9
+ # intended; keeping a readable copy in `spree_webhook_deliveries.payload` is
10
+ # not, because that column is queryable and is served back through the Admin
11
+ # API delivery log.
12
+ #
13
+ # Sensitive values are therefore replaced with {REDACTION_PLACEHOLDER} before
14
+ # the record is written, and re-attached in memory at send time so the
15
+ # outgoing request body is unchanged.
16
+ module WebhookPayloadRedaction
17
+ extend ActiveSupport::Concern
18
+
19
+ # Payload keys under `data` whose values are live credentials.
20
+ SENSITIVE_PAYLOAD_KEYS = %w[reset_token unsubscribe_token].freeze
21
+
22
+ REDACTION_PLACEHOLDER = '[REDACTED]'
23
+
24
+ # Splits a payload into the version safe to persist and the secrets held
25
+ # back from it.
26
+ #
27
+ # @param payload [Hash] the full event payload
28
+ # @return [Array(Hash, Hash)] redacted payload, and the extracted secrets
29
+ # keyed as they appeared under `data`
30
+ def self.split(payload)
31
+ secrets = {}
32
+
33
+ redacted = transform_data_hashes(payload) do |data|
34
+ data.to_h do |key, value|
35
+ if SENSITIVE_PAYLOAD_KEYS.include?(key.to_s) && value.present?
36
+ secrets[secret_key_for(key)] = value
37
+ [key, REDACTION_PLACEHOLDER]
38
+ else
39
+ [key, value]
40
+ end
41
+ end
42
+ end
43
+
44
+ secrets.empty? ? [payload, {}] : [redacted, secrets]
45
+ end
46
+
47
+ # Re-attaches previously extracted secrets to a redacted payload.
48
+ #
49
+ # @param payload [Hash] the redacted payload
50
+ # @param secrets [Hash] secrets returned by {split}
51
+ # @return [Hash] the payload as it should go over the wire
52
+ def self.merge(payload, secrets)
53
+ return payload if secrets.blank?
54
+
55
+ transform_data_hashes(payload) do |data|
56
+ data.to_h do |key, value|
57
+ secret = secrets[secret_key_for(key)]
58
+ [key, secret.presence || value]
59
+ end
60
+ end
61
+ end
62
+
63
+ # Applies +block+ to every `data` hash on the payload.
64
+ #
65
+ # Both key forms are visited rather than only the first match: a payload
66
+ # carrying `:data` *and* `'data'` would otherwise leave one of them
67
+ # unredacted.
68
+ def self.transform_data_hashes(payload)
69
+ return payload unless payload.is_a?(Hash)
70
+
71
+ [:data, 'data'].reduce(payload) do |result, data_key|
72
+ data = result[data_key]
73
+ next result unless data.is_a?(Hash)
74
+
75
+ result.merge(data_key => yield(data))
76
+ end
77
+ end
78
+ private_class_method :transform_data_hashes
79
+
80
+ # Secrets are keyed by name alone. They cross an ActiveJob serialization
81
+ # boundary, which coerces symbol keys to strings, so the key form at split
82
+ # time cannot be relied on to still match at merge time.
83
+ def self.secret_key_for(key)
84
+ key.to_s
85
+ end
86
+ private_class_method :secret_key_for
87
+ end
88
+ end
@@ -233,6 +233,7 @@ module Spree
233
233
  assign_new_default_address_to_user
234
234
 
235
235
  if can_be_deleted?
236
+ unassign_from_incomplete_orders
236
237
  super
237
238
  else
238
239
  update_column :deleted_at, Time.current
@@ -364,5 +365,11 @@ module Spree
364
365
  def assign_new_default_address_to_user_scope
365
366
  user.addresses.not_quick_checkout.reorder(created_at: :desc)
366
367
  end
368
+
369
+ def unassign_from_incomplete_orders
370
+ orders = Spree::Order.incomplete.where(user_id: user_id)
371
+ orders.where(ship_address_id: id).update_all(ship_address_id: nil, state: 'address', updated_at: Time.current)
372
+ orders.where(bill_address_id: id).update_all(bill_address_id: nil, state: 'address', updated_at: Time.current)
373
+ end
367
374
  end
368
375
  end
@@ -46,6 +46,11 @@ module Spree
46
46
  end
47
47
 
48
48
  belongs_to :store, class_name: 'Spree::Store'
49
+ # Optional single-channel binding for publishable keys. A bound key
50
+ # server-assigns the request's channel (see Api::V3::ChannelResolution),
51
+ # making channel identity unforgeable instead of client-asserted via the
52
+ # X-Spree-Channel header. See docs/plans/5.6-store-channel-context-and-key-binding.md.
53
+ belongs_to :channel, class_name: 'Spree::Channel', optional: true
49
54
  belongs_to :created_by, polymorphic: true, optional: true
50
55
  belongs_to :revoked_by, polymorphic: true, optional: true
51
56
 
@@ -64,6 +69,11 @@ module Spree
64
69
  # guard is at the model so every entry point (API, legacy admin, rake tasks)
65
70
  # is covered uniformly.
66
71
  validate :scopes_immutable, if: -> { secret? && persisted? && scopes_changed? }
72
+ validate :channel_binding_valid, if: -> { channel_id.present? }
73
+ # Like scopes, the binding is fixed for the life of the key — rebinding a
74
+ # live credential to a different channel would silently repoint every
75
+ # client holding it; mint a new key instead.
76
+ validate :channel_immutable, if: -> { persisted? && channel_id_changed? }
67
77
 
68
78
  before_validation :generate_token, on: :create
69
79
 
@@ -156,6 +166,18 @@ module Spree
156
166
  errors.add(:scopes, Spree.t(:api_key_scopes_immutable))
157
167
  end
158
168
 
169
+ def channel_binding_valid
170
+ if secret?
171
+ errors.add(:channel, Spree.t(:api_key_channel_publishable_only))
172
+ elsif channel && channel.store_id != store_id
173
+ errors.add(:channel, Spree.t(:api_key_channel_must_belong_to_store))
174
+ end
175
+ end
176
+
177
+ def channel_immutable
178
+ errors.add(:channel, Spree.t(:api_key_channel_immutable))
179
+ end
180
+
159
181
  # Generates the token on creation. For publishable keys, stores the raw token
160
182
  # in the +token+ column. For secret keys, computes an HMAC-SHA256 digest stored
161
183
  # in +token_digest+, saves the first 12 characters as +token_prefix+ for display,
@@ -24,11 +24,21 @@ module Spree
24
24
  end
25
25
 
26
26
  # When it comes to computing shipments or line items: same same.
27
+ #
28
+ # Both tax modes are computed from the item's live taxable basis, which
29
+ # follows every discount: item-level promotions and the item's share of
30
+ # whole-order promotions (see LineItem#taxable_basis). Included
31
+ # (VAT-style) tax intentionally does not read the stored pre_tax_amount
32
+ # column — that column is only refreshed by Spree::TaxRate.adjust on
33
+ # checkout transitions, so it goes stale the moment a promotion is
34
+ # applied or removed.
27
35
  def compute_shipment_or_line_item(item)
36
+ basis = item.taxable_basis
37
+
28
38
  if rate.included_in_price
29
- deduced_total_by_rate(item.pre_tax_amount, rate)
39
+ deduced_total_by_rate(net_basis(item, basis), rate)
30
40
  else
31
- round_to_two_places(item.discounted_amount * rate.amount)
41
+ round_to_two_places(basis * rate.amount)
32
42
  end
33
43
  end
34
44
 
@@ -54,5 +64,24 @@ module Spree
54
64
  def deduced_total_by_rate(pre_tax_amount, rate)
55
65
  round_to_two_places(pre_tax_amount * rate.amount)
56
66
  end
67
+
68
+ # Backs all included-in-price rates out of the gross basis, mirroring
69
+ # Spree::TaxRate.store_pre_tax_amount (several rates can share a tax
70
+ # category, e.g. MOSS VAT). Falls back to this rate's amount when no
71
+ # rate matches the item, e.g. in bare setups without matching zones.
72
+ def net_basis(item, basis)
73
+ included = included_rates_amount(item)
74
+ included = rate.amount if included.zero?
75
+
76
+ basis / (1 + included)
77
+ end
78
+
79
+ # Memoized per tax category: tax rate and zone rows are configuration,
80
+ # invariant within a recalculation pass.
81
+ def included_rates_amount(item)
82
+ @included_rates_amount ||= {}
83
+ @included_rates_amount[item.tax_category_id] ||=
84
+ Spree::TaxRate.included_tax_amount_for(tax_zone: rate.zone, tax_category: item.tax_category)
85
+ end
57
86
  end
58
87
  end
@@ -22,6 +22,7 @@ module Spree
22
22
  has_many :order_routing_rules, class_name: 'Spree::OrderRoutingRule', dependent: :destroy
23
23
  has_many :publications, class_name: 'Spree::ProductPublication', dependent: :destroy
24
24
  has_many :products, through: :publications, class_name: 'Spree::Product'
25
+ has_many :api_keys, class_name: 'Spree::ApiKey', dependent: :nullify
25
26
 
26
27
  attribute :active, :boolean, default: true
27
28
 
@@ -40,6 +41,10 @@ module Spree
40
41
  # arrives at a single default without relying on DB constraints.
41
42
  before_save :demote_other_defaults, if: -> { default? && will_save_change_to_default? }
42
43
  before_destroy :ensure_not_default
44
+ # prepend: the +dependent: :nullify+ on +api_keys+ registers its own
45
+ # before_destroy at association-declaration time; without prepend it would
46
+ # clear every binding before this guard could see them.
47
+ before_destroy :ensure_no_active_bound_api_keys, prepend: true
43
48
  after_create :ensure_default_order_routing_rules
44
49
 
45
50
  scope :active, -> { where(active: true) }
@@ -115,19 +120,32 @@ module Spree
115
120
 
116
121
  # @return [Boolean]
117
122
  def can_be_deleted?
118
- !default?
123
+ !default? && !api_keys.active.exists?
119
124
  end
120
125
 
121
126
  private
122
127
 
123
128
  def ensure_not_default
124
- return if can_be_deleted?
129
+ return unless default?
125
130
  return if destroyed_by_association.present?
126
131
 
127
132
  errors.add(:base, Spree.t('errors.messages.cannot_delete_default_channel'))
128
133
  throw :abort
129
134
  end
130
135
 
136
+ # Fail closed: deleting a channel must not silently convert its bound
137
+ # publishable keys into unbound (store-wide) credentials via the
138
+ # +dependent: :nullify+ above. Active keys block deletion — revoke or
139
+ # rebind them first; revoked keys are historical and nullify freely.
140
+ # Store-cascade deletes skip the guard (the keys die with the store).
141
+ def ensure_no_active_bound_api_keys
142
+ return unless api_keys.active.exists?
143
+ return if destroyed_by_association.present?
144
+
145
+ errors.add(:base, Spree.t('errors.messages.cannot_delete_channel_with_active_api_keys'))
146
+ throw :abort
147
+ end
148
+
131
149
  def backfill_code_from_name
132
150
  self.code = name
133
151
  end
@@ -146,6 +146,29 @@ module Spree
146
146
  alias discounted_money display_discounted_amount
147
147
  alias discounted_amount taxable_amount
148
148
 
149
+ # Returns the amount this line item is taxed on: the discounted amount
150
+ # reduced further by the line item's proportional share of any
151
+ # whole-order promotions, which are adjustments on the order itself and
152
+ # therefore not part of +taxable_adjustment_total+. Never negative.
153
+ #
154
+ # @return [BigDecimal]
155
+ def taxable_basis
156
+ basis = taxable_amount
157
+ return basis if basis <= 0
158
+
159
+ order_discount = order.order_level_promo_total
160
+ return basis if order_discount.zero?
161
+
162
+ # summed in SQL so the allocation uses the persisted adjustment totals
163
+ # of all line items, not a possibly stale cached association
164
+ items_total = order.line_items.reorder(nil).pick(
165
+ Arel.sql('COALESCE(SUM(price * quantity + taxable_adjustment_total), 0)')
166
+ )
167
+ return basis if items_total <= 0
168
+
169
+ [basis + (order_discount * basis / items_total), BigDecimal(0)].max
170
+ end
171
+
149
172
  # Returns the final amount of the line item
150
173
  #
151
174
  # @return [BigDecimal]
@@ -360,6 +360,16 @@ module Spree
360
360
  line_items.sum(:pre_tax_amount)
361
361
  end
362
362
 
363
+ # Sum of the eligible promotion adjustments applied to the order itself
364
+ # (whole-order discounts created by Promotion::Actions::CreateAdjustment),
365
+ # as opposed to promotions applied to individual line items or shipments.
366
+ # Zero or negative.
367
+ #
368
+ # @return [BigDecimal]
369
+ def order_level_promo_total
370
+ adjustments.promotion.eligible.sum(:amount)
371
+ end
372
+
363
373
  # Sum of all line item and shipment pre-tax
364
374
  def pre_tax_total
365
375
  pre_tax_item_amount + shipments.sum(:pre_tax_amount)
@@ -207,6 +207,15 @@ module Spree
207
207
  end
208
208
  alias discounted_amount discounted_cost
209
209
 
210
+ # Returns the amount this shipment is taxed on: its discounted cost,
211
+ # never negative (stacked shipping promotions can push discounted_cost
212
+ # below zero). Whole-order promotions are not allocated to shipments.
213
+ #
214
+ # @return [BigDecimal]
215
+ def taxable_basis
216
+ [discounted_cost, BigDecimal(0)].max
217
+ end
218
+
210
219
  def final_price
211
220
  cost + adjustment_total
212
221
  end
@@ -54,6 +54,13 @@ module Spree
54
54
  # Pre-tax amounts must be stored so that we can calculate
55
55
  # correct rate amounts in the future. For example:
56
56
  # https://github.com/spree/spree/issues/4318#issuecomment-34723428
57
+ #
58
+ # NOTE: this deliberately excludes the item's share of whole-order
59
+ # promotions and is therefore NOT the basis tax is computed on (that is
60
+ # LineItem#taxable_basis / Shipment#taxable_basis). Refund and reporting
61
+ # code (e.g. Calculator::Returns::DefaultRefundAmount) weighs order-level
62
+ # adjustments separately on top of this column, so allocating them here
63
+ # would double-count discounts.
57
64
  def self.store_pre_tax_amount(item, rates)
58
65
  pre_tax_amount = case item.class.to_s
59
66
  when 'Spree::LineItem' then item.discounted_amount
@@ -7,6 +7,11 @@ module Spree
7
7
  belongs_to :webhook_endpoint, class_name: 'Spree::WebhookEndpoint'
8
8
  delegate :url, to: :webhook_endpoint
9
9
 
10
+ # Credentials stripped from the persisted payload. Never written to the
11
+ # database — held in memory on a single delivery instance, assigned by
12
+ # WebhookDeliveryJob when it runs. See {Spree::WebhookPayloadRedaction}.
13
+ attr_accessor :payload_secrets
14
+
10
15
  validates :event_name, presence: true
11
16
  validates :payload, presence: true
12
17
 
@@ -64,6 +69,23 @@ module Spree
64
69
  webhook_endpoint.check_auto_disable! unless is_success
65
70
  end
66
71
 
72
+ # Payload as it should be sent over the wire.
73
+ #
74
+ # Re-attaches any credentials withheld from the persisted column. Once this
75
+ # record has been reloaded from the database those secrets are gone for
76
+ # good, so a redelivery sends the redacted placeholder — single-use tokens
77
+ # are not replayable anyway.
78
+ #
79
+ # This is deliberate: keeping a recoverable copy would put the credential
80
+ # back at rest, which is what redaction exists to prevent. If a delivery is
81
+ # lost (worker crash between creation and delivery), the customer requests
82
+ # a new reset, which mints a fresh token.
83
+ #
84
+ # @return [Hash]
85
+ def deliverable_payload
86
+ Spree::WebhookPayloadRedaction.merge(payload, payload_secrets)
87
+ end
88
+
67
89
  # Create a new delivery with the same payload and queue it.
68
90
  # Used to retry failed deliveries manually.
69
91
  #
@@ -3,6 +3,22 @@ require 'nokogiri'
3
3
  module Spree
4
4
  module DataFeeds
5
5
  class GooglePresenter < BasePresenter
6
+ # Optional Google Merchant Center product attributes sourced from
7
+ # metafields. See https://support.google.com/merchants/answer/7052112
8
+ OPTIONAL_ATTRIBUTES = %w[
9
+ brand gtin mpn identifier_exists condition adult multipack is_bundle
10
+ age_group color gender material pattern size size_type size_system
11
+ product_length product_width product_height product_weight
12
+ google_product_category product_type sale_price sale_price_effective_date
13
+ cost_of_goods_sold unit_pricing_measure unit_pricing_base_measure
14
+ shipping shipping_label shipping_weight shipping_length shipping_width
15
+ shipping_height ships_from_country transit_time_label max_handling_time
16
+ min_handling_time tax tax_category energy_efficiency_class
17
+ min_energy_efficiency_class max_energy_efficiency_class
18
+ gtin_source expiration_date custom_label_0 custom_label_1 custom_label_2
19
+ custom_label_3 custom_label_4 mobile_link additional_image_link
20
+ ].freeze
21
+
6
22
  # @return [String] RSS XML feed for Google Merchant Center
7
23
  def call
8
24
  builder = Nokogiri::XML::Builder.new do |xml|
@@ -59,10 +75,21 @@ module Spree
59
75
 
60
76
  def build_optional_attributes(xml, product)
61
77
  product.public_metafields.each do |metafield|
62
- xml['g'].send(metafield.metafield_definition.key.parameterize.underscore, metafield.value)
78
+ key = metafield.metafield_definition.key.parameterize.underscore
79
+ next unless OPTIONAL_ATTRIBUTES.include?(key)
80
+
81
+ append_g_element(xml, key, metafield.value)
63
82
  end
64
83
  end
65
84
 
85
+ def append_g_element(xml, name, value)
86
+ parent = xml.parent
87
+ node = Nokogiri::XML::Node.new(name, parent.document)
88
+ node.namespace = parent.namespace_scopes.find { |ns| ns.prefix == 'g' }
89
+ node.content = value
90
+ parent << node
91
+ end
92
+
66
93
  def format_title(product, variant)
67
94
  parts = [product.name]
68
95
  variant.option_values.each do |option_value|