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
@@ -0,0 +1,104 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Stores a file the merchant provided, through the Admin API's own upload
4
+ # endpoint, and answers with the id every other write accepts.
5
+ #
6
+ # The dashboard uploads in two steps: ask for a storage target, then PUT
7
+ # the bytes to it. An agent has no second request to make, so this sends
8
+ # the bytes with the request — the multipart shape `POST /files` already
9
+ # accepts — and the endpoint owns the type allowlist, the size cap and
10
+ # which store the file belongs to.
11
+ #
12
+ # Storing a file is not yet using it: the id is attached by whichever
13
+ # tool the merchant asked for next, under that tool's own permission.
14
+ class UploadFile < Spree::AgentTool
15
+ tool_name 'upload_file'
16
+ description 'Store a file the merchant provided — a CSV to import, an image for a ' \
17
+ 'product, a document to attach — and return the id other tools accept. ' \
18
+ 'Send text as text; use encoding "base64" only for images, PDFs and other ' \
19
+ 'binary files. Storing a file does nothing on its own.'
20
+ # The endpoint's own floor, which is the gate the dashboard's uploader
21
+ # passes; whatever uses the file checks its own permission after.
22
+ permission 'write_products'
23
+ mutating!
24
+
25
+ param :filename, description: 'The file name, with its extension', required: true
26
+ param :content, description: 'The file contents — text as text, binary base64 encoded',
27
+ required: true
28
+ param :encoding, description: '"base64" for a binary file; omit for text'
29
+ param :content_type, description: "The file's media type, e.g. text/csv or application/pdf"
30
+
31
+ # A private upload accepts any type, which is right for the dashboard —
32
+ # a merchant picking a file has already decided. A model repeating a
33
+ # filename back has decided nothing, so an agent is held to the kinds
34
+ # it has a reason to send.
35
+ PERMITTED_CONTENT_TYPES = %w[
36
+ text/csv
37
+ text/plain
38
+ application/pdf
39
+ image/jpeg
40
+ image/png
41
+ image/webp
42
+ image/gif
43
+ application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
44
+ ].freeze
45
+
46
+ def call(filename:, content:, encoding: nil, content_type: nil)
47
+ bytes, error = decode(content, encoding)
48
+ return { error: error } if error
49
+
50
+ # Read from the bytes, not from what the caller named it: Marcel
51
+ # places the content first, so a renamed script is not a CSV.
52
+ kind = Marcel::MimeType.for(StringIO.new(bytes), name: filename,
53
+ declared_type: content_type.presence)
54
+ unless PERMITTED_CONTENT_TYPES.include?(kind)
55
+ return { error: "Files of type #{kind.inspect} are not accepted here.",
56
+ supported_content_types: PERMITTED_CONTENT_TYPES }
57
+ end
58
+
59
+ response = ApiDispatch.new(context).call(
60
+ method: :post,
61
+ path: '/api/v3/admin/files',
62
+ body: {
63
+ 'filename' => filename,
64
+ 'content_type' => kind,
65
+ # Agent uploads are private: a purchase order or a customer list
66
+ # is not something to put behind a public URL, and a private
67
+ # upload is the one that accepts a document at all.
68
+ 'visibility' => 'private',
69
+ 'file' => StringIO.new(bytes)
70
+ }.compact
71
+ )
72
+ return { error: response.error_message } unless response.success?
73
+
74
+ data = response.data.to_h
75
+
76
+ {
77
+ ok: true,
78
+ file_id: data['signed_id'],
79
+ filename: data['filename'],
80
+ content_type: data['content_type'],
81
+ byte_size: data['byte_size']
82
+ }
83
+ end
84
+
85
+ def summary(arguments)
86
+ "Store the file #{arguments[:filename]}"
87
+ end
88
+
89
+ private
90
+
91
+ # Text arrives as text. Base64 is a third larger and a model has to
92
+ # emit every byte of it, so it is asked for only where the bytes are
93
+ # not text at all.
94
+ def decode(content, encoding)
95
+ raw = content.to_s
96
+ return [raw, nil] unless encoding.to_s == 'base64'
97
+
98
+ [Base64.strict_decode64(raw.gsub(/\s+/, '')), nil]
99
+ rescue ArgumentError
100
+ [nil, 'content is not valid base64. Send text as text, without the encoding parameter.']
101
+ end
102
+ end
103
+ end
104
+ end
@@ -77,6 +77,19 @@ module Spree
77
77
  def complete_draft_order
78
78
  result = Spree.order_complete_workflow.call(order: cart, payment_pending: payment_pending)
79
79
  failure(result.value, result.error) if result.failure?
80
+
81
+ # A draft leaves before FINALIZE, so the document that phase files is
82
+ # filed here — otherwise it charged tax that never reached the return.
83
+ # A draft holding several sellers' goods divides like a checkout, and
84
+ # each of its orders is a sale of its own to file.
85
+ if result.value.is_a?(Spree::OrderGroup)
86
+ @order_group = result.value
87
+ @order = order_group.orders.order(:id).first
88
+ else
89
+ @order = result.value
90
+ end
91
+ external_step :commit_tax
92
+
80
93
  halt!(result.value)
81
94
  end
82
95
 
@@ -141,6 +154,26 @@ module Spree
141
154
  def recalculate_in_lock
142
155
  Spree::Carts::PriceItems.apply(@confirmed_prices) if @confirmed_prices.present?
143
156
  cart.recalculate_totals!
157
+ rescue Spree::Tax::ProviderError => error
158
+ # The engine that decides this sale's tax did not produce any.
159
+ # Completing anyway would place an order whose tax nobody computed, so
160
+ # the sale is refused — as a failure the caller can render, not as an
161
+ # exception that reads like a broken checkout.
162
+ Rails.error.report(error, context: { order_id: cart.id }, source: 'spree.checkout')
163
+
164
+ if error.is_a?(Spree::Tax::CalculationRefused)
165
+ # The engine said no to this request. Its message names the problem —
166
+ # the postcode, the missing company code — and asking the customer to
167
+ # retry would be asking them to repeat something that cannot work.
168
+ failure(cart, code: 'tax_calculation_refused', message: error.message.to_s)
169
+ else
170
+ # It could not be asked. Retrying may work, and the reason belongs in
171
+ # the error tracker rather than the response: the raw message names
172
+ # the endpoint and the transport, which tells a shopper nothing and
173
+ # tells everyone else too much.
174
+ failure(cart, code: 'tax_provider_unavailable',
175
+ message: I18n.t('spree.cart_line_item.tax_unavailable'))
176
+ end
144
177
  end
145
178
 
146
179
  def verify_expected_total
@@ -253,9 +286,10 @@ module Spree
253
286
 
254
287
  line_item_map = copy_line_items!(cart, order)
255
288
  fulfillment_map = copy_fulfillments!(cart, order, line_item_map)
256
- copy_typed_lines!(cart, order, line_item_map, fulfillment_map)
289
+ fee_map = copy_typed_lines!(cart, order, line_item_map, fulfillment_map)
257
290
  copy_promotions!(cart, order)
258
291
  copy_tax_identifier!(cart, order)
292
+ copy_tax_exemptions!(cart, order, line_item_map, fulfillment_map, fee_map)
259
293
  copy_po_document!(cart, order)
260
294
  repoint_money_records!(cart, order)
261
295
 
@@ -290,6 +324,71 @@ module Spree
290
324
  order.create_tax_identifier!(attributes.merge('source' => source_of(resolved)))
291
325
  end
292
326
 
327
+ # Freezes the exemption evidence the sale was priced with, beside the
328
+ # registration and for the same reason. A certificate lapses on its own
329
+ # date with nothing written to it, can be revoked, and is destroyed with
330
+ # its company — so a provider re-resolving at commit or refund time would
331
+ # file a credit for an exempt sale as a consumer refund and declare tax
332
+ # that was never collected. Read from the cart, which is what was priced.
333
+ #
334
+ # An empty list is still written: "found no claim" and "placed before this
335
+ # existed" are different facts, and the reader tells them apart.
336
+ def copy_tax_exemptions!(cart, order, line_item_map, fulfillment_map, fee_map)
337
+ translation = exemption_item_ids(line_item_map, fulfillment_map, fee_map)
338
+ claims = cart.usable_exemptions.map { |claim| reown_claim(claim, translation) }
339
+
340
+ order.update_column(:applied_tax_exemptions, claims)
341
+ end
342
+
343
+ # An override names its line by prefixed id, and completion mints new ones.
344
+ # Left as the cart's it matches nothing — which widens the claim rather
345
+ # than narrowing it, exempting every line instead of carving one out.
346
+ def reown_claim(claim, translation)
347
+ snapshot = claim.to_snapshot
348
+ overrides = Array(snapshot['item_overrides'])
349
+ return snapshot if overrides.empty?
350
+
351
+ snapshot.merge(
352
+ 'item_overrides' => overrides.map do |override|
353
+ reowned = translation[override['item_id'].to_s]
354
+ next override.merge('item_id' => reowned) if reowned
355
+
356
+ # It narrowed nothing while the cart was priced either, so it is
357
+ # left alone and reported rather than dropped.
358
+ report_unmatched_override(override)
359
+ override
360
+ end
361
+ )
362
+ end
363
+
364
+ # Cart prefixed id → order prefixed id, encoded from the ids the copy maps
365
+ # already carry rather than by reloading the records.
366
+ def exemption_item_ids(line_item_map, fulfillment_map, fee_map)
367
+ translation = {}
368
+
369
+ line_item_map.each do |cart_line_item, order_line_item|
370
+ translation[cart_line_item.prefixed_id] = order_line_item.prefixed_id
371
+ end
372
+ { Spree::Fulfillment => fulfillment_map, Spree::Fee => fee_map }.each do |klass, ids|
373
+ ids.each do |cart_id, order_id|
374
+ translation[klass.prefixed_id_for(cart_id)] = klass.prefixed_id_for(order_id)
375
+ end
376
+ end
377
+
378
+ translation
379
+ end
380
+
381
+ def report_unmatched_override(override)
382
+ Rails.error.report(
383
+ Spree::Tax::UnusableExemptionError.new(
384
+ "A tax exemption override named #{override['item_id'].inspect}, which is not part of this sale"
385
+ ),
386
+ handled: true,
387
+ context: { item_id: override['item_id'] },
388
+ source: 'spree.core'
389
+ )
390
+ end
391
+
293
392
  # The buyer's purchase order follows the number onto the order. The same
294
393
  # blob is attached to both records rather than uploaded twice — the cart
295
394
  # keeps its copy so a completion that rolls back leaves the buyer's
@@ -366,6 +465,9 @@ module Spree
366
465
  # Fees before tax lines, because a fee is something tax is charged on: a
367
466
  # tax line copied first would reach the order still naming the cart's
368
467
  # fee, and that row is then destroyed with it.
468
+ #
469
+ # @return [Hash{Integer => Integer}] cart fee id → order fee id, which an
470
+ # exemption override may name
369
471
  def copy_typed_lines!(cart, order, line_item_map, fulfillment_map)
370
472
  line_item_id_map = line_item_map.transform_keys(&:id).transform_values(&:id)
371
473
  fee_map = {}
@@ -392,6 +494,8 @@ module Spree
392
494
  fee_map[row.id] = copy.id if klass == Spree::Fee
393
495
  end
394
496
  end
497
+
498
+ fee_map
395
499
  end
396
500
 
397
501
  def copy_promotions!(cart, order)
@@ -458,7 +562,16 @@ module Spree
458
562
  # separate sale with its own tax, and an unfiled sibling is a hole in the
459
563
  # merchant's return that only surfaces at filing time.
460
564
  def commit_tax
461
- placed_orders.each { |placed| placed.tax_provider.commit(placed) }
565
+ placed_orders.each do |placed|
566
+ placed.tax_provider.commit(placed)
567
+ rescue Spree::Tax::ProviderError => error
568
+ # Deliberately not a failure. The order is placed and paid; refusing
569
+ # now would leave the customer charged for a sale the platform denies
570
+ # having taken. Filing is idempotent and replayable, so the document
571
+ # can follow — an unfiled sale is a reporting gap to chase, not a
572
+ # reason to break the checkout that produced it.
573
+ Rails.error.report(error, context: { order_id: placed.id }, source: 'spree.checkout')
574
+ end
462
575
  end
463
576
 
464
577
  # What this checkout produced: the children of a split, or the one order
@@ -90,8 +90,8 @@ module Spree
90
90
  # settled by its assignments. Rules are reconciled by replacement, so
91
91
  # the audience rules the caller never mentioned are merged back in;
92
92
  # without this, editing a catalog's name through the inline payload
93
- # would destroy a market rule and silently change how its prices are
94
- # restated for VAT.
93
+ # would destroy a market rule and silently change which buyers its
94
+ # prices apply to.
95
95
  def merged_rules(rows)
96
96
  existing = catalog.price_list&.price_rules&.to_a || []
97
97
  untouched = existing.reject(&:contextual?)
@@ -212,9 +212,15 @@ module Spree
212
212
  # redacting that would overwrite the shared address for every other
213
213
  # member and every other order. Only rows this person owns, or rows
214
214
  # nobody owns, are this erasure's to rewrite.
215
+ #
216
+ # Ownership is the id alone, never the type. A snapshot taken from a
217
+ # company site keeps its owner_type so the copy stays addressed to a
218
+ # business rather than to a person, so matching on the type too would
219
+ # skip exactly those copies — and an erasure that quietly covers less
220
+ # than it did raises nothing.
215
221
  snapshots = Spree::Address.where(id: address_ids)
216
222
  mine = snapshots.where(owner_type: customer.class.base_class.to_s, owner_id: customer.id)
217
- unowned = snapshots.where(owner_type: nil, owner_id: nil)
223
+ unowned = snapshots.where(owner_id: nil)
218
224
 
219
225
  mine.or(unowned).find_each do |address|
220
226
  redact_address(address, deleted: false)
@@ -407,7 +413,9 @@ module Spree
407
413
  end
408
414
 
409
415
  # The token goes too, so a link already emailed stops resolving rather
410
- # than waiting out its expiry.
416
+ # than waiting out its expiry. Confirmation links are bound to it, so
417
+ # a request still waiting for one can never be confirmed.
418
+ requests.awaiting_confirmation.update_all(status: 'lapsed', updated_at: Time.current)
411
419
  requests.update_all(
412
420
  email: customer.email,
413
421
  download_token: nil,
@@ -458,22 +466,35 @@ module Spree
458
466
 
459
467
  # @param address [Spree::Address]
460
468
  # @param deleted [Boolean] whether to soft-delete it as well
469
+ # What the person left on an address, rewritten; the address itself kept
470
+ # readable enough to answer a tax enquiry.
471
+ #
472
+ # A snapshot copied from a company site is the narrower case. The dock
473
+ # and the trading name on it are the business's, not this person's, and
474
+ # erasing them would take the merchant's record of a lawful sale with
475
+ # them — so only the contact details go. A business address is allowed
476
+ # to name nobody, which is why those fields are emptied rather than
477
+ # stamped: writing "Redacted" where a company never named anyone would
478
+ # invent a person who was not there.
461
479
  def redact_address(address, deleted:)
462
- attributes = {
463
- first_name: REDACTED_NAME,
464
- last_name: REDACTED_NAME,
465
- address1: REDACTED_NAME,
466
- address2: nil,
467
- phone: nil,
468
- alternative_phone: nil,
469
- company: nil,
470
- label: nil,
471
- latitude: nil,
472
- longitude: nil,
473
- postal_code: truncated_postal_code(address.postal_code),
474
- metadata: {},
475
- updated_at: Time.current
476
- }
480
+ attributes =
481
+ if address.business_owned?
482
+ { first_name: nil, last_name: nil }
483
+ else
484
+ {
485
+ first_name: REDACTED_NAME,
486
+ last_name: REDACTED_NAME,
487
+ address1: REDACTED_NAME,
488
+ address2: nil,
489
+ company: nil,
490
+ label: nil,
491
+ latitude: nil,
492
+ longitude: nil,
493
+ postal_code: truncated_postal_code(address.postal_code)
494
+ }
495
+ end
496
+
497
+ attributes.merge!(phone: nil, alternative_phone: nil, metadata: {}, updated_at: Time.current)
477
498
  attributes[:deleted_at] = Time.current if deleted && address.deleted_at.nil?
478
499
 
479
500
  address.update_columns(attributes)
@@ -36,6 +36,7 @@ module Spree
36
36
  super
37
37
 
38
38
  step :ensure_pending
39
+ step :ensure_addressed_to_customer
39
40
  step :mark_processing
40
41
 
41
42
  run_hooks :before_fulfill
@@ -56,6 +57,15 @@ module Spree
56
57
  failure(data_request, I18n.t('spree.data_request_errors.not_pending'))
57
58
  end
58
59
 
60
+ # An account already erased has a replaced address by design, and
61
+ # erase_customer counts that as done.
62
+ def ensure_addressed_to_customer
63
+ customer = data_request.customer
64
+ return if customer.nil? || customer.anonymized? || data_request.addressed_to_customer?
65
+
66
+ failure(data_request, I18n.t('spree.data_request_errors.address_changed'))
67
+ end
68
+
59
69
  def mark_processing
60
70
  data_request.update!(status: 'processing')
61
71
  end
@@ -78,21 +88,25 @@ module Spree
78
88
  # anonymizer starts after the check and finishes its attachment scan
79
89
  # before this attaches, stranding a full personal-data file it will
80
90
  # never see.
91
+ #
92
+ # The address is checked again under the same lock, so it cannot change
93
+ # between the check and the export reading it.
81
94
  return attach_export unless customer
82
95
 
83
- refused = false
96
+ refusal = nil
84
97
 
85
98
  customer.with_lock do
86
- if customer.reload.anonymized?
87
- refused = true
99
+ customer.reload
100
+ if customer.anonymized?
101
+ refusal = 'spree.data_request_errors.already_anonymized'
102
+ elsif !data_request.addressed_to_customer?
103
+ refusal = 'spree.data_request_errors.address_changed'
88
104
  else
89
105
  attach_export
90
106
  end
91
107
  end
92
108
 
93
- return unless refused
94
-
95
- failure(data_request, I18n.t('spree.data_request_errors.already_anonymized'))
109
+ failure(data_request, I18n.t(refusal)) if refusal
96
110
  end
97
111
 
98
112
  def attach_export
@@ -253,8 +253,16 @@ module Spree
253
253
 
254
254
  # Reverses the filed tax document. The canceled sale must stop appearing
255
255
  # in the merchant's tax liability.
256
+ #
257
+ # Reported rather than raised, as `Carts::Complete` treats its filing. The
258
+ # cancellation and its payments are already settled by now, and the steps
259
+ # after this one update statuses and announce `order.canceled` — so
260
+ # raising abandoned a cancelled order half-recorded. Void is idempotent, so
261
+ # a standing document can follow.
256
262
  def void_tax
257
263
  order.tax_provider.void(order)
264
+ rescue Spree::Tax::ProviderError => error
265
+ Rails.error.report(error, context: { order_id: order.id }, source: 'spree.orders.cancel')
258
266
  end
259
267
  end
260
268
  end
@@ -43,6 +43,7 @@ module Spree
43
43
  step :split_by_seller
44
44
  step :finalize_fulfillments
45
45
  step :place_order
46
+ step :freeze_tax_exemptions
46
47
  step :use_coupon_codes
47
48
  step :redeem_gift_card
48
49
  step :fulfill_auto_fulfillments
@@ -151,6 +152,13 @@ module Spree
151
152
  order.update!(status: 'placed', completed_at: Time.current)
152
153
  end
153
154
 
155
+ # Pins what the sale was priced with, as `Carts::Complete` does for a cart.
156
+ # A draft is its own source, so nothing needs translating — and it is the
157
+ # path company certificates take, which left it resolving live at credit.
158
+ def freeze_tax_exemptions
159
+ order.update_column(:applied_tax_exemptions, order.usable_exemptions.map(&:to_snapshot))
160
+ end
161
+
154
162
  def use_coupon_codes
155
163
  Spree::CouponCodes::CouponCodesHandler.new(order: order).use_all_codes
156
164
  end
@@ -8,18 +8,18 @@ module Spree
8
8
  # Standalone from Spree::Carts::Create (storefront). Admin-created orders
9
9
  # are first-class Spree::Order records (status: 'draft') in 5.x and remain
10
10
  # so in 6.0 — Spree::Cart in 6.0 is storefront-only.
11
- class Create
12
- prepend Spree::ServiceModule::Base
13
-
11
+ class Create < Spree::Workflow
14
12
  attr_reader :discount_application_errors
15
13
 
16
- # @param store [Spree::Store]
17
- # @param customer [Object, nil] resolved customer (Spree.customer_class instance)
18
- # @param created_by [Object, nil] the staff member creating the draft
19
- # (Spree.admin_user_class instance); nil for machine callers (secret keys)
20
- # @param params [Hash] order params (see admin API docs)
14
+ # @param store [Spree::Store] the store the order belongs to
15
+ # @param customer [Object, nil] who is ordering, when they are an
16
+ # existing customer; the order keeps `email` either way
17
+ # @param created_by [Object, nil] who is placing it — a staff member, or
18
+ # the credential acting for one
19
+ # @param params [Hash] the order's own attributes, plus `items`,
20
+ # addresses and a coupon code
21
21
  # @return [Spree::ServiceModule::Result]
22
- def call(store:, customer: nil, created_by: nil, params: {})
22
+ def perform(store:, customer: nil, created_by: nil, params: {})
23
23
  @store = store
24
24
  @customer = customer
25
25
  @created_by = created_by
@@ -28,6 +28,14 @@ module Spree
28
28
 
29
29
  return failure(:store_is_required) if store.nil?
30
30
 
31
+ @sales_context = Spree::SalesContext.new(
32
+ store: store,
33
+ market: (resolve_market if @params[:market_id].present?),
34
+ currency: @params[:currency],
35
+ locale: @params[:locale]
36
+ )
37
+ return refuse_sales_context unless @sales_context.valid?
38
+
31
39
  order = nil
32
40
  ApplicationRecord.transaction do
33
41
  order = build_order
@@ -60,8 +68,9 @@ module Spree
60
68
  customer: @customer,
61
69
  created_by: @created_by,
62
70
  email: @params[:email] || @customer&.email,
63
- currency: @params[:currency].presence&.upcase || @store.default_currency,
64
- locale: @params[:locale] || Spree::Current.locale,
71
+ market: @sales_context.market,
72
+ currency: @sales_context.currency,
73
+ locale: @sales_context.locale || Spree::Current.locale,
65
74
  customer_note: @params[:customer_note],
66
75
  po_number: @params[:po_number],
67
76
  internal_note: @params[:internal_note],
@@ -74,7 +83,6 @@ module Spree
74
83
  # signed id, so it assigns like any other attribute.
75
84
  attrs[:po_document_signed_id] = @params[:po_document_signed_id] if @params[:po_document_signed_id].present?
76
85
 
77
- attrs[:market] = resolve_market if @params[:market_id].present?
78
86
  attrs[:channel] = resolve_channel if @params[:channel_id].present?
79
87
  attrs[:company] = resolve_company if @params[:company_id].present?
80
88
  attrs[:preferred_stock_location] = resolve_preferred_stock_location if @params[:preferred_stock_location_id].present?
@@ -83,6 +91,12 @@ module Spree
83
91
  @store.orders.new(attrs)
84
92
  end
85
93
 
94
+ def refuse_sales_context
95
+ order = @store.orders.new
96
+ order.errors.merge!(@sales_context.errors)
97
+ failure(order, order.errors.full_messages.to_sentence)
98
+ end
99
+
86
100
  def resolve_market
87
101
  @store.markets.find_by_param!(@params[:market_id])
88
102
  end
@@ -100,34 +114,44 @@ module Spree
100
114
  end
101
115
 
102
116
  def assign_addresses(order)
103
- if @params[:use_customer_default_address] && @customer
104
- @customer.association(:bill_address).load_target
105
- @customer.association(:ship_address).load_target
106
- order.bill_address = @customer.bill_address&.snapshot
107
- order.ship_address = @customer.ship_address&.snapshot
108
- end
117
+ assign_default_addresses(order) if @params[:use_customer_default_address]
109
118
 
110
119
  assign_address(order, :ship_address, @params[:shipping_address_id], @params[:shipping_address])
111
120
  assign_address(order, :bill_address, @params[:billing_address_id], @params[:billing_address])
121
+ order.use_shipping = @params[:use_shipping]
122
+ end
123
+
124
+ def assign_default_addresses(order)
125
+ company = order.company
126
+
127
+ if company.present?
128
+ order.bill_address = company.default_billing_address&.snapshot
129
+ order.ship_address = company.default_shipping_address&.snapshot
130
+ return
131
+ end
132
+
133
+ return if @customer.nil?
134
+
135
+ @customer.association(:bill_address).load_target
136
+ @customer.association(:ship_address).load_target
137
+ order.bill_address = @customer.bill_address&.snapshot
138
+ order.ship_address = @customer.ship_address&.snapshot
112
139
  end
113
140
 
114
141
  def assign_address(order, association, address_id, address_attrs)
115
142
  if address_id.present?
116
- address = resolve_user_address(address_id)
143
+ address = order.selectable_address(address_id)
117
144
  order.public_send(:"#{association}_id=", address.id) if address
118
145
  elsif address_attrs.present?
119
146
  order.public_send(:"#{association}_attributes=", address_attrs)
120
147
  end
121
148
  end
122
149
 
123
- def resolve_user_address(address_id)
124
- return unless @customer
125
-
126
- @customer.addresses.find_by_param(address_id)
127
- end
128
-
129
150
  def add_items(order)
130
- result = Spree::Orders::UpsertItems.call(order: order, items: @params[:items])
151
+ result = Spree::Orders::UpsertItems.call(
152
+ order: order, items: @params[:items],
153
+ orderable_variants: Spree::SalesContext.from_order(order).orderable_variants
154
+ )
131
155
  return if result.success?
132
156
 
133
157
  propagate_step_failure!(order, result, fallback: 'Failed to add items to order')
@@ -17,6 +17,9 @@ module Spree
17
17
  # @param currency [String, nil] defaults to the store's; set it for a
18
18
  # foreign-currency order
19
19
  # @param expected_at [Date, nil]
20
+ # @param cancel_by [Date, nil] the date past which the merchant no longer
21
+ # wants the goods, so an order still open after it reads as overdue to
22
+ # cancel rather than merely late
20
23
  # @param reference [String, nil] the supplier's own order number
21
24
  # @param notes [String, nil]
22
25
  # @param metadata [Hash, nil]
@@ -65,6 +65,10 @@ module Spree
65
65
  # The refunded amount goes with them, because it can be less than those
66
66
  # lines are worth — a restocking fee, or an agreed part-refund. Without it
67
67
  # a provider would credit the tax on goods whose value the merchant kept.
68
+ # Reported rather than raised, as filing and voiding are: the money has
69
+ # moved and the return is marked refunded by now, and `return.refunded`
70
+ # follows — so raising failed a refund that had gone through, and lost the
71
+ # event with it.
68
72
  def refund_tax
69
73
  received = return_record.return_line_items.select { |line| line.refund_amount.positive? }
70
74
  return if received.empty?