spree_core 6.0.0.rc2 → 6.0.0.rc4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) 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/taxation.rb +44 -3
  6. data/app/models/concerns/spree/translatable_resource.rb +49 -0
  7. data/app/models/spree/cart.rb +1 -0
  8. data/app/models/spree/delivery_rate.rb +12 -4
  9. data/app/models/spree/delivery_rate_provider/base.rb +1 -1
  10. data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
  11. data/app/models/spree/fulfillment.rb +1 -0
  12. data/app/models/spree/gateway/bogus.rb +7 -0
  13. data/app/models/spree/line_item.rb +3 -8
  14. data/app/models/spree/oauth_access_grant.rb +11 -0
  15. data/app/models/spree/oauth_access_token.rb +16 -0
  16. data/app/models/spree/oauth_application.rb +97 -0
  17. data/app/models/spree/oauth_consent_request.rb +23 -0
  18. data/app/models/spree/oauth_protected_resource.rb +21 -0
  19. data/app/models/spree/oauth_redirect.rb +12 -0
  20. data/app/models/spree/option_type.rb +5 -0
  21. data/app/models/spree/option_value.rb +5 -0
  22. data/app/models/spree/order.rb +5 -1
  23. data/app/models/spree/payment.rb +14 -2
  24. data/app/models/spree/payment_capture_event.rb +2 -0
  25. data/app/models/spree/policy.rb +6 -0
  26. data/app/models/spree/price.rb +16 -33
  27. data/app/models/spree/price_rule.rb +3 -16
  28. data/app/models/spree/price_rules/market_rule.rb +0 -6
  29. data/app/models/spree/pricing_provider/base.rb +1 -1
  30. data/app/models/spree/product.rb +1 -1
  31. data/app/models/spree/purchase_order_item.rb +1 -1
  32. data/app/models/spree/return.rb +1 -0
  33. data/app/models/spree/search_provider/database.rb +1 -1
  34. data/app/models/spree/stock/estimator.rb +4 -20
  35. data/app/models/spree/store.rb +8 -0
  36. data/app/models/spree/tax_exemption/item_override.rb +14 -0
  37. data/app/models/spree/tax_exemption.rb +26 -0
  38. data/app/models/spree/tax_exemption_certificate.rb +13 -2
  39. data/app/models/spree/tax_exemption_reason.rb +62 -0
  40. data/app/models/spree/tax_provider/base.rb +24 -4
  41. data/app/models/spree/tax_provider/internal.rb +36 -29
  42. data/app/models/spree/tax_rate.rb +9 -35
  43. data/app/models/spree/translations/batch.rb +14 -4
  44. data/app/services/spree/carts/price_items.rb +2 -8
  45. data/app/services/spree/carts/split_by_seller.rb +6 -1
  46. data/app/services/spree/carts/update.rb +34 -7
  47. data/app/services/spree/email_templates/preview.rb +24 -3
  48. data/app/services/spree/seeds/store_resources.rb +1 -0
  49. data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
  50. data/app/services/spree/tax/resolve_exemptions.rb +11 -2
  51. data/app/services/spree/update_check.rb +8 -1
  52. data/app/tools/spree/agent_tools/create_export.rb +116 -0
  53. data/app/tools/spree/agent_tools/create_import.rb +117 -0
  54. data/app/tools/spree/agent_tools/create_media.rb +81 -0
  55. data/app/tools/spree/agent_tools/create_resource.rb +36 -0
  56. data/app/tools/spree/agent_tools/curate_products.rb +90 -0
  57. data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
  58. data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
  59. data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
  60. data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
  61. data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
  62. data/app/tools/spree/agent_tools/get_resource.rb +97 -0
  63. data/app/tools/spree/agent_tools/import_tool.rb +80 -0
  64. data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
  65. data/app/tools/spree/agent_tools/query_report.rb +124 -0
  66. data/app/tools/spree/agent_tools/resource_write.rb +99 -0
  67. data/app/tools/spree/agent_tools/search_resources.rb +155 -0
  68. data/app/tools/spree/agent_tools/set_stock.rb +52 -0
  69. data/app/tools/spree/agent_tools/update_resource.rb +42 -0
  70. data/app/tools/spree/agent_tools/upload_file.rb +104 -0
  71. data/app/workflows/spree/carts/complete.rb +115 -2
  72. data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
  73. data/app/workflows/spree/orders/cancel.rb +8 -0
  74. data/app/workflows/spree/orders/complete.rb +8 -0
  75. data/app/{services → workflows}/spree/orders/create.rb +9 -9
  76. data/app/workflows/spree/purchase_orders/create.rb +3 -0
  77. data/app/workflows/spree/returns/refund.rb +4 -0
  78. data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
  79. data/app/workflows/spree/variants/create.rb +7 -4
  80. data/config/locales/en.yml +14 -0
  81. data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
  82. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
  83. data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
  84. data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
  85. data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
  86. data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
  87. data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
  88. data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
  89. data/db/sample_data/orders.rb +1 -1
  90. data/lib/spree/agent_tool.rb +188 -0
  91. data/lib/spree/agent_tools/api_dispatch.rb +204 -0
  92. data/lib/spree/agent_tools/context.rb +191 -0
  93. data/lib/spree/agent_tools/default_catalog.rb +180 -0
  94. data/lib/spree/agent_tools/membership_map.rb +61 -0
  95. data/lib/spree/agent_tools/record_summary.rb +219 -0
  96. data/lib/spree/agent_tools/registry.rb +131 -0
  97. data/lib/spree/agent_tools/resource_map.rb +352 -0
  98. data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
  99. data/lib/spree/agent_tools/workflow_schema.rb +279 -0
  100. data/lib/spree/agent_tools/workflow_tool.rb +654 -0
  101. data/lib/spree/agent_tools.rb +24 -0
  102. data/lib/spree/core/configuration.rb +1 -1
  103. data/lib/spree/core/controller_helpers/store.rb +3 -13
  104. data/lib/spree/core/dependencies.rb +9 -0
  105. data/lib/spree/core/engine.rb +35 -1
  106. data/lib/spree/core/model_extensions.rb +111 -0
  107. data/lib/spree/core/permissions/default_catalog.rb +6 -1
  108. data/lib/spree/core/pricing/price_resolution.rb +4 -4
  109. data/lib/spree/core/version.rb +1 -1
  110. data/lib/spree/core.rb +27 -2
  111. data/lib/spree/service_module.rb +11 -0
  112. data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
  113. data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
  114. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
  115. data/lib/tasks/markets.rake +47 -0
  116. data/lib/tasks/tax_exemption_reasons.rake +48 -0
  117. metadata +67 -7
  118. data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
@@ -9,6 +9,10 @@ module Spree
9
9
  # zone_id any more, but the column stays in the database through 6.0: it is
10
10
  # what `spree:migrate_tax_zones` converts from, and that task runs after
11
11
  # db:migrate. The physical drop lands in 6.1.
12
+ #
13
+ # The same goes for included_in_price: whether tax sits inside a price is
14
+ # the market's choice (Spree::Market#tax_inclusive), and the column is read
15
+ # only by `spree:markets:backfill_tax_inclusive`, which clears it.
12
16
 
13
17
  acts_as_paranoid
14
18
 
@@ -52,15 +56,10 @@ module Spree
52
56
  }
53
57
  # The same question asked with an address in hand.
54
58
  scope :for_address, ->(address) { for_jurisdiction(address&.country_code, address&.state_code) }
55
- # Country-wide: unlike for_jurisdiction(country_code, nil) this leaves the
56
- # state unconstrained, so it sums a country's state-level rates too. Used
57
- # where the state is irrelevant — backing VAT out of a gross price.
58
- scope :for_country, ->(country_code) { where(country_code: [country_code.presence, nil].uniq) }
59
59
  scope :for_tax_category,
60
60
  ->(category) { where(tax_category_id: category.try(:id)) }
61
- scope :included_in_price, -> { where(included_in_price: true) }
62
61
 
63
- self.whitelisted_ransackable_attributes = %w[amount rate country_code state_code tax_category_id included_in_price name]
62
+ self.whitelisted_ransackable_attributes = %w[amount rate country_code state_code tax_category_id name]
64
63
  ransack_alias :rate, :amount
65
64
  search_by :name
66
65
 
@@ -92,38 +91,13 @@ module Spree
92
91
  self.rate_percent = value
93
92
  end
94
93
 
95
- # The included-in-price rate to back out of a gross price.
96
- #
97
- # @param options [Hash] +:country+ (or +:address+) and +:tax_category+
98
- # @return [BigDecimal] 0 when the jurisdiction or category is unknown
99
- def self.included_tax_amount_for(options)
100
- # +:country+ is a documented override point and the VAT path still hands
101
- # it a Spree::Country, so take the code off whichever arrives.
102
- country = options[:country]
103
- country_code = country.try(:iso) || country.presence || options[:address]&.country_code
104
- return 0 unless country_code && options[:tax_category]
105
-
106
- scope = options[:address] ? for_address(options[:address]) : for_country(country_code)
107
- # Per-store tax configuration is the norm since 6.0, so without this the
108
- # sum would add up every store's rate for the same country.
109
- store = options[:store] || Spree::Current.store
110
- scope = scope.for_store(store) if store
111
- scope.included_in_price.for_tax_category(options[:tax_category]).sum(:amount)
112
- end
113
-
114
- def included?
115
- included_in_price
116
- end
117
-
118
- def additional?
119
- !included_in_price
120
- end
121
-
122
94
  # The customer-facing label snapshotted onto TaxLine rows.
123
95
  #
96
+ # @param included [Boolean] whether the tax sits inside the price, which
97
+ # the sale's market decides (Spree::Market#tax_inclusive)
124
98
  # @return [String]
125
- def adjustment_label
126
- I18n.t "spree.adjustment_labels.tax_rates.#{included_in_price? ? :including_tax : :excluding_tax}",
99
+ def adjustment_label(included: false)
100
+ I18n.t "spree.adjustment_labels.tax_rates.#{included ? :including_tax : :excluding_tax}",
127
101
  name: name.presence || tax_category.name,
128
102
  amount: amount_for_label
129
103
  end
@@ -45,12 +45,22 @@ module Spree
45
45
  @records = []
46
46
  end
47
47
 
48
- # Distinct +write_<resource>+ scopes a caller needs to authorize this
49
- # batch — one per resource type present. Lets the API layer gate an
50
- # API-key request without re-deriving scope names.
48
+ # Distinct write permissions a caller needs to authorize this batch —
49
+ # one per resource type present.
50
+ #
51
+ # Each resource names its own, because several are guarded by a
52
+ # permission not named after them: an option type rides
53
+ # `write_products`, a policy rides `write_settings`. Deriving the name
54
+ # from the type produced strings outside the permission catalog, which
55
+ # no credential could hold and which the API therefore skipped — so a
56
+ # grant narrowed to reading could still rewrite a store's policy text.
57
+ #
58
+ # A type that resolves to nothing is left out; `resolve_record!` is what
59
+ # reports it, one entry at a time with its index.
60
+ #
51
61
  # @return [Array<String>]
52
62
  def required_scopes
53
- @entries.map { |entry| "write_#{entry[:resource_type].to_s.pluralize}" }.uniq
63
+ @entries.filter_map { |entry| resource_class(entry[:resource_type])&.translation_write_permission }.uniq
54
64
  end
55
65
 
56
66
  # Upserts every entry in one transaction. Yields each resolved record
@@ -71,20 +71,14 @@ module Spree
71
71
  resolutions = Array(resolutions)
72
72
  return if resolutions.empty?
73
73
 
74
- # One derivation for the batch: every line in a resolution set belongs
75
- # to the same cart, and tax_address / tax_country walk associations and
76
- # a store preference that would otherwise be re-read per line.
77
- owner = resolutions.first.first.owner
78
- vat_inputs = { address: owner&.tax_address, country: owner&.tax_country, market: owner&.market }
79
-
80
74
  resolutions.each do |line_item, price|
81
75
  # Probe-based flows resolve against stand-ins, so the guard has to
82
76
  # hold at the write too.
83
77
  next if line_item.manual_price?
84
78
 
85
- amount = price.price_including_vat_for(**vat_inputs)
79
+ amount = price.price
86
80
  # Marked before the blank guard: the provider has already answered,
87
- # so a restatement that comes back blank must still not send the
81
+ # so a price that comes back blank must still not send the
88
82
  # after_save callback back to the provider from inside the caller's
89
83
  # transaction. Only the assign-for-a-coming-save case needs it — a
90
84
  # persisted write goes through update_columns and fires no callbacks.
@@ -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
@@ -229,6 +230,29 @@ module Spree
229
230
  end
230
231
  end
231
232
 
233
+ # Three ways an external service can leave a sale unpriced, and a
234
+ # customer can only act on what they are told. A refused calculation
235
+ # carries the engine's own words, because "the postcode is not valid for
236
+ # that state" is something they can fix and "please try again" is not.
237
+ # The same split the API's own handler makes: a refusal names something
238
+ # to fix, an outage is worth retrying, and a price that could not be
239
+ # confirmed is neither tax case.
240
+ def unavailable_code_for(error)
241
+ case error
242
+ when Spree::Tax::CalculationRefused then 'tax_calculation_refused'
243
+ when Spree::Tax::ProviderError then 'tax_provider_unavailable'
244
+ else 'provider_unavailable'
245
+ end
246
+ end
247
+
248
+ def unavailable_message_for(error)
249
+ case error
250
+ when Spree::Tax::CalculationRefused then error.message.to_s
251
+ when Spree::Tax::ProviderError then I18n.t('spree.cart_line_item.tax_unavailable')
252
+ else I18n.t('spree.cart_line_item.pricing_unavailable')
253
+ end
254
+ end
255
+
232
256
  # Three-way dispatch on the cart→checkout transition:
233
257
  # entering checkout → Reserve, mid-checkout mutation → Extend, reverting to cart → Release.
234
258
  # A failed Reserve raises so the enclosing transaction rolls back and the
@@ -256,10 +280,13 @@ module Spree
256
280
  else
257
281
  cart.recalculate_totals!
258
282
  end
259
- rescue Spree::Pricing::PriceResolution::ProviderUnavailable
283
+ rescue Spree::Pricing::PriceResolution::ProviderUnavailable, Spree::Tax::ProviderError
260
284
  # Deliberately not swallowed like the rest: a strict store refusing to
261
285
  # price must roll the whole update back, so `call` sees it and the
262
- # address change does not commit around unconfirmed prices.
286
+ # address change does not commit around unconfirmed prices. A tax engine
287
+ # that cannot answer is the same problem one column over — swallowing it
288
+ # showed the customer a total with tax missing and saved the address
289
+ # anyway, so the mistake only surfaced when they tried to pay.
263
290
  raise
264
291
  rescue StandardError => e
265
292
  Rails.error.report(e, context: { order_id: cart.id }, source: 'spree.checkout')
@@ -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
@@ -0,0 +1,81 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Puts a file into the media library, optionally on a product.
4
+ #
5
+ # Creating media needs a file, which an agent had no way to supply until
6
+ # `upload_file` existed — which is why the resource reached the map
7
+ # read-only. The id that tool returns is what this one takes.
8
+ #
9
+ # A row with no owner is a library file: uploaded, not yet placed. Naming
10
+ # a product places it in that product's gallery, which is what a merchant
11
+ # handing over photographs usually means.
12
+ class CreateMedia < Spree::AgentTool
13
+ tool_name 'media_create'
14
+ description 'Add an uploaded file to the media library, or straight onto a product. ' \
15
+ 'Call upload_file first and pass the file_id it returns. Without a product ' \
16
+ 'the file sits in the library for the merchant to place later.'
17
+ permission 'write_media'
18
+ mutating!
19
+
20
+ param :file_id, description: 'The file_id upload_file returned', required: true
21
+ param :product_id, description: "A product's prefixed id, to place the file in its gallery"
22
+ param :alt, description: 'Alt text describing the image, for accessibility and SEO'
23
+
24
+ def call(file_id:, product_id: nil, alt: nil)
25
+ # Placement is the path: nested under a product puts the file in that
26
+ # product's gallery, the flat one leaves it in the library. The
27
+ # endpoint resolves the parent, checks it and verifies the signed id,
28
+ # so there is nothing to look up here first.
29
+ path = if product_id.present?
30
+ "/api/v3/admin/products/#{product_id}/media"
31
+ else
32
+ '/api/v3/admin/media'
33
+ end
34
+
35
+ response = ApiDispatch.new(context).call(
36
+ method: :post, path: path, body: { signed_id: file_id, alt: alt.presence }.compact
37
+ )
38
+ unless response.success?
39
+ # The endpoint speaks about signed references in general, since many
40
+ # of its parameters are one. Here it is always a file, so the
41
+ # refusal names the tool that produces a valid id.
42
+ message = response.error_message
43
+ if response.status == 422 && message.to_s.match?(/signed reference/i)
44
+ message = "#{file_id.inspect} is not a file this store uploaded, or it has expired. " \
45
+ 'Call upload_file first and pass the file_id it returns.'
46
+ end
47
+
48
+ return { error: message }
49
+ end
50
+
51
+ media = response.data.to_h
52
+
53
+ {
54
+ ok: true,
55
+ id: media['id'],
56
+ filename: media['filename'],
57
+ media_type: media['media_type'],
58
+ product_id: product_id.presence,
59
+ # The media library has no page of its own in the map, so the link
60
+ # that helps is the product's — where the merchant will look at it.
61
+ dashboard_path: product_id.presence && dashboard_path_for(product_id)
62
+ }.compact
63
+ end
64
+
65
+ def summary(arguments)
66
+ return "Add a file to #{arguments[:product_id]}'s gallery" if arguments[:product_id].present?
67
+
68
+ 'Add a file to the media library'
69
+ end
70
+
71
+ private
72
+
73
+ def dashboard_path_for(product_id)
74
+ path = ResourceMap.find('products')&.dashboard_path
75
+ return if path.blank?
76
+
77
+ format(path, id: product_id)
78
+ end
79
+ end
80
+ end
81
+ end