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
@@ -0,0 +1,279 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Reads a workflow's argument contract and turns it into a JSON parameter
4
+ # schema an agent can fill in.
5
+ #
6
+ # Nothing here is hand-maintained. `Method#parameters` on `perform` names
7
+ # every keyword and says which are required; the YARD `@param` block above
8
+ # it gives each one a type and a sentence of prose. Both already exist on
9
+ # every workflow, because the house style requires them — so a workflow
10
+ # gains a parameter and the tool's schema gains it in the same commit.
11
+ class WorkflowSchema
12
+ # A workflow that cannot be exposed as written. Raised at derivation time
13
+ # so the contract spec fails in CI rather than an agent meeting a broken
14
+ # tool in production.
15
+ class UndocumentedParameterError < StandardError
16
+ def initialize(workflow_class, names)
17
+ super("#{workflow_class} exposes #{names.join(', ')} without a YARD @param — " \
18
+ 'every parameter of an exposed workflow needs a documented type')
19
+ end
20
+ end
21
+
22
+ # Tenancy, which comes from the credential and never from the caller.
23
+ # A workflow that takes `store:` is given the context's store; offering
24
+ # it would invite a model to name another merchant's store, and the
25
+ # answer to "which store" is the API key or the signed-in admin.
26
+ CONTEXT_PARAMETERS = %i[store].freeze
27
+
28
+ # Actor keywords a workflow takes whose association has not been
29
+ # converted to `acted_by` yet — the 6.1 half of
30
+ # docs/plans/6.0-action-actors.md. They are principals by the same
31
+ # reasoning as the converted ones, so they are injected too; each drops
32
+ # off this list as its association converts, and the registry below then
33
+ # carries it.
34
+ UNCONVERTED_PRINCIPAL_PARAMETERS = %i[
35
+ reviewer reviewed_by rejected_by suspended_by verified_by reopened_by
36
+ ].freeze
37
+
38
+ # YARD type to JSON type. A model type is handled separately — it becomes
39
+ # a prefixed-id string resolved through the resource map.
40
+ PRIMITIVES = {
41
+ 'String' => 'string',
42
+ 'Symbol' => 'string',
43
+ 'Time' => 'string',
44
+ 'DateTime' => 'string',
45
+ 'Date' => 'string',
46
+ 'Boolean' => 'boolean',
47
+ 'TrueClass' => 'boolean',
48
+ 'FalseClass' => 'boolean',
49
+ 'Integer' => 'integer',
50
+ 'Numeric' => 'number',
51
+ 'Float' => 'number',
52
+ 'BigDecimal' => 'number',
53
+ 'Hash' => 'object',
54
+ 'Array' => 'array'
55
+ }.freeze
56
+
57
+ # @return [Class] the workflow this schema describes
58
+ attr_reader :workflow_class
59
+
60
+ # @param workflow_class [Class]
61
+ # @param except [Array<Symbol>] parameters to leave out of the schema
62
+ def initialize(workflow_class, except: [])
63
+ @workflow_class = workflow_class
64
+ @except = except.map(&:to_sym)
65
+ end
66
+
67
+ # Every parameter the caller may supply, in `perform` order.
68
+ #
69
+ # @return [Array<Hash>] `{name:, required:, json_type:, model_name:, description:}`
70
+ def parameters
71
+ @parameters ||= begin
72
+ documented = docs
73
+ undocumented = keywords.keys - documented.keys - injected_parameters - @except
74
+ raise UndocumentedParameterError.new(workflow_class, undocumented) if undocumented.any?
75
+
76
+ keywords.filter_map do |name, required|
77
+ next if @except.include?(name) || injected_parameters.include?(name)
78
+
79
+ build_parameter(name, required, documented.fetch(name))
80
+ end
81
+ end
82
+ end
83
+
84
+ # The principal keywords this workflow accepts, so the adapter knows
85
+ # which to inject.
86
+ #
87
+ # @return [Array<Symbol>]
88
+ def principal_parameters
89
+ keywords.keys & self.class.principal_parameter_names
90
+ end
91
+
92
+ # The tenancy keywords this workflow accepts.
93
+ #
94
+ # @return [Array<Symbol>]
95
+ def context_parameters
96
+ keywords.keys & CONTEXT_PARAMETERS
97
+ end
98
+
99
+ # Everything filled from the context rather than by the caller.
100
+ #
101
+ # @return [Array<Symbol>]
102
+ def injected_parameters
103
+ self.class.principal_parameter_names + CONTEXT_PARAMETERS
104
+ end
105
+
106
+ private
107
+
108
+ # `perform`'s keywords, mapped to whether they are required.
109
+ #
110
+ # @return [Hash{Symbol => Boolean}]
111
+ def keywords
112
+ @keywords ||= workflow_class.instance_method(:perform).parameters.filter_map do |kind, name|
113
+ [name, kind == :keyreq] if %i[key keyreq].include?(kind)
114
+ end.to_h
115
+ end
116
+
117
+ def build_parameter(name, required, doc)
118
+ types = doc[:types]
119
+ model_name = types.filter_map { |type| model_name_for(type) }.first
120
+
121
+ json_type = model_name ? 'string' : json_type_for(types)
122
+
123
+ {
124
+ name: name,
125
+ required: required,
126
+ model_name: model_name,
127
+ json_type: json_type,
128
+ item_type: json_type == 'array' ? item_type_for(types) : nil,
129
+ description: description_for(doc[:description], model_name)
130
+ }
131
+ end
132
+
133
+ def description_for(text, model_name)
134
+ return text if model_name.blank?
135
+
136
+ prefix = "Prefixed id of the #{model_name.demodulize.underscore.humanize.downcase}"
137
+ text.present? ? "#{prefix} — #{text}" : prefix
138
+ end
139
+
140
+ # A Spree model, addressed by prefixed id, as its canonical class name.
141
+ # A YARD block may name a deprecated alias (`Spree::ShippingMethod` for
142
+ # `Spree::DeliveryMethod`), and the alias is a constant pointing at the
143
+ # same class — so resolve the constant and ask it for its own name
144
+ # rather than trusting the spelling.
145
+ #
146
+ # `Spree.admin_user_class` and the bare `Object` of a principal
147
+ # parameter never reach here: those parameters are stripped first.
148
+ #
149
+ # @return [String, nil]
150
+ def model_name_for(type)
151
+ return unless type.start_with?('Spree::')
152
+
153
+ constant = type.safe_constantize
154
+ return unless constant.is_a?(Class) && constant < ActiveRecord::Base
155
+
156
+ constant.name
157
+ end
158
+
159
+ # What an array holds, read from the documented type: `Array<Hash>` holds
160
+ # objects, and anything else is treated as strings — the common case for
161
+ # a list of names or codes.
162
+ def item_type_for(types)
163
+ inner = types.filter_map { |type| type[/\AArray<(.+)>\z/, 1] }.first
164
+
165
+ PRIMITIVES[inner] || 'string'
166
+ end
167
+
168
+ def json_type_for(types)
169
+ concrete = types.reject { |type| type == 'nil' }
170
+ # `Array<Hash>` and friends — the outer container is what JSON sees.
171
+ concrete = concrete.map { |type| type.split('<').first }
172
+ concrete.filter_map { |type| PRIMITIVES[type] }.first || 'string'
173
+ end
174
+
175
+ # The YARD block above `perform`, parsed from the workflow's own source.
176
+ #
177
+ # Read from source rather than through the YARD gem: the gem is a
178
+ # development dependency, and this has to work in production, where the
179
+ # tool list is built on every request.
180
+ #
181
+ # @return [Hash{Symbol => Hash}]
182
+ def docs
183
+ @docs ||= self.class.parse_docs(workflow_class)
184
+ end
185
+
186
+ class << self
187
+ # Every keyword that names who acted, so none is ever offered to a
188
+ # caller as a free parameter.
189
+ #
190
+ # Read from {Spree::ActedBy} rather than listed here: the macro already
191
+ # records each actor association per model, exactly so a caller
192
+ # sweeping them walks all of them without being handed a list. A model
193
+ # that gains an actor gains it here in the same commit, and a name
194
+ # nobody declares cannot linger.
195
+ #
196
+ # @return [Array<Symbol>]
197
+ def principal_parameter_names
198
+ declared = if defined?(Spree::ActedBy)
199
+ Spree::ActedBy.models.flat_map(&:acted_by_associations).map(&:to_sym)
200
+ else
201
+ []
202
+ end
203
+
204
+ (declared | UNCONVERTED_PRINCIPAL_PARAMETERS).uniq
205
+ end
206
+
207
+ # Parsed once per workflow class and cached, because a tool list is
208
+ # built for every MCP request and the source never changes at runtime.
209
+ #
210
+ # @param workflow_class [Class]
211
+ # @return [Hash{Symbol => Hash}] `{types: [String], description: String}`
212
+ def parse_docs(workflow_class)
213
+ file, = workflow_class.instance_method(:perform).source_location
214
+ key = [workflow_class.name, file, (File.mtime(file) if file && File.exist?(file))]
215
+
216
+ cache[key] ||= parse_source(workflow_class)
217
+ end
218
+
219
+ # @return [void]
220
+ def clear_cache!
221
+ @cache = {}
222
+ end
223
+
224
+ private
225
+
226
+ def cache
227
+ @cache ||= {}
228
+ end
229
+
230
+ def parse_source(workflow_class)
231
+ source = comment_above_perform(workflow_class)
232
+ return {} if source.blank?
233
+
234
+ parse_param_tags(source)
235
+ end
236
+
237
+ # The comment block immediately above `def perform`, joined so a
238
+ # `@param` whose prose wraps onto the next line keeps it.
239
+ def comment_above_perform(workflow_class)
240
+ location = workflow_class.instance_method(:perform).source_location
241
+ return if location.nil?
242
+
243
+ file, line = location
244
+ return unless File.exist?(file)
245
+
246
+ lines = File.readlines(file)
247
+ comment = []
248
+ index = line - 2
249
+ while index >= 0 && lines[index].strip.start_with?('#')
250
+ comment.unshift(lines[index].strip.delete_prefix('#').strip)
251
+ index -= 1
252
+ end
253
+ comment
254
+ end
255
+
256
+ def parse_param_tags(comment_lines)
257
+ tags = {}
258
+ current = nil
259
+
260
+ comment_lines.each do |line|
261
+ if (match = line.match(/\A@param\s+(\w+)\s+\[([^\]]*)\]\s*(.*)\z/))
262
+ current = match[1].to_sym
263
+ tags[current] = {
264
+ types: match[2].split(',').map(&:strip).reject(&:empty?),
265
+ description: match[3].strip
266
+ }
267
+ elsif line.start_with?('@')
268
+ current = nil
269
+ elsif current && line.present?
270
+ tags[current][:description] = [tags[current][:description], line].reject(&:blank?).join(' ')
271
+ end
272
+ end
273
+
274
+ tags
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end