spree_core 6.0.0.rc3 → 6.0.0.rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/app/mailers/spree/email_template_mailer.rb +5 -2
  3. data/app/models/concerns/spree/has_external_references.rb +7 -6
  4. data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
  5. data/app/models/concerns/spree/purchase/addresses.rb +95 -32
  6. data/app/models/concerns/spree/purchase/taxation.rb +44 -3
  7. data/app/models/concerns/spree/translatable_resource.rb +49 -0
  8. data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
  9. data/app/models/spree/ability.rb +8 -0
  10. data/app/models/spree/address.rb +6 -0
  11. data/app/models/spree/cart.rb +4 -6
  12. data/app/models/spree/company.rb +1 -1
  13. data/app/models/spree/data_request.rb +34 -1
  14. data/app/models/spree/delivery_rate.rb +12 -4
  15. data/app/models/spree/delivery_rate_provider/base.rb +1 -1
  16. data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
  17. data/app/models/spree/fulfillment.rb +1 -0
  18. data/app/models/spree/gateway/bogus.rb +7 -0
  19. data/app/models/spree/line_item.rb +3 -8
  20. data/app/models/spree/oauth_access_grant.rb +11 -0
  21. data/app/models/spree/oauth_access_token.rb +16 -0
  22. data/app/models/spree/oauth_application.rb +97 -0
  23. data/app/models/spree/oauth_consent_request.rb +23 -0
  24. data/app/models/spree/oauth_protected_resource.rb +21 -0
  25. data/app/models/spree/oauth_redirect.rb +12 -0
  26. data/app/models/spree/option_type.rb +5 -0
  27. data/app/models/spree/option_value.rb +5 -0
  28. data/app/models/spree/order.rb +6 -2
  29. data/app/models/spree/payment.rb +14 -2
  30. data/app/models/spree/payment_capture_event.rb +2 -0
  31. data/app/models/spree/policy.rb +6 -0
  32. data/app/models/spree/price.rb +16 -33
  33. data/app/models/spree/price_rule.rb +3 -16
  34. data/app/models/spree/price_rules/market_rule.rb +0 -6
  35. data/app/models/spree/pricing_provider/base.rb +1 -1
  36. data/app/models/spree/product.rb +1 -1
  37. data/app/models/spree/purchase_order_item.rb +1 -1
  38. data/app/models/spree/return.rb +1 -0
  39. data/app/models/spree/sales_context.rb +162 -0
  40. data/app/models/spree/search_provider/database.rb +1 -1
  41. data/app/models/spree/stock/estimator.rb +4 -20
  42. data/app/models/spree/store.rb +8 -0
  43. data/app/models/spree/tax_exemption/item_override.rb +14 -0
  44. data/app/models/spree/tax_exemption.rb +26 -0
  45. data/app/models/spree/tax_exemption_certificate.rb +13 -2
  46. data/app/models/spree/tax_exemption_reason.rb +62 -0
  47. data/app/models/spree/tax_line.rb +5 -4
  48. data/app/models/spree/tax_provider/base.rb +24 -4
  49. data/app/models/spree/tax_provider/internal.rb +36 -29
  50. data/app/models/spree/tax_rate.rb +9 -35
  51. data/app/models/spree/translations/batch.rb +14 -4
  52. data/app/services/spree/carts/destroy.rb +7 -2
  53. data/app/services/spree/carts/price_items.rb +2 -8
  54. data/app/services/spree/carts/split_by_seller.rb +6 -1
  55. data/app/services/spree/carts/update.rb +35 -11
  56. data/app/services/spree/data_requests/confirm.rb +37 -0
  57. data/app/services/spree/data_requests/create.rb +61 -13
  58. data/app/services/spree/email_templates/preview.rb +24 -3
  59. data/app/services/spree/seeds/store_resources.rb +1 -0
  60. data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
  61. data/app/services/spree/tax/resolve_exemptions.rb +11 -2
  62. data/app/services/spree/update_check.rb +8 -1
  63. data/app/tools/spree/agent_tools/create_export.rb +116 -0
  64. data/app/tools/spree/agent_tools/create_import.rb +117 -0
  65. data/app/tools/spree/agent_tools/create_media.rb +81 -0
  66. data/app/tools/spree/agent_tools/create_resource.rb +36 -0
  67. data/app/tools/spree/agent_tools/curate_products.rb +90 -0
  68. data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
  69. data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
  70. data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
  71. data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
  72. data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
  73. data/app/tools/spree/agent_tools/get_resource.rb +97 -0
  74. data/app/tools/spree/agent_tools/import_tool.rb +80 -0
  75. data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
  76. data/app/tools/spree/agent_tools/query_report.rb +124 -0
  77. data/app/tools/spree/agent_tools/resource_write.rb +99 -0
  78. data/app/tools/spree/agent_tools/search_resources.rb +155 -0
  79. data/app/tools/spree/agent_tools/set_stock.rb +52 -0
  80. data/app/tools/spree/agent_tools/update_resource.rb +42 -0
  81. data/app/tools/spree/agent_tools/upload_file.rb +104 -0
  82. data/app/workflows/spree/carts/complete.rb +115 -2
  83. data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
  84. data/app/workflows/spree/customers/anonymize.rb +38 -17
  85. data/app/workflows/spree/data_requests/fulfill.rb +20 -6
  86. data/app/workflows/spree/orders/cancel.rb +8 -0
  87. data/app/workflows/spree/orders/complete.rb +8 -0
  88. data/app/{services → workflows}/spree/orders/create.rb +50 -26
  89. data/app/workflows/spree/purchase_orders/create.rb +3 -0
  90. data/app/workflows/spree/returns/refund.rb +4 -0
  91. data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
  92. data/app/workflows/spree/variants/create.rb +7 -4
  93. data/config/locales/en.yml +18 -0
  94. data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
  95. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
  96. data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
  97. data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
  98. data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
  99. data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
  100. data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
  101. data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
  102. data/db/sample_data/orders.rb +1 -1
  103. data/lib/spree/agent_tool.rb +188 -0
  104. data/lib/spree/agent_tools/api_dispatch.rb +204 -0
  105. data/lib/spree/agent_tools/context.rb +191 -0
  106. data/lib/spree/agent_tools/default_catalog.rb +180 -0
  107. data/lib/spree/agent_tools/membership_map.rb +61 -0
  108. data/lib/spree/agent_tools/record_summary.rb +219 -0
  109. data/lib/spree/agent_tools/registry.rb +131 -0
  110. data/lib/spree/agent_tools/resource_map.rb +352 -0
  111. data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
  112. data/lib/spree/agent_tools/workflow_schema.rb +279 -0
  113. data/lib/spree/agent_tools/workflow_tool.rb +654 -0
  114. data/lib/spree/agent_tools.rb +24 -0
  115. data/lib/spree/core/configuration.rb +1 -1
  116. data/lib/spree/core/controller_helpers/store.rb +3 -13
  117. data/lib/spree/core/dependencies.rb +9 -0
  118. data/lib/spree/core/engine.rb +37 -1
  119. data/lib/spree/core/model_extensions.rb +111 -0
  120. data/lib/spree/core/permissions/default_catalog.rb +6 -1
  121. data/lib/spree/core/pricing/price_resolution.rb +4 -4
  122. data/lib/spree/core/version.rb +1 -1
  123. data/lib/spree/core.rb +27 -2
  124. data/lib/spree/service_module.rb +11 -0
  125. data/lib/spree/testing_support/factories/data_request_factory.rb +4 -0
  126. data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
  127. data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
  128. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
  129. data/lib/tasks/markets.rake +47 -0
  130. data/lib/tasks/tax_exemption_reasons.rake +48 -0
  131. metadata +69 -7
  132. data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59f0dc211ad34ae5b6985f353cf1a92237d937834cc2135ad93249ee8868aab1
4
- data.tar.gz: 96e00c6df2bb8f8172c231e672403cd9d25cda61a5deef1413938a327cdce4f5
3
+ metadata.gz: 9e7960e9257c5de15c7d9e69d8916c669c5ab082fccd02e17c5ee9c9d9d378c8
4
+ data.tar.gz: 3c160cd58beb2d3fc3c99bead42bc0d3f365952ae7c2b2c103d92004303f7d13
5
5
  SHA512:
6
- metadata.gz: f5000bd075a0d24d328b56f4fd5f691e9dbcd1a689f1285dcdbb4558e3c8328b3acc9a8ac44e70afff39000daa015722277e3751527c827f2146ec22c34482d4
7
- data.tar.gz: 3282389785170a88dae97b1f95a51364056955ecb1d8d393e25c7f1c911d94e053681e35f1bf5e80bd4f3533fb8c0cb669e93d1d9ce898b199766970dffbeae7
6
+ metadata.gz: b396fb4c3d553e372dab904f57cb794015b4c2670028fea33c775fc343ac844db1d0d2733a9c47cfb2450898408ad4696ba312114719f7b3d1ad55716149f674
7
+ data.tar.gz: 89e9eb2e33742ec34f5c89e5e732544a868e4241ba5886cfa357e0ca43929107d895024ff6e936952c3648cf51ac379e7839ac2d211dc2515697d2ff52d8e7a7
@@ -11,11 +11,14 @@ module Spree
11
11
  # @param record_id [String, nil] the record to build sample data from
12
12
  # @param email_key [String, nil] for the layout or a partial, the email to show it in
13
13
  # @param branding [Hash] unsaved branding values
14
- def test_email(store, recipient, key, locale: nil, subject: nil, body: nil, record_id: nil, email_key: nil, branding: {})
14
+ # @param mailer_logo_signed_id [String, nil] the signed id of an unsaved, uploaded email logo
15
+ # @param mailer_logo_cleared [Boolean] whether the email logo is sent as removed
16
+ def test_email(store, recipient, key, locale: nil, subject: nil, body: nil, record_id: nil, email_key: nil, branding: {},
17
+ mailer_logo_signed_id: nil, mailer_logo_cleared: false)
15
18
  @current_store = store
16
19
  email = Spree::EmailTemplates::Preview.new(
17
20
  store: store, key: key, locale: locale, subject: subject, body: body, record_id: record_id, email_key: email_key,
18
- branding: branding
21
+ branding: branding, mailer_logo_signed_id: mailer_logo_signed_id, mailer_logo_cleared: mailer_logo_cleared
19
22
  ).call
20
23
 
21
24
  mail(to: recipient, subject: I18n.t('spree.email_templates.test_email_subject', subject: email.subject)) do |format|
@@ -11,13 +11,14 @@ module Spree
11
11
  # Scope-friendly so controllers can chain it onto an already store-scoped
12
12
  # relation: `current_store.products.with_external_id('erp', 'MAT-100')`.
13
13
  # Scoping the lookup to the store is the caller's job, exactly as with any
14
- # other id coming off a request.
14
+ # other id coming off a request. A subquery rather than a join, so a
15
+ # relation that also preloads `external_references` still loads every
16
+ # key, not only the matched one.
15
17
  scope :with_external_id, lambda { |system, external_id|
16
- joins(:external_references).where(
17
- spree_external_references: {
18
- system: Spree::ExternalReference.normalize_system(system),
19
- external_id: external_id.to_s.strip
20
- }
18
+ where(
19
+ id: Spree::ExternalReference.for_system(system).
20
+ where(resource_type: polymorphic_name, external_id: external_id.to_s.strip).
21
+ select(:resource_id)
21
22
  )
22
23
  }
23
24
  end
@@ -52,7 +52,14 @@ module Spree
52
52
  schema = value_schema(definition)
53
53
  choices = definition[:choices]
54
54
  choices = choices.call if choices.respond_to?(:call)
55
- schema['enum'] = choices.map { |choice| choice.is_a?(Symbol) ? choice.to_s : choice } if choices.present?
55
+ if choices.present?
56
+ # `{ value => label }` names values that cannot label themselves — an
57
+ # endpoint URL is no i18n key, let alone something to show a merchant.
58
+ labels = choices.to_h { |value, label| [value.to_s, label.to_s] } if choices.is_a?(Hash)
59
+ values = choices.is_a?(Hash) ? choices.keys : Array(choices)
60
+ schema['enum'] = values.map { |choice| choice.is_a?(Symbol) ? choice.to_s : choice }
61
+ schema['x-spree-choice-labels'] = labels if labels
62
+ end
56
63
  default = definition[:dynamic_default] ? nil : wire_default(default, definition)
57
64
  unset_reads_null = default.nil? && !definition[:dynamic_default]
58
65
 
@@ -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.
@@ -28,6 +28,15 @@ module Spree
28
28
  market&.tax_provider_instance || Spree.default_tax_provider.new
29
29
  end
30
30
 
31
+ # Whether this sale's prices include tax, which its market decides. With
32
+ # no market yet (an empty cart before currency resolution) the store's
33
+ # default market answers.
34
+ #
35
+ # @return [Boolean]
36
+ def tax_inclusive?
37
+ (market || store&.default_market)&.tax_inclusive? || false
38
+ end
39
+
31
40
  # The typed inputs every +estimate+ call carries. Assembled here rather
32
41
  # than at each call site so a single-item re-estimate reaches the provider
33
42
  # with the same buyer registration and exemption evidence as a full
@@ -39,7 +48,8 @@ module Spree
39
48
  {
40
49
  tax_date: Time.current,
41
50
  tax_identifier: resolved_tax_identifier,
42
- exemptions: usable_exemptions
51
+ exemptions: usable_exemptions,
52
+ tax_inclusive: tax_inclusive?
43
53
  }
44
54
  end
45
55
 
@@ -100,15 +110,30 @@ module Spree
100
110
  pre_tax_item_amount + fulfillments.sum(:pre_tax_amount)
101
111
  end
102
112
 
103
- private
104
-
105
113
  # The resolver is swappable, so what it returns is host code. An entry
106
114
  # whose per-line overrides cannot name their line is not a narrower claim
107
115
  # but a wider one — the provider would read it as the whole order being
108
116
  # exempt — so it is dropped rather than trusted. Charging tax is the safe
109
117
  # direction: under-exempting surfaces as a customer query, over-exempting
110
118
  # is tax the merchant owes and never collected.
119
+ #
120
+ # Public because a provider filing the sale needs the same evidence core
121
+ # hands it as an +estimate+ argument: an external engine commits its
122
+ # document after placement, and resolving again there could file an
123
+ # exemption the estimate never applied.
124
+ #
125
+ # A placed order answers from its frozen snapshot for that reason, the way
126
+ # +resolved_tax_identifier+ does. Resolving live months later is not the
127
+ # same evidence: a certificate lapses on its own date with nothing written
128
+ # to it, can be revoked, and goes with its company when that is deleted —
129
+ # so a credit for an exempt sale would be filed as a consumer refund and
130
+ # declare tax the sale never collected. Orders placed before the snapshot
131
+ # existed have none, and still resolve live.
132
+ #
133
+ # @return [Array]
111
134
  def usable_exemptions
135
+ return frozen_tax_exemptions if frozen_tax_exemptions?
136
+
112
137
  resolved = Array(Spree.tax_resolve_exemptions_service.new.call(order: self).value)
113
138
 
114
139
  resolved.select do |exemption|
@@ -127,6 +152,22 @@ module Spree
127
152
  end
128
153
  end
129
154
 
155
+ private
156
+
157
+ # Carts always resolve live — a buyer's certificates legitimately change
158
+ # while they shop, and the cart is priced again each time.
159
+ #
160
+ # Only nil is an absent snapshot. An empty one records that the sale found
161
+ # no claim, and reading that as absent would let a certificate added later
162
+ # explain a sale it was never part of.
163
+ def frozen_tax_exemptions?
164
+ is_a?(Spree::Order) && completed? && !applied_tax_exemptions.nil?
165
+ end
166
+
167
+ def frozen_tax_exemptions
168
+ Array(applied_tax_exemptions).map { |snapshot| Spree::TaxExemption.from_snapshot(snapshot) }
169
+ end
170
+
130
171
  # Read through the legal entity, never the node — a division holds no
131
172
  # registrations of its own.
132
173
  def company_tax_identifier
@@ -12,6 +12,21 @@ module Spree
12
12
  const_get(:TRANSLATABLE_FIELDS)
13
13
  end
14
14
 
15
+ # The permission a caller needs in order to write this resource's
16
+ # translations.
17
+ #
18
+ # Declared rather than derived from the class name, because several
19
+ # resources are guarded by a permission that is not named after them:
20
+ # option types and values ride `write_products`, a policy rides
21
+ # `write_settings`. Deriving it produced names outside the permission
22
+ # catalog, which a credential could never hold and which the API
23
+ # therefore could not check at all.
24
+ #
25
+ # @return [String]
26
+ def translation_write_permission
27
+ "write_#{Spree::Base.polymorphic_api_type(name).pluralize}"
28
+ end
29
+
15
30
  # Maps a public API field name to the internal Mobility field, for a
16
31
  # model whose translatable column is named differently from the field the
17
32
  # API exposes. The public name is what the serializer and the translation
@@ -70,6 +85,40 @@ module Spree
70
85
  def arel_table_alias
71
86
  Arel::Table.new(translation_table_alias)
72
87
  end
88
+
89
+ # Sorts translated fields by the translation, or by the default-language
90
+ # value in the base column when the translation is missing or empty.
91
+ # Ransack sorts them by the translation alone, which leaves untranslated
92
+ # records unordered. On a DISTINCT relation the value is selected as
93
+ # well, because PostgreSQL rejects an ORDER BY the select list lacks.
94
+ #
95
+ # @return [ActiveRecord::Relation]
96
+ def with_translation_fallback_order
97
+ relation = all
98
+ return relation unless relation.order_values.any? { |node| translated_ordering?(node) }
99
+
100
+ relation = relation.select(arel_table[Arel.star]) if relation.distinct_value && relation.select_values.empty?
101
+ orders = relation.order_values.map do |node|
102
+ next node unless translated_ordering?(node)
103
+
104
+ value = Arel::Nodes::NamedFunction.new('COALESCE', [
105
+ Arel::Nodes::NamedFunction.new('NULLIF', [node.expr, Arel::Nodes.build_quoted('')]),
106
+ arel_table[node.expr.name]
107
+ ])
108
+ relation = relation.select(Arel::Nodes::As.new(value, Arel.sql('translated_sort'))) if relation.distinct_value
109
+ node.class.new(value)
110
+ end
111
+
112
+ relation.reorder(orders)
113
+ end
114
+
115
+ private
116
+
117
+ def translated_ordering?(node)
118
+ node.is_a?(Arel::Nodes::Ordering) &&
119
+ node.expr.is_a?(Mobility::Plugins::Arel::Attribute) &&
120
+ self <= node.expr.backend_class.model_class
121
+ end
73
122
  end
74
123
 
75
124
  def get_field_with_locale(locale, field_name, fallback: false)
@@ -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
@@ -179,6 +179,7 @@ module Spree
179
179
 
180
180
  fulfillment_ids = fulfillments.map(&:id)
181
181
  DeliveryRate.where(fulfillment_id: fulfillment_ids).delete_all
182
+ Spree::ExternalReference.where(resource_type: Spree::Fulfillment.name, resource_id: fulfillment_ids).delete_all if fulfillment_ids.any?
182
183
  fulfillments.delete_all
183
184
  fulfillment_items.reset
184
185
  return fulfillments.reload unless can_propose_deliveries?
@@ -283,12 +284,9 @@ module Spree
283
284
  #
284
285
  # @return [ActiveRecord::Relation<Spree::Variant>]
285
286
  def orderable_variants
286
- products = Spree.products_for_context_service.call(
287
- store: store, channel: channel, customer: customer, company: resolved_company,
288
- base: store.products.not_discontinued_on(channel)
289
- ).value
290
-
291
- 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
292
290
  end
293
291
 
294
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
@@ -41,7 +41,7 @@ module Spree
41
41
  return price if tax_rate.nil? || tax_amount.zero? || !tax_rate.show_rate_in_label
42
42
 
43
43
  I18n.t(
44
- "spree.shipping_rates.display_price.#{tax_rate.included_in_price? ? :including_tax : :excluding_tax}",
44
+ "spree.shipping_rates.display_price.#{tax_inclusive? ? :including_tax : :excluding_tax}",
45
45
  price: price,
46
46
  tax_amount: display_tax_amount,
47
47
  tax_rate_name: tax_rate.name
@@ -79,7 +79,7 @@ module Spree
79
79
  def tax_amount
80
80
  @tax_amount ||= if tax_rate.nil?
81
81
  BigDecimal(0)
82
- elsif tax_rate.included_in_price?
82
+ elsif tax_inclusive?
83
83
  Spree::Money::Rounding.to_currency(cost / (1 + tax_rate.amount) * tax_rate.amount, currency)
84
84
  else
85
85
  Spree::Money::Rounding.to_currency(cost * tax_rate.amount, currency)
@@ -90,14 +90,22 @@ module Spree
90
90
  #
91
91
  # @return [BigDecimal]
92
92
  def additional_tax_total
93
- tax_rate&.included_in_price? ? BigDecimal(0) : tax_amount
93
+ tax_rate && tax_inclusive? ? BigDecimal(0) : tax_amount
94
94
  end
95
95
 
96
96
  # Returns the included tax total for the shipping rate
97
97
  #
98
98
  # @return [BigDecimal]
99
99
  def included_tax_total
100
- tax_rate&.included_in_price? ? tax_amount : BigDecimal(0)
100
+ tax_rate && tax_inclusive? ? tax_amount : BigDecimal(0)
101
+ end
102
+
103
+ # Whether the cost already includes its tax, which the sale's market
104
+ # decides (Spree::Purchase::Taxation#tax_inclusive?).
105
+ #
106
+ # @return [Boolean]
107
+ def tax_inclusive?
108
+ fulfillment&.owner&.tax_inclusive? || false
101
109
  end
102
110
 
103
111
  alias tax_total tax_amount
@@ -6,7 +6,7 @@ module Spree
6
6
  # Stored as a string class name on DeliveryMethod and constantized at call
7
7
  # time; registered via Spree.delivery_rate_providers. The Estimator
8
8
  # dispatches here in place of calling the calculator directly, so
9
- # filtering, VAT gross-up, tax resolution and sorting stay in one place
9
+ # filtering, markup, tax resolution and sorting stay in one place
10
10
  # for every provider.
11
11
  #
12
12
  # External providers resolve credentials through the delivery method's
@@ -2,10 +2,11 @@ module Spree
2
2
  module DeliveryRateProvider
3
3
  # A single quote returned by a delivery rate provider.
4
4
  #
5
- # +cost+ is pre-tax and pre-VAT-gross-up: the Estimator applies
6
- # +gross_amount+ and resolves the tax rate afterwards, exactly as it does
7
- # for calculator output. Everything else is carrier metadata that flows
8
- # onto the persisted DeliveryRate.
5
+ # +cost+ is the quoted price: the Estimator adds markup and resolves the
6
+ # tax rate afterwards, exactly as it does for calculator output, and the
7
+ # market decides whether that tax is inside the cost or added on top.
8
+ # Everything else is carrier metadata that flows onto the persisted
9
+ # DeliveryRate.
9
10
  class Estimate
10
11
  include ActiveModel::Model
11
12
  include ActiveModel::Attributes
@@ -5,6 +5,7 @@ module Spree
5
5
  include Spree::DerivedNumber
6
6
  derives_number infix: 'F'
7
7
  include Spree::HasCustomFields
8
+ include Spree::HasExternalReferences
8
9
  include Spree::Metadata
9
10
  include Spree::StorePreferences
10
11
  if defined?(Spree::Security::Shipments)
@@ -70,6 +70,13 @@ module Spree
70
70
  Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization)
71
71
  end
72
72
 
73
+ # A made-up address, so the dashboard link can be tried without a provider.
74
+ def gateway_dashboard_payment_url(payment)
75
+ return if payment.transaction_id.blank?
76
+
77
+ "https://dashboard.bogus.example/payments/#{payment.transaction_id}"
78
+ end
79
+
73
80
  def test?
74
81
  # Test mode is not really relevant with bogus gateway (no such thing as live server)
75
82
  true
@@ -3,6 +3,7 @@ module Spree
3
3
  has_prefix_id :li # Spree-specific: line item
4
4
 
5
5
  include Spree::HasCustomFields
6
+ include Spree::HasExternalReferences
6
7
  include Spree::Metadata
7
8
 
8
9
  publishes_lifecycle_events
@@ -157,12 +158,6 @@ module Spree
157
158
 
158
159
  # Assigns the current price without saving — the caller is expected to.
159
160
  # {#recalculate_price} persists instead.
160
- #
161
- # Restatement for a tax-inclusive market happens inside
162
- # {Spree::Carts::PriceItems}: an address-less cart still has a taxing
163
- # country (its market's own), and without it a cross-border cart is taxed
164
- # for the destination at a price quoted for home, leaving the merchant to
165
- # absorb the difference.
166
161
  # persist: false — runs mid-build and from callers that save the record
167
162
  # themselves.
168
163
  def update_price
@@ -180,8 +175,8 @@ module Spree
180
175
  price_record = Spree::PricingProvider::Internal.new.price_for(context)
181
176
  return if price_record.blank?
182
177
 
183
- # Through the same writer every resolved price goes through, so the
184
- # tax-inclusive restatement inputs are stated once.
178
+ # Through the same writer every resolved price goes through, so a line
179
+ # built here is priced exactly like a resolved one.
185
180
  Spree::Carts::PriceItems.apply([[self, price_record]], persist: false)
186
181
  end
187
182