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,654 @@
1
+ module Spree
2
+ module AgentTools
3
+ # One agent tool per exposed workflow.
4
+ #
5
+ # Every back-office write already goes through a workflow, so there is
6
+ # nothing to hand-write: the allowlist names a `Spree::Dependencies` key
7
+ # and a permission, {WorkflowSchema} reads the argument contract off the
8
+ # workflow's own `perform`, and this class resolves the arguments, injects
9
+ # the principal, takes the lock the controller takes and shapes the result.
10
+ #
11
+ # Generated classes are anonymous subclasses cached per dependency key, so
12
+ # a host app that swaps `Spree.order_cancel_workflow` for its own class
13
+ # keeps the tool and gets the replacement's schema.
14
+ class WorkflowTool < Spree::AgentTool
15
+ class_attribute :dependency_key, instance_accessor: false
16
+ class_attribute :schema_except, instance_accessor: false, default: [].freeze
17
+ class_attribute :summary_override, instance_accessor: false
18
+
19
+ class << self
20
+ # Builds (and caches) the tool class for one exposed workflow.
21
+ #
22
+ # @param dependency_key [Symbol] a `Spree::Dependencies` key
23
+ # @param permission [String] the `write_*` key gating it
24
+ # @param except [Array<Symbol>] parameters to hide
25
+ # @param summary [#call, nil] overrides the generated confirmation line
26
+ # @return [Class<WorkflowTool>, nil] nil when the key does not resolve
27
+ def for(dependency_key, permission:, except: [], summary: nil)
28
+ workflow_class = resolve_workflow(dependency_key)
29
+ return if workflow_class.nil?
30
+
31
+ # Keyed by what actually shapes the class, including the workflow's
32
+ # own source location and mtime: a tool's parameters are read off
33
+ # `perform`, so editing a workflow in development has to produce a
34
+ # new tool rather than serve the schema built before the reload.
35
+ # That is the same reason the registry stores names and not classes.
36
+ #
37
+ # A summary override is a callable with no stable identity, so its
38
+ # presence is part of the key but the object is not — otherwise
39
+ # every reload would leave a permanent entry behind.
40
+ cache_key = [dependency_key, workflow_class.name, source_fingerprint(workflow_class),
41
+ permission, except, !summary.nil?]
42
+ cached = cache[cache_key]
43
+ return cached if cached && cached.summary_override.equal?(summary)
44
+
45
+ cache[cache_key] = build(dependency_key, workflow_class, permission, except, summary)
46
+ end
47
+
48
+ # @return [void]
49
+ def clear_cache!
50
+ @cache = {}
51
+ end
52
+
53
+ # Identifies the source a tool's schema was read from, so an edit in
54
+ # development invalidates the generated class. Nil in a packaged gem
55
+ # whose source is not on disk, which simply means the key never
56
+ # changes there — correct, since the source cannot change either.
57
+ #
58
+ # @return [Array, nil]
59
+ def source_fingerprint(workflow_class)
60
+ file, = workflow_class.instance_method(:perform).source_location
61
+ return if file.nil?
62
+
63
+ [file, File.mtime(file)]
64
+ rescue StandardError
65
+ nil
66
+ end
67
+
68
+ # The tool name for a dependency key: the workflow's dotted identity
69
+ # with dots as underscores, because MCP names allow only letters,
70
+ # digits, underscore and hyphen.
71
+ #
72
+ # @param workflow_class [Class]
73
+ # @return [String]
74
+ def tool_name_for(workflow_class)
75
+ workflow_class.workflow_key.tr('.', '_')
76
+ end
77
+
78
+ # @return [Class, nil]
79
+ def resolve_workflow(dependency_key)
80
+ Spree.public_send(dependency_key)
81
+ rescue NoMethodError, NameError
82
+ nil
83
+ end
84
+
85
+ # The workflow's own leading comment, first paragraph — what the class
86
+ # says it does, in the author's words rather than a second description
87
+ # kept in step by hand.
88
+ #
89
+ # @param workflow_class [Class]
90
+ # @return [String]
91
+ def description_for(workflow_class)
92
+ paragraph = leading_comment(workflow_class)
93
+ return "Runs the #{workflow_class.workflow_key.tr('.', ' ')} workflow." if paragraph.blank?
94
+
95
+ paragraph
96
+ end
97
+
98
+ private
99
+
100
+ def cache
101
+ @cache ||= {}
102
+ end
103
+
104
+ def build(dependency_key, workflow_class, permission_key, except, summary)
105
+ derived_schema = WorkflowSchema.new(workflow_class, except: except)
106
+ derived_name = tool_name_for(workflow_class)
107
+ derived_description = description_for(workflow_class)
108
+
109
+ Class.new(self) do
110
+ self.dependency_key = dependency_key
111
+ self.schema_except = except.freeze
112
+ self.summary_override = summary
113
+
114
+ tool_name derived_name
115
+ description derived_description
116
+ permission permission_key
117
+ mutating!
118
+
119
+ derived_schema.parameters.each do |parameter|
120
+ param parameter[:name],
121
+ type: parameter[:json_type].to_sym,
122
+ items: parameter[:item_type]&.to_sym,
123
+ description: parameter[:description],
124
+ required: parameter[:required]
125
+ end
126
+ end
127
+ end
128
+
129
+ def leading_comment(workflow_class)
130
+ location = Object.const_source_location(workflow_class.name)
131
+ return if location.nil?
132
+
133
+ file, line = location
134
+ return unless file && File.exist?(file)
135
+
136
+ lines = File.readlines(file)
137
+ comment = []
138
+ index = line - 2
139
+ while index >= 0 && lines[index].strip.start_with?('#')
140
+ comment.unshift(lines[index].strip.delete_prefix('#').strip)
141
+ index -= 1
142
+ end
143
+ # First paragraph only — the rest is design rationale for readers of
144
+ # the class, not for a model choosing between tools.
145
+ comment.take_while(&:present?).join(' ').presence
146
+ end
147
+ end
148
+
149
+ # @return [Class] the workflow this tool runs, resolved now so a host
150
+ # app's replacement is picked up without a restart
151
+ def workflow_class
152
+ self.class.resolve_workflow(self.class.dependency_key)
153
+ end
154
+
155
+ # @return [WorkflowSchema]
156
+ def schema
157
+ @schema ||= WorkflowSchema.new(workflow_class, except: self.class.schema_except)
158
+ end
159
+
160
+ # Runs the workflow with the caller's arguments.
161
+ #
162
+ # Model-typed arguments arrive as prefixed ids and are resolved through
163
+ # the resource map under the context's store, so another store's id is
164
+ # simply not found. Principal parameters are never accepted from the
165
+ # caller — they are filled from the authenticated principal.
166
+ #
167
+ # @return [Hash] the result handed back to the model
168
+ def call(**arguments)
169
+ resolved = resolve_arguments(arguments)
170
+ return resolved if resolved.is_a?(Hash) && resolved[:error]
171
+
172
+ # The endpoint runs this workflow behind its own routing,
173
+ # authentication, authorization and serializer, so when there is one
174
+ # the request goes through it rather than beside it.
175
+ return dispatch(resolved) if endpoint
176
+
177
+ refusal = unauthorized_create || unauthorized_record(resolved)
178
+ return refusal if refusal
179
+
180
+ keywords = resolved.merge(principal_arguments).merge(context_arguments)
181
+
182
+ run(keywords, subject: subject_record(resolved))
183
+ rescue ArgumentError => e
184
+ { error: e.message }
185
+ end
186
+
187
+ # @return [Spree::AgentTools::WorkflowEndpoints::Entry, nil]
188
+ def endpoint
189
+ return @endpoint if defined?(@endpoint)
190
+
191
+ @endpoint = WorkflowEndpoints.find(self.class.dependency_key)
192
+ end
193
+
194
+ # One line saying what this tool did, for the client's confirmation
195
+ # prompt and the assistant's approval card.
196
+ #
197
+ # @param arguments [Hash]
198
+ # @return [String]
199
+ def summary(arguments, subject: nil)
200
+ return self.class.summary_override.call(context, arguments) if self.class.summary_override
201
+
202
+ action = workflow_class.workflow_key.tr('.', ' ').humanize
203
+ subject ||= subject_record(arguments)
204
+ [action, label_for(subject)].compact.join(' ')
205
+ end
206
+
207
+ private
208
+
209
+ # Runs the workflow by calling the operation that runs it.
210
+ #
211
+ # The path's `:id` is the record the write is about; a parent segment
212
+ # (`:order_id` on a payment) is read from that record rather than asked
213
+ # for, since the caller named the payment and the order follows from it.
214
+ # Everything else travels in the body under the name the endpoint
215
+ # documents, which is not always the workflow's keyword — a
216
+ # cancellation's `note:` is `cancel_note` on the wire.
217
+ def dispatch(resolved)
218
+ subject = subject_record(resolved)
219
+ path, missing = endpoint_path(subject, resolved)
220
+ return { error: missing } if missing
221
+
222
+ response = ApiDispatch.new(context).call(
223
+ method: endpoint.verb.downcase.to_sym, path: path, body: dispatch_body(resolved)
224
+ )
225
+ return { error: response.error_message } unless response.success?
226
+
227
+ { summary: summary(resolved, subject: subject),
228
+ record: payload_summary(response.data) }.compact
229
+ end
230
+
231
+ def endpoint_path(subject, resolved)
232
+ values = {}
233
+
234
+ endpoint.path_params.each do |segment|
235
+ record = segment == 'id' ? subject : parent_for(segment, subject, resolved)
236
+ return [nil, "This tool needs a #{segment.sub(/_id\z/, '').humanize.downcase} it was not given."] if
237
+ record.nil?
238
+
239
+ values[segment] = record.prefixed_id
240
+ end
241
+
242
+ [endpoint.path_for(values), nil]
243
+ end
244
+
245
+ # A parent segment names an association: `:order_id` on a payment is
246
+ # `payment.order`. A record the caller named for that association is
247
+ # preferred, so a split payment is refunded against the order the
248
+ # caller meant.
249
+ def parent_for(segment, subject, resolved)
250
+ name = segment.sub(/_id\z/, '')
251
+ named = resolved[name.to_sym]
252
+ return named if named.is_a?(ActiveRecord::Base)
253
+
254
+ from_subject = subject.try(name)
255
+ return from_subject if from_subject
256
+
257
+ # A keyword whose name does not match the segment but whose record is
258
+ # what the segment addresses: a label's `owner:` is the fulfillment in
259
+ # `/fulfillments/:fulfillment_id`, and the same tool reaches a return
260
+ # through `/returns/:return_id`.
261
+ records(resolved).find { |record| record.class.name.demodulize.underscore == name }
262
+ end
263
+
264
+ # What the operation accepts, under its own names. A record resolved for
265
+ # a path segment is already in the path; anything else the endpoint does
266
+ # not document is left out rather than sent and ignored.
267
+ def dispatch_body(resolved)
268
+ accepted = endpoint.body_keys
269
+ return {} if accepted.empty?
270
+
271
+ resolved.each_with_object({}) do |(name, value), body|
272
+ # An `attributes` hash is not a field of the body — it IS the body.
273
+ # The endpoint documents its keys one by one, so it merges in
274
+ # rather than travelling under a name of its own.
275
+ if name.to_s == 'attributes' && value.is_a?(Hash)
276
+ body.merge!(value.transform_keys(&:to_s))
277
+ next
278
+ end
279
+
280
+ key = body_key_for(name, accepted)
281
+ next if key.nil?
282
+
283
+ body[key] = value.is_a?(ActiveRecord::Base) ? value.prefixed_id : value
284
+ end
285
+ end
286
+
287
+ # The endpoint's name for a workflow keyword.
288
+ #
289
+ # Exact first, then the `_id` form a record takes on the wire. Failing
290
+ # both, the one accepted key that ENDS in the keyword: a cancellation's
291
+ # `reason:` is `cancel_reason_id` and its `note:` is `cancel_note`,
292
+ # qualified by the action rather than the resource. Matched on the
293
+ # suffix rather than by guessing the qualifier, and only when exactly
294
+ # one key matches — two would make the choice a coin toss, and a
295
+ # silently misplaced value is worse than a refusal.
296
+ def body_key_for(name, accepted)
297
+ exact = [name.to_s, "#{name}_id"].find { |candidate| accepted.include?(candidate) }
298
+ return exact if exact
299
+
300
+ suffixed = accepted.select { |key| key.end_with?("_#{name}", "_#{name}_id") }
301
+ suffixed.first if suffixed.one?
302
+ end
303
+
304
+ # A record as the endpoint returned it, summarised the way a record read
305
+ # from the map would be.
306
+ def payload_summary(data)
307
+ return unless data.is_a?(Hash)
308
+
309
+ entry = ResourceMap.all.find { |candidate| candidate.key == data['object'] } ||
310
+ subject_entry
311
+ return { id: data['id'] } if entry.nil?
312
+
313
+ RecordSummary.from_payload(entry: entry, payload: data)
314
+ end
315
+
316
+ # Holding `write_orders` says an admin may change orders; it does not say
317
+ # they may change *this* one. A host app narrows that with a
318
+ # record-level rule, the Admin API honours it, and a workflow tool must
319
+ # too — otherwise an agent writes records its user could not touch by
320
+ # clicking. Every record the workflow was handed is checked, not just the
321
+ # subject, since a cancellation reason from another seller is as much a
322
+ # leak as the order itself.
323
+ def unauthorized_record(arguments)
324
+ # A create's own action is checked against the class above; the records
325
+ # it was handed are collaborators (a supplier, a stock location), so
326
+ # they are checked for the read-and-use they actually get.
327
+ action = ability_action == :create ? :show : ability_action
328
+
329
+ records(arguments).each do |record|
330
+ refusal = unauthorized(action, record)
331
+ return refusal if refusal
332
+ end
333
+
334
+ nil
335
+ end
336
+
337
+ # A create workflow has no record to check yet — the workflow builds it
338
+ # — so the Admin API's `authorize_resource!(@resource, :create)` has no
339
+ # counterpart here. Authorize against the class instead, which is what a
340
+ # record-level rule on a create would be evaluated against anyway.
341
+ #
342
+ # @return [Hash, nil]
343
+ def unauthorized_create
344
+ return unless ability_action == :create
345
+
346
+ # A key whose model does not resolve leaves nothing to authorize
347
+ # against, and `can?(:create, nil)` answers true — so the check would
348
+ # pass for exactly the workflow nobody can vouch for. The attribute
349
+ # filter is keyed off the same lookup and would also fall open, which
350
+ # is the tenancy hole it exists to close.
351
+ model = created_model
352
+ return { error: 'This tool cannot be used: its workflow names a resource that does not exist.' } if model.nil?
353
+ return if context.can?(:create, model)
354
+
355
+ { error: "You do not have permission to create this #{model.model_name.human.downcase}." }
356
+ end
357
+
358
+ # What the workflow creates, named by its own key: `products.create`
359
+ # creates a product. A key whose model cannot be resolved falls back to
360
+ # the subject check alone rather than guessing.
361
+ #
362
+ # @return [Class, nil]
363
+ def created_model
364
+ @created_model ||= "Spree::#{workflow_class.workflow_key.split('.').first.classify}".safe_constantize
365
+ end
366
+
367
+ # Which CanCanCan action this workflow amounts to. The stock catalog
368
+ # grants writes as `:manage`, which covers everything — but an ability
369
+ # may be replaced, and a host app that grants `:update` without
370
+ # `:destroy` means it: a deletion checked as an update would go through.
371
+ #
372
+ # @return [Symbol]
373
+ def ability_action
374
+ case workflow_class.workflow_key.split('.').last
375
+ when 'destroy' then :destroy
376
+ when 'create' then :create
377
+ else :update
378
+ end
379
+ end
380
+
381
+ # Only what the caller named. The store and the principal are injected
382
+ # from the credential, so checking them would ask whether the caller may
383
+ # "update" their own store or their own user — which an order-desk role
384
+ # legitimately may not, and which has nothing to do with the write they
385
+ # actually asked for.
386
+ def records(arguments)
387
+ arguments.values.select { |value| value.is_a?(ActiveRecord::Base) }
388
+ end
389
+
390
+ # The record the write is about, for the lock and the confirmation line.
391
+ def subject_record(arguments)
392
+ records(arguments).first
393
+ end
394
+
395
+ def run(keywords, subject: nil)
396
+ result = with_subject_lock(subject) { workflow_class.call(**keywords) }
397
+
398
+ if result.success?
399
+ { summary: summary(keywords, subject: result.value.is_a?(ActiveRecord::Base) ? result.value : subject),
400
+ record: record_result(result.value) }
401
+ else
402
+ { error: error_text(result) }
403
+ end
404
+ end
405
+
406
+ # The same row lock the admin controllers take for an order-subject
407
+ # workflow. Taken generically: whichever record the workflow is acting
408
+ # on is the one that must not change underneath it.
409
+ def with_subject_lock(subject, &)
410
+ return yield unless subject.respond_to?(:with_lock) && subject.persisted?
411
+
412
+ subject.with_lock(&)
413
+ end
414
+
415
+ def record_result(value)
416
+ return unless value.is_a?(ActiveRecord::Base)
417
+
418
+ entry = ResourceMap.all.find { |candidate| value.is_a?(candidate.model_class) }
419
+ return { id: value.try(:prefixed_id) } if entry.nil?
420
+
421
+ RecordSummary.call(entry: entry, record: value)
422
+ end
423
+
424
+ # The workflow's own refusal, in its own words, so the model can correct
425
+ # itself instead of retrying blind.
426
+ def error_text(result)
427
+ error = result.error
428
+ errors = error.respond_to?(:value) ? error.value : error
429
+
430
+ return errors.full_messages.to_sentence if errors.respond_to?(:full_messages) && errors.any?
431
+ return error.to_s if error.present?
432
+
433
+ 'The workflow refused the change.'
434
+ end
435
+
436
+ # Who acted, for the workflow keywords that record it.
437
+ #
438
+ # Only where the record can actually hold this principal. Several
439
+ # models still declare `created_by` / `reviewed_by` as a plain
440
+ # `belongs_to` on the admin-user class rather than through `acted_by`,
441
+ # and handing those an API key raises AssociationTypeMismatch deep
442
+ # inside the workflow — a protocol-level failure where the honest
443
+ # outcome is simply an unattributed record.
444
+ def principal_arguments
445
+ principal = context.principal
446
+ schema.principal_parameters.
447
+ select { |name| principal_storable?(name, principal) }.
448
+ index_with { principal }
449
+ end
450
+
451
+ # @return [Boolean]
452
+ def principal_storable?(name, principal)
453
+ association = subject_model&.reflect_on_association(name)
454
+ return true if association.nil? || association.polymorphic?
455
+
456
+ expected = association.klass
457
+ principal.is_a?(expected)
458
+ rescue NameError
459
+ true
460
+ end
461
+
462
+ # The model this workflow writes, for checking what its associations
463
+ # will accept. Nil when the key names nothing resolvable.
464
+ def subject_model
465
+ return @subject_model if defined?(@subject_model)
466
+
467
+ @subject_model = created_model
468
+ rescue StandardError
469
+ @subject_model = nil
470
+ end
471
+
472
+ # Tenancy comes from the credential, never from a parameter.
473
+ def context_arguments
474
+ schema.context_parameters.index_with { context.store }
475
+ end
476
+
477
+ # Turns the caller's JSON arguments into what `perform` expects,
478
+ # resolving every model-typed parameter from its prefixed id.
479
+ #
480
+ # @return [Hash, Hash{Symbol => String}] keywords, or an `{error:}` result
481
+ def resolve_arguments(arguments)
482
+ resolved = {}
483
+
484
+ schema.parameters.each do |parameter|
485
+ name = parameter[:name]
486
+ next unless arguments.key?(name)
487
+
488
+ value = arguments[name]
489
+ next if value.nil?
490
+
491
+ if parameter[:model_name]
492
+ record = find_record(parameter[:model_name], value)
493
+ return { error: "No #{parameter[:model_name].demodulize.underscore.humanize.downcase} found for #{name} #{value.inspect}." } if record.nil?
494
+
495
+ resolved[name] = record
496
+ elsif parameter[:item_type] == 'object'
497
+ rows, refusal = resolve_rows(name, value)
498
+ return refusal if refusal
499
+
500
+ resolved[name] = rows
501
+ elsif parameter[:json_type] == 'object'
502
+ filtered, refusal = permitted_attributes(value)
503
+ return refusal if refusal
504
+
505
+ resolved[name] = filtered
506
+ else
507
+ resolved[name] = value
508
+ end
509
+ end
510
+
511
+ resolved
512
+ end
513
+
514
+ # Only the attributes the Admin API itself would accept for the record
515
+ # this workflow writes.
516
+ #
517
+ # A workflow is an internal service that has always trusted its caller,
518
+ # so it assigns the hash it is handed. The REST controllers filter first
519
+ # — `permitted_params` is a fixed list that never includes a tenancy
520
+ # key — and a tool must do the same before a workflow sees the payload.
521
+ # Without it a caller naming `store_id` writes into another merchant's
522
+ # store, since the workflow assigns the hash after setting tenancy.
523
+ #
524
+ # Unknown keys are named back rather than dropped, matching
525
+ # `ResourceWrite` so a model that guessed a field learns the real one.
526
+ #
527
+ # @return [Array(Hash, nil), Array(nil, Hash)]
528
+ def permitted_attributes(attributes)
529
+ return [attributes, nil] unless attributes.is_a?(Hash)
530
+
531
+ entry = subject_entry
532
+ if entry.nil?
533
+ # No entry means no list of what the Admin API would accept, and
534
+ # forwarding the hash unchecked is what let a caller name
535
+ # `store_id` and write into another merchant's store.
536
+ return [nil, { error: 'This tool cannot be used: its resource is not one an agent may write.' }]
537
+ end
538
+
539
+ attributes = attributes.transform_keys(&:to_s)
540
+ allowed = entry.writable_attribute_names
541
+ return [attributes, nil] if allowed.blank?
542
+
543
+ rejected = attributes.keys - allowed
544
+ return [attributes, nil] if rejected.empty?
545
+
546
+ [nil, { error: "#{entry.key} does not accept #{rejected.to_sentence}. " \
547
+ "Accepted attributes: #{allowed.to_sentence}." }]
548
+ end
549
+
550
+ # The map entry for what this workflow writes, which owns the list of
551
+ # attributes the Admin API accepts for it.
552
+ #
553
+ # @return [ResourceMap::Entry, nil]
554
+ def subject_entry
555
+ return @subject_entry if defined?(@subject_entry)
556
+
557
+ model = created_model || subject_model
558
+ @subject_entry = model && ResourceMap.all.find { |candidate| candidate.model_name == model.name }
559
+ rescue StandardError
560
+ @subject_entry = nil
561
+ end
562
+
563
+ # A list of hashes — a purchase order's or stock transfer's `items:` —
564
+ # names records by prefixed id inside each row, and the workflow builds
565
+ # associations from them. Without resolving those the association is
566
+ # handed a string and the record fails validation with "must exist",
567
+ # which reads to the model as a bad id rather than a missing step.
568
+ #
569
+ # @return [Array(Array<Hash>, nil), Array(nil, Hash)]
570
+ def resolve_rows(name, value)
571
+ rows = Array(value).map do |row|
572
+ next row unless row.is_a?(Hash)
573
+
574
+ resolved_row = {}
575
+ row.each do |field, field_value|
576
+ model_name = nested_model_name(field)
577
+
578
+ if model_name && field_value.is_a?(String)
579
+ record = find_record(model_name, field_value)
580
+ if record.nil?
581
+ return [nil, { error: "No #{field} found for #{field_value.inspect} in #{name}." }]
582
+ end
583
+
584
+ resolved_row[field.to_sym] = record
585
+ else
586
+ resolved_row[field.to_sym] = field_value
587
+ end
588
+ end
589
+ resolved_row
590
+ end
591
+
592
+ [rows, nil]
593
+ end
594
+
595
+ # Which model a row's field names, taken from the resource map so only
596
+ # something the caller may already read can be referenced.
597
+ #
598
+ # @return [String, nil]
599
+ def nested_model_name(field)
600
+ entry = Spree::AgentTools::ResourceMap.find(field.to_s.pluralize)
601
+ return unless entry
602
+ return unless context.permitted?(entry.permission)
603
+
604
+ entry.model_class.name
605
+ end
606
+
607
+ # Resolves a prefixed id within the caller's store, through the resource
608
+ # map — so tenancy is answered the same way every read is.
609
+ #
610
+ # A model the map does not hold is one with no store scoping of its own
611
+ # (a certificate belongs to a company, a submission to a seller). Those
612
+ # resolve through the mapped parent, so the id still has to belong to
613
+ # this store; a model with neither is not resolvable and the caller is
614
+ # told so rather than being handed another store's record.
615
+ def find_record(model_name, value)
616
+ relation = store_scoped_relation(model_name)
617
+ return if relation.nil?
618
+
619
+ relation.find_by_prefix_id(value)
620
+ rescue StandardError
621
+ nil
622
+ end
623
+
624
+ def store_scoped_relation(model_name)
625
+ entry = ResourceMap.all.find { |candidate| candidate.model_name == model_name }
626
+ return entry.scope_for(context) if entry
627
+
628
+ parent_scoped_relation(model_name.constantize)
629
+ end
630
+
631
+ # Narrows an unscoped model through whichever of its parents the map
632
+ # knows, so `certificate` is looked up among this store's companies'
633
+ # certificates rather than every store's.
634
+ def parent_scoped_relation(model_class)
635
+ model_class.reflect_on_all_associations(:belongs_to).each do |association|
636
+ next if association.polymorphic?
637
+
638
+ entry = ResourceMap.all.find { |candidate| candidate.model_name == association.klass.name }
639
+ next if entry.nil?
640
+
641
+ return model_class.where(association.foreign_key => entry.scope_for(context).select(:id))
642
+ end
643
+
644
+ nil
645
+ end
646
+
647
+ def label_for(record)
648
+ return if record.nil?
649
+
650
+ record.try(:number) || record.try(:name) || record.try(:prefixed_id)
651
+ end
652
+ end
653
+ end
654
+ end
@@ -0,0 +1,24 @@
1
+ require 'spree/agent_tools/registry'
2
+ require 'spree/agent_tools/context'
3
+ require 'spree/agent_tools/resource_map'
4
+ require 'spree/agent_tools/api_dispatch'
5
+ require 'spree/agent_tools/membership_map'
6
+ require 'spree/agent_tools/workflow_endpoints'
7
+ require 'spree/agent_tools/record_summary'
8
+ require 'spree/agent_tools/workflow_schema'
9
+ require 'spree/agent_tools/workflow_tool'
10
+ require 'spree/agent_tools/default_catalog'
11
+
12
+ module Spree
13
+ # The agent-tool contract: what an MCP client or the dashboard assistant may
14
+ # do on a merchant's behalf, and under whose authority.
15
+ #
16
+ # Nothing here talks to a model or a protocol. A tool is a name, a
17
+ # description, a JSON parameter schema, a permission key, a mutating flag
18
+ # and an executor — see {Spree::AgentTool} — and the adapters in `spree_mcp`
19
+ # and the dashboard assistant turn that into their own vocabulary.
20
+ #
21
+ # See docs/plans/6.0-mcp-server.md.
22
+ module AgentTools
23
+ end
24
+ end
@@ -157,7 +157,7 @@ module Spree
157
157
  preference :track_inventory_levels, :boolean, default: true, deprecated: 'Set it on the store instead' # Determines whether to track on_hand values for variants / products.
158
158
  preference :track_price_history, :boolean, default: true, deprecated: 'Set it on the store instead' # Records price changes for Omnibus Directive compliance. Disable for non-EU stores.
159
159
  preference :price_history_retention_days, :integer, default: 30, deprecated: 'Set it on the store instead' # Days to retain price history records. Used by spree:price_history:prune rake task.
160
- # SPREE_UPDATE_CHECK_DISABLED turns it off as well.
160
+ # SPREE_UPDATE_CHECK_DISABLED turns it off as well; it never runs in the test environment or on CI.
161
161
  preference :update_check_enabled, :boolean, default: true
162
162
  preference :use_user_locale, :boolean, default: true, deprecated: true
163
163