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
@@ -34,10 +34,15 @@ module Spree
34
34
  # recorded about the purchase (copied from the cart), so every child gets
35
35
  # the same hash — and so does the group. Once split, each order's
36
36
  # metadata is its own: edits never propagate between siblings.
37
+ #
38
+ # `applied_tax_exemptions` rides along, or a sibling resolves again and
39
+ # takes override ids that match nothing it owns. Line items keep their ids
40
+ # here; a divided fulfillment and its money rows do not, so an override
41
+ # naming one is stale — latent while nothing supplies overrides.
37
42
  CARRIED_TO_SIBLING = %w[
38
43
  email currency locale market_id channel_id company_id
39
44
  customer_id token accept_marketing preferred_stock_location_id
40
- customer_note last_ip_address po_number metadata
45
+ customer_note last_ip_address po_number metadata applied_tax_exemptions
41
46
  ].freeze
42
47
 
43
48
  # @param order [Spree::Order] the paid draft order, adopted as the
@@ -28,10 +28,11 @@ module Spree
28
28
  success(cart)
29
29
  rescue ActiveRecord::RecordNotFound
30
30
  raise
31
- rescue Spree::Pricing::PriceResolution::ProviderUnavailable => e
32
- # A strict store could not confirm a price. The transaction has rolled
33
- # back, so the address change is not committed either — better than
34
- # saving a destination whose prices nobody stood behind.
31
+ rescue Spree::Pricing::PriceResolution::ProviderUnavailable, Spree::Tax::ProviderError => e
32
+ # A strict store could not confirm a price, or the tax engine did not
33
+ # produce tax. The transaction has rolled back, so the address change is
34
+ # not committed either — better than saving a destination whose prices
35
+ # or tax nobody stood behind.
35
36
  #
36
37
  # The in-memory cart still holds the rolled-back attributes, though, so
37
38
  # a caller that renders the object it passed in would show the customer
@@ -48,7 +49,7 @@ module Spree
48
49
  end
49
50
  cart.warnings |= warnings if warnings.present?
50
51
 
51
- failure(cart, I18n.t('spree.cart_line_item.pricing_unavailable'))
52
+ failure(cart, { code: unavailable_code_for(e), message: unavailable_message_for(e) })
52
53
  rescue ActiveRecord::RecordInvalid => e
53
54
  failure(cart, e.record.errors.full_messages.to_sentence)
54
55
  rescue StandardError => e
@@ -205,10 +206,7 @@ module Spree
205
206
  end
206
207
 
207
208
  def resolve_address_id(prefixed_id)
208
- return unless cart.customer
209
-
210
- decoded = Spree::Address.decode_prefixed_id(prefixed_id)
211
- decoded ? cart.customer.addresses.find_by(id: decoded)&.id : nil
209
+ cart.selectable_address(prefixed_id)&.id
212
210
  end
213
211
 
214
212
  def assign_market
@@ -229,6 +227,29 @@ module Spree
229
227
  end
230
228
  end
231
229
 
230
+ # Three ways an external service can leave a sale unpriced, and a
231
+ # customer can only act on what they are told. A refused calculation
232
+ # carries the engine's own words, because "the postcode is not valid for
233
+ # that state" is something they can fix and "please try again" is not.
234
+ # The same split the API's own handler makes: a refusal names something
235
+ # to fix, an outage is worth retrying, and a price that could not be
236
+ # confirmed is neither tax case.
237
+ def unavailable_code_for(error)
238
+ case error
239
+ when Spree::Tax::CalculationRefused then 'tax_calculation_refused'
240
+ when Spree::Tax::ProviderError then 'tax_provider_unavailable'
241
+ else 'provider_unavailable'
242
+ end
243
+ end
244
+
245
+ def unavailable_message_for(error)
246
+ case error
247
+ when Spree::Tax::CalculationRefused then error.message.to_s
248
+ when Spree::Tax::ProviderError then I18n.t('spree.cart_line_item.tax_unavailable')
249
+ else I18n.t('spree.cart_line_item.pricing_unavailable')
250
+ end
251
+ end
252
+
232
253
  # Three-way dispatch on the cart→checkout transition:
233
254
  # entering checkout → Reserve, mid-checkout mutation → Extend, reverting to cart → Release.
234
255
  # A failed Reserve raises so the enclosing transaction rolls back and the
@@ -256,10 +277,13 @@ module Spree
256
277
  else
257
278
  cart.recalculate_totals!
258
279
  end
259
- rescue Spree::Pricing::PriceResolution::ProviderUnavailable
280
+ rescue Spree::Pricing::PriceResolution::ProviderUnavailable, Spree::Tax::ProviderError
260
281
  # Deliberately not swallowed like the rest: a strict store refusing to
261
282
  # price must roll the whole update back, so `call` sees it and the
262
- # address change does not commit around unconfirmed prices.
283
+ # address change does not commit around unconfirmed prices. A tax engine
284
+ # that cannot answer is the same problem one column over — swallowing it
285
+ # showed the customer a total with tax missing and saved the address
286
+ # anyway, so the mistake only surfaced when they tried to pay.
263
287
  raise
264
288
  rescue StandardError => e
265
289
  Rails.error.report(e, context: { order_id: cart.id }, source: 'spree.checkout')
@@ -0,0 +1,37 @@
1
+ module Spree
2
+ module DataRequests
3
+ # Confirms a request the customer raised, from the link emailed to the
4
+ # address it names, and queues the work.
5
+ #
6
+ # Confirming again is answered with the request as it stands, so a second
7
+ # click on the same link changes nothing.
8
+ class Confirm
9
+ prepend Spree::ServiceModule::Base
10
+
11
+ # @param data_request [Spree::DataRequest]
12
+ # @return [Spree::ServiceModule::Result] the confirmed request
13
+ def call(data_request:)
14
+ newly_confirmed = false
15
+
16
+ data_request.with_lock do
17
+ newly_confirmed = data_request.confirmable?
18
+
19
+ if newly_confirmed
20
+ data_request.update!(status: 'pending')
21
+ elsif data_request.awaiting_confirmation?
22
+ data_request.update!(status: 'lapsed')
23
+ end
24
+ end
25
+
26
+ if data_request.lapsed?
27
+ data_request.errors.add(:base, I18n.t('spree.data_request_errors.confirmation_invalid'))
28
+ return failure(data_request)
29
+ end
30
+
31
+ Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id) if newly_confirmed
32
+
33
+ success(data_request)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -7,9 +7,15 @@ module Spree
7
7
  # whole history, and a customer holding the button down should not be able
8
8
  # to queue a hundred of them. It is also the honest answer — the request
9
9
  # they already made is still being worked on.
10
+ #
11
+ # A request the customer raised themselves waits for confirmation from its
12
+ # email address instead of being queued, because it reaches guest orders
13
+ # placed at that address. Asking again while it waits sends the email again.
10
14
  class Create
11
15
  prepend Spree::ServiceModule::Base
12
16
 
17
+ CONFIRMATION_EVENT = 'data_request.confirmation_requested'.freeze
18
+
13
19
  # @param store [Spree::Store]
14
20
  # @param customer [Spree::Customer]
15
21
  # @param kind [String] Spree::DataRequest::ACCESS or ERASURE
@@ -19,8 +25,12 @@ module Spree
19
25
  # caller fulfils the request itself — the admin export renders the file
20
26
  # inline for a waiting staffer, and queueing would additionally email
21
27
  # the customer a copy of something they did not ask for
28
+ # @param confirm_by_email [Boolean] hold the request until it is
29
+ # confirmed from its email address; true for the customer's own requests
30
+ # @param redirect_url [String, nil] the storefront page the confirmation
31
+ # link opens, already checked against the store's allowed origins
22
32
  # @return [Spree::ServiceModule::Result]
23
- def call(store:, customer:, kind:, requested_by: nil, process: true)
33
+ def call(store:, customer:, kind:, requested_by: nil, process: true, confirm_by_email: false, redirect_url: nil)
24
34
  # Nothing useful is left to answer with: an export would return the
25
35
  # tombstone and email a link to an address that no longer receives,
26
36
  # and a second erasure has nothing to erase.
@@ -38,7 +48,9 @@ module Spree
38
48
  # of the same person's whole history; taking the lock closes it without
39
49
  # a partial unique index, which MySQL does not support.
40
50
  customer.with_lock do
41
- existing = in_flight_request(store: store, customer: customer, kind: kind)
51
+ lapse_unconfirmed_requests(store: store, customer: customer, kind: kind, staff_request: !confirm_by_email)
52
+ existing = in_flight_request(store: store, customer: customer, kind: kind,
53
+ include_unconfirmed: confirm_by_email)
42
54
 
43
55
  unless existing
44
56
  data_request = Spree::DataRequest.new(
@@ -46,20 +58,27 @@ module Spree
46
58
  customer: customer,
47
59
  kind: kind.to_s,
48
60
  email: customer.email,
49
- requested_by: requested_by
61
+ requested_by: requested_by,
62
+ status: confirm_by_email ? 'awaiting_confirmation' : 'pending'
50
63
  )
51
64
  data_request.save
52
65
  end
53
66
  end
54
67
 
55
- return success(existing) if existing
56
- return failure(data_request) unless data_request.persisted?
68
+ return failure(data_request) if data_request && !data_request.persisted?
57
69
 
58
- # Enqueued after the lock is released: the job may start immediately,
59
- # and it should not contend for the row this flow still holds.
60
- Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id) if process
70
+ request = existing || data_request
61
71
 
62
- success(data_request)
72
+ # Both happen after the lock is released: the job may start at once and
73
+ # should not contend for the row this flow holds, and the email must
74
+ # not go out before the row is committed.
75
+ if request.awaiting_confirmation?
76
+ request_confirmation(request, redirect_url: redirect_url)
77
+ elsif data_request && process
78
+ Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id)
79
+ end
80
+
81
+ success(request)
63
82
  end
64
83
 
65
84
  private
@@ -67,10 +86,39 @@ module Spree
67
86
  # Only the person's own requests. A staff member exporting from the admin
68
87
  # opens their own row and answers it inline, and handing that back here
69
88
  # would close the customer's request with a file they can never download.
70
- def in_flight_request(store:, customer:, kind:)
71
- Spree::DataRequest.where(store_id: store.id, customer_id: customer.id, kind: kind.to_s,
72
- requested_by_id: nil).
73
- in_progress.recent_first.first
89
+ # A request still waiting for confirmation is returned only to the
90
+ # customer: staff have checked who they are dealing with, so theirs
91
+ # proceeds on its own row (see lapse_unconfirmed_requests).
92
+ def in_flight_request(store:, customer:, kind:, include_unconfirmed:)
93
+ requests = Spree::DataRequest.where(store_id: store.id, customer_id: customer.id, kind: kind.to_s,
94
+ requested_by_id: nil)
95
+ open_requests = include_unconfirmed ? requests.in_progress.or(requests.confirmable) : requests.in_progress
96
+
97
+ open_requests.recent_first.first
98
+ end
99
+
100
+ # Lapses what can no longer be confirmed: requests past the window, and
101
+ # requests naming an address the account has since moved away from, so
102
+ # asking again emails the current address. A staff request of the same
103
+ # kind replaces the customer's unconfirmed one, which would otherwise
104
+ # run the same work a second time once confirmed.
105
+ def lapse_unconfirmed_requests(store:, customer:, kind:, staff_request:)
106
+ unconfirmed = Spree::DataRequest.where(store_id: store.id, customer_id: customer.id).awaiting_confirmation
107
+ stale = unconfirmed.past_confirmation_window.or(unconfirmed.where.not(email: customer.email))
108
+ stale = stale.or(unconfirmed.where(kind: kind.to_s)) if staff_request
109
+
110
+ stale.find_each { |data_request| data_request.update!(status: 'lapsed') }
111
+ end
112
+
113
+ # The token travels in a credential event, which reaches only webhook
114
+ # endpoints that name it and keeps the token out of the delivery log.
115
+ def request_confirmation(data_request, redirect_url:)
116
+ payload = data_request.event_payload_for(
117
+ CONFIRMATION_EVENT,
118
+ confirmation_token: data_request.generate_token_for(:confirmation),
119
+ redirect_url: redirect_url.presence
120
+ )
121
+ data_request.publish_event(CONFIRMATION_EVENT, payload)
74
122
  end
75
123
  end
76
124
  end
@@ -23,8 +23,11 @@ module Spree
23
23
  # @param email_key [String, nil] for the layout or a partial, the email to show it in
24
24
  # @param strict [Boolean] whether an unknown variable raises
25
25
  # @param branding [Hash] unsaved branding values (see Spree::Emails::Branding)
26
+ # @param mailer_logo_signed_id [String, nil] the signed id of an unsaved, uploaded email logo
27
+ # @param mailer_logo_cleared [Boolean] whether the email logo is previewed as removed
26
28
  def initialize(store:, key:, locale: Spree::EmailTemplate::ANY_LOCALE, subject: nil, body: nil,
27
- record_id: nil, email_key: nil, strict: false, branding: {})
29
+ record_id: nil, email_key: nil, strict: false, branding: {}, mailer_logo_signed_id: nil,
30
+ mailer_logo_cleared: false)
28
31
  @store = store
29
32
  @key = key
30
33
  @locale = locale.presence || Spree::EmailTemplate::ANY_LOCALE
@@ -34,16 +37,20 @@ module Spree
34
37
  @email_key = email_key
35
38
  @strict = strict
36
39
  @branding = branding
40
+ @mailer_logo_signed_id = mailer_logo_signed_id
41
+ @mailer_logo_cleared = mailer_logo_cleared
37
42
  end
38
43
 
39
44
  # @return [Spree::Emails::RenderedEmail]
40
45
  # @raise [Spree::EmailTemplates::NoSampleRecord] when the store has no record to build the sample from
46
+ # @raise [ActiveRecord::RecordInvalid] when the unsaved logo is one saving the store would refuse
47
+ # @raise [ActiveSupport::MessageVerifier::InvalidSignature] when the unsaved logo's signed id is not valid
41
48
  def call
42
49
  sample = email.sample_class.new(store: @store, record_id: @record_id)
43
50
 
44
51
  in_locale do
45
- renderer = Spree::Emails::Renderer.new(resolver: resolver, store: @store, currency: sample.currency, strict: @strict,
46
- branding: @branding)
52
+ renderer = Spree::Emails::Renderer.new(resolver: resolver, store: email_store, currency: sample.currency,
53
+ strict: @strict, branding: @branding)
47
54
  sample_variables = sample.variables
48
55
  @variables = renderer.variables(sample_variables)
49
56
  @rendered = renderer.render(resolver.find(email.key), sample_variables)
@@ -66,6 +73,20 @@ module Spree
66
73
  Spree.editable_email_templates[@key] || raise(ArgumentError, "#{@key} is not an editable email template")
67
74
  end
68
75
 
76
+ # The store as the email shows it. An unsaved logo upload or removal is
77
+ # applied to a copy that is never saved, and checked as saving the store
78
+ # checks it, so the preview never shows a logo the Save button refuses.
79
+ def email_store
80
+ return @store if @mailer_logo_signed_id.nil? && !@mailer_logo_cleared
81
+
82
+ @email_store ||= @store.class.find(@store.id).tap do |store|
83
+ store.mailer_logo = @mailer_logo_cleared ? nil : @mailer_logo_signed_id
84
+ store.validate
85
+ (store.errors.attribute_names - [:mailer_logo]).each { |attribute| store.errors.delete(attribute) }
86
+ raise ActiveRecord::RecordInvalid, store if store.errors.any?
87
+ end
88
+ end
89
+
69
90
  def resolver
70
91
  @resolver ||= Spree::Emails::TemplateResolver.for_mailers(store: @store, locale: language, drafts: drafts)
71
92
  end
@@ -18,6 +18,7 @@ module Spree
18
18
  Spree::Events.disable do
19
19
  ActiveRecord::Base.no_touching do
20
20
  TaxCategories.call(store: store)
21
+ TaxExemptionReasons.call(store: store)
21
22
  Channels.call(store: store)
22
23
  Roles.call(store: store)
23
24
  DigitalDelivery.call(store: store)
@@ -0,0 +1,28 @@
1
+ module Spree
2
+ module Seeds
3
+ # The reasons a business can give for buying without tax. Neutral codes
4
+ # that Spree's own engine keeps for reference; a store using an engine
5
+ # that reads them (Avalara) changes each code to that engine's.
6
+ class TaxExemptionReasons
7
+ prepend Spree::ServiceModule::Base
8
+ include StoreScoped
9
+
10
+ REASONS = {
11
+ 'RESALE' => 'Resale',
12
+ 'GOVERNMENT' => 'Government',
13
+ 'CHARITABLE' => 'Charitable organization',
14
+ 'EDUCATIONAL' => 'Educational institution',
15
+ 'MANUFACTURING' => 'Manufacturing',
16
+ 'AGRICULTURAL' => 'Agricultural'
17
+ }.freeze
18
+
19
+ private
20
+
21
+ def seed(store)
22
+ REASONS.each do |code, name|
23
+ store.tax_exemption_reasons.find_or_create_by_code_or_name!(code: code, name: name)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -29,9 +29,18 @@ module Spree
29
29
  return success([]) if company.nil? || address.nil?
30
30
 
31
31
  success(
32
- company.tax_exemption_certificates.active.for_address(address).map do |certificate|
32
+ company.tax_exemption_certificates.active.for_address(address).includes(:tax_exemption_reason).filter_map do |certificate|
33
+ reason = certificate.tax_exemption_reason
34
+ # A certificate recorded before reasons existed keeps its old code
35
+ # until `spree:upgrade:tax_exemption_reasons` links it. One with
36
+ # neither (its reason row removed outside the app) is skipped
37
+ # rather than failing tax for every order of the company.
38
+ reason_code = reason&.code || certificate.reason_code
39
+ next if reason_code.blank?
40
+
33
41
  Spree::TaxExemption.new(
34
- reason_code: certificate.reason_code,
42
+ reason_code: reason_code,
43
+ reason_name: reason&.name,
35
44
  certificate_number: certificate.certificate_number,
36
45
  country_code: certificate.country_code,
37
46
  state_code: certificate.state_code
@@ -34,9 +34,16 @@ module Spree
34
34
  LARGEST_BUCKET = '100k+'.freeze
35
35
 
36
36
  class << self
37
+ # Always false in the test environment and on CI servers (which set
38
+ # +CI+), so no test run ever reaches spreecloud.io; specs stub the
39
+ # request instead.
40
+ #
37
41
  # @return [Boolean] false when the operator turned the check off
38
42
  def enabled?
39
- Spree::Config.update_check_enabled && !env_flag?('SPREE_UPDATE_CHECK_DISABLED')
43
+ !Rails.env.test? &&
44
+ !env_flag?('CI') &&
45
+ Spree::Config.update_check_enabled &&
46
+ !env_flag?('SPREE_UPDATE_CHECK_DISABLED')
40
47
  end
41
48
 
42
49
  # When false, the check still runs but sends the running version only.
@@ -0,0 +1,116 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Exports records to a CSV the merchant can download.
4
+ #
5
+ # Mutating not because it changes the store — it does not — but because
6
+ # it produces a file of store data and emails a link to it. That leaves
7
+ # the building, so a merchant decides, not the model.
8
+ class CreateExport < Spree::AgentTool
9
+ tool_name 'create_export'
10
+ description 'Export records to a CSV file. Say which kind of records and, ' \
11
+ 'optionally, the same filters search_resources accepts. The merchant ' \
12
+ 'approves before the file is generated.'
13
+ # Exports are gated on the READ scope of what is being exported — the
14
+ # Admin API treats an export as a read, because that is what it is. That
15
+ # is a per-resource question, so it is answered in `call` rather than
16
+ # here: a class-level key would hide the tool from a caller who may
17
+ # export orders but not products.
18
+
19
+ # Exports whose rows carry a bearer instrument — a code that spends
20
+ # money for whoever reads it. Refused outright rather than stripped,
21
+ # because an export of gift cards without their codes answers nothing
22
+ # the merchant asked for. `Spree::Mcp::Exports` reads this list too, so
23
+ # a file made before this existed stays unreadable.
24
+ BEARER_EXPORTS = %w[gift_cards coupon_codes].freeze
25
+
26
+ permission nil
27
+ mutating!
28
+
29
+ param :resource, description: 'What to export: products, orders, customers',
30
+ required: true
31
+ param :filters, type: :object,
32
+ description: 'Optional Ransack filters, e.g. {"status_eq": "active"}'
33
+
34
+ def call(resource:, filters: nil)
35
+ export_class = export_class_for(resource)
36
+ return unknown_resource(resource) if export_class.nil?
37
+
38
+ required = "read_#{export_class.required_scope}"
39
+ unless context.holds?(required)
40
+ return { error: "You do not have permission to export #{resource.to_s.tr('_', ' ')}." }
41
+ end
42
+
43
+ # Refused here rather than at read, so the merchant is not left with a
44
+ # generated file of live codes waiting to be collected.
45
+ if Spree::AgentTools::CreateExport::BEARER_EXPORTS.include?(resource.to_s)
46
+ return { error: "#{resource.to_s.tr('_', ' ').capitalize} carry codes that spend money for " \
47
+ 'whoever reads them, so they cannot be exported here. Download them from ' \
48
+ 'the dashboard instead.' }
49
+ end
50
+
51
+ export = export_class.new(
52
+ store: context.store,
53
+ # For a signed-in admin the export builds an ability from this user,
54
+ # so the file holds exactly the records that admin could see,
55
+ # record-level rules included. For an API key there is no user and
56
+ # no ability: the scope check above is the whole filter, which is
57
+ # how the Admin API treats key requests everywhere else.
58
+ user: context.user,
59
+ format: 'csv',
60
+ search_params: filters.presence
61
+ )
62
+
63
+ refusal = unauthorized(:create, export)
64
+ return refusal if refusal
65
+
66
+ # Saving publishes `export.created`, which is what actually enqueues
67
+ # generation. (`generate_async` hands a raw id to a job that expects a
68
+ # prefixed one — the subscriber is the working path.)
69
+ return { error: export.errors.full_messages.to_sentence } unless export.save
70
+
71
+ {
72
+ ok: true,
73
+ id: export.prefixed_id,
74
+ resource: resource.to_s,
75
+ # Generation is a background job, so there is no file yet. Naming
76
+ # the tool that finishes the job keeps the model from sending the
77
+ # merchant to the dashboard for something it can fetch itself.
78
+ # Naming the MCP resource matters: a client that can read one will
79
+ # otherwise never learn the file is there, and the model reports a
80
+ # download path to a merchant instead of reading the rows itself.
81
+ resource_uri: "spree+export://#{export.prefixed_id}",
82
+ message: 'The export is being prepared. Once it is ready you can read its contents ' \
83
+ "from the MCP resource spree+export://#{export.prefixed_id}, or call " \
84
+ "get_resource (resource: \"exports\", id: \"#{export.prefixed_id}\") for its " \
85
+ 'status and a download_url to hand the merchant.'
86
+ }
87
+ end
88
+
89
+ def summary(arguments)
90
+ label = arguments[:resource].to_s.tr('_', ' ')
91
+ return "Export all #{label}" if arguments[:filters].blank?
92
+
93
+ filters = arguments[:filters].keys.map { |key| key.to_s.tr('_', ' ') }.join(', ')
94
+ "Export #{label} filtered by #{filters}"
95
+ end
96
+
97
+ private
98
+
99
+ # Only the export kinds Spree actually ships, resolved by name rather
100
+ # than constantized from model input.
101
+ def export_class_for(resource)
102
+ key = resource.to_s.downcase
103
+ Spree::Export.descendants.find do |klass|
104
+ klass.name.demodulize.underscore == key
105
+ end
106
+ end
107
+
108
+ def unknown_resource(resource)
109
+ {
110
+ error: "Cannot export #{resource.inspect}",
111
+ available: Spree::Export.descendants.map { |k| k.name.demodulize.underscore }.sort
112
+ }
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,117 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Starts a CSV import from a file the merchant provided.
4
+ #
5
+ # Gated on the WRITE scope of what is being imported, computed per call —
6
+ # a customers import needs `write_customers`, a products import
7
+ # `write_products`. A single class-level key could not say that, which is
8
+ # why the resource reached the map read-only; `create_export` answers the
9
+ # same question the same way.
10
+ #
11
+ # Creating the import reads the file's header row and proposes a column
12
+ # mapping. Nothing is written to the catalog until that mapping is
13
+ # confirmed, so this is the safe half of the wizard: the merchant still
14
+ # sees what the columns were taken to mean before any row lands.
15
+ class CreateImport < Spree::AgentTool
16
+ tool_name 'create_import'
17
+ description 'Start a CSV import from a file the merchant provided. Call upload_file ' \
18
+ 'first and pass the file_id it returns. The columns are read and a mapping ' \
19
+ 'proposed; no records change until the mapping is confirmed.'
20
+ # Per-resource, answered in `call` — a class-level key would hide the
21
+ # tool from a caller who may import one kind of record but not another.
22
+ permission nil
23
+ mutating!
24
+
25
+ param :resource, description: 'What the file holds: products, customers, purchase_orders',
26
+ required: true
27
+ param :file_id, description: 'The file_id upload_file returned', required: true
28
+
29
+ def call(resource:, file_id:)
30
+ import_class = import_class_for(resource)
31
+ return unknown_resource(resource) if import_class.nil?
32
+
33
+ required = "write_#{import_class.required_scope}"
34
+ unless context.holds?(required)
35
+ return { error: "You do not have permission to import #{resource.to_s.tr('_', ' ')}." }
36
+ end
37
+
38
+ start(import_class, resource, file_id)
39
+ end
40
+
41
+ def summary(arguments)
42
+ "Start a #{arguments[:resource].to_s.tr('_', ' ')} import"
43
+ end
44
+
45
+ private
46
+
47
+ def start(import_class, resource, file_id)
48
+ owner = importing_user
49
+ return { error: no_user_error } if owner.nil?
50
+
51
+ import = import_class.new(store: context.store, user: owner)
52
+ import.attachment = file_id
53
+
54
+ refusal = unauthorized(:create, import)
55
+ return refusal if refusal
56
+ return { error: import.errors.full_messages.to_sentence } unless import.save
57
+
58
+ mapping = Spree.import_start_mapping_workflow.call(import: import)
59
+ return { error: mapping_failure(mapping, import) } unless mapping.success?
60
+
61
+ {
62
+ ok: true,
63
+ id: import.prefixed_id,
64
+ resource: resource.to_s,
65
+ status: import.reload.status,
66
+ # The mapping is the point of this step: the model should read it
67
+ # back and tell the merchant what the columns were taken to mean
68
+ # before anything is written.
69
+ next_step: "Call describe_import_mapping with id #{import.prefixed_id} to see the " \
70
+ 'columns, then confirm it to run the import.',
71
+ dashboard_path: ResourceMap.find('imports')&.dashboard_path_for(import)
72
+ }
73
+ rescue ::CSV::MalformedCSVError, EncodingError => e
74
+ { error: "That file could not be read as CSV: #{e.message}" }
75
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
76
+ { error: "#{file_id.inspect} is not a file this store uploaded. Call upload_file first." }
77
+ rescue ActiveStorage::FileNotFoundError
78
+ { error: 'That upload did not finish — its bytes are missing. Upload the file again.' }
79
+ end
80
+
81
+ def mapping_failure(result, import)
82
+ error = result.error
83
+ message = error.try(:value).is_a?(String) ? error.value : error.to_s
84
+
85
+ message.presence || import.processing_errors.presence || 'The file could not be mapped.'
86
+ end
87
+
88
+ # An import belongs to a person — it emails them when it finishes and
89
+ # its failed rows are theirs to retry. A grant carries the admin who
90
+ # approved it; a standing key carries whoever minted it, which is the
91
+ # same fallback the Admin API uses.
92
+ def importing_user
93
+ return context.user if context.user.present?
94
+
95
+ creator = context.api_key&.created_by
96
+ creator if creator.is_a?(Spree.admin_user_class)
97
+ end
98
+
99
+ def no_user_error
100
+ 'An import has to belong to a staff member, and this credential names none. ' \
101
+ 'Connect an agent through the dashboard, or start the import there.'
102
+ end
103
+
104
+ def import_class_for(resource)
105
+ key = resource.to_s.downcase
106
+ Spree::Import.available_types.find { |klass| klass.name.demodulize.underscore == key }
107
+ end
108
+
109
+ def unknown_resource(resource)
110
+ {
111
+ error: "Cannot import #{resource.inspect}.",
112
+ available: Spree::Import.available_types.map { |k| k.name.demodulize.underscore }.sort
113
+ }
114
+ end
115
+ end
116
+ end
117
+ end