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.
- checksums.yaml +4 -4
- data/app/helpers/spree/rich_text_helper.rb +37 -0
- data/app/jobs/spree/imports/process_group_job.rb +46 -0
- data/app/models/concerns/spree/publishable.rb +29 -1
- data/app/models/concerns/spree/webhook_payload_redaction.rb +88 -0
- data/app/models/spree/address.rb +7 -0
- data/app/models/spree/api_key.rb +22 -0
- data/app/models/spree/calculator/default_tax.rb +31 -2
- data/app/models/spree/channel.rb +20 -2
- data/app/models/spree/line_item.rb +23 -0
- data/app/models/spree/order.rb +10 -0
- data/app/models/spree/shipment.rb +9 -0
- data/app/models/spree/tax_rate.rb +7 -0
- data/app/models/spree/webhook_delivery.rb +22 -0
- data/app/presenters/spree/data_feeds/google_presenter.rb +28 -1
- data/app/services/spree/addresses/update.rb +32 -25
- data/app/services/spree/sample_data/loader.rb +7 -0
- data/app/services/spree/seeds/all.rb +2 -0
- data/app/services/spree/seeds/api_keys.rb +8 -4
- data/app/services/spree/seeds/channels.rb +25 -0
- data/app/services/spree/seeds/customer_groups.rb +18 -0
- data/config/locales/en.yml +4 -0
- data/db/migrate/20260719000001_add_channel_id_to_spree_api_keys.rb +5 -0
- data/db/sample_data/channels.rb +12 -1
- data/db/sample_data/wholesale.rb +59 -0
- data/lib/generators/spree/dummy/templates/rails/database.yml +1 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/events.rb +31 -7
- data/lib/spree/permitted_attributes.rb +1 -1
- data/lib/spree/testing_support/lifecycle_events.rb +12 -0
- metadata +10 -5
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81bf3080a8d43c9bc6a6d76561c9fae751c9282a1e8b30dfb388ca6abe2511d7
|
|
4
|
+
data.tar.gz: 423cf415d52c180297db3e26b581ea4078cfcf0e7816fc3084d12034b3d74a87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
data/app/models/spree/address.rb
CHANGED
|
@@ -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
|
data/app/models/spree/api_key.rb
CHANGED
|
@@ -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
|
|
39
|
+
deduced_total_by_rate(net_basis(item, basis), rate)
|
|
30
40
|
else
|
|
31
|
-
round_to_two_places(
|
|
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
|
data/app/models/spree/channel.rb
CHANGED
|
@@ -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
|
|
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]
|
data/app/models/spree/order.rb
CHANGED
|
@@ -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
|
-
|
|
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|
|