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,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?)
@@ -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
@@ -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?
@@ -12,18 +12,22 @@ module Spree
12
12
  # A feed states absolute levels (+count_on_hand+), which is what an
13
13
  # external system knows. The movement records the delta between what Spree
14
14
  # held and what the feed says.
15
- class BulkUpsert
16
- prepend Spree::ServiceModule::Base
15
+ class BulkUpsert < Spree::Workflow
17
16
 
18
17
  # What the stock history shows against a movement this service wrote.
19
18
  FEED_REASON = 'inventory_feed'.freeze
20
19
 
21
- # @param rows [Array<Hash>] each with +variant_id+, +stock_location_id+,
22
- # and either +count_on_hand+ (absolute) or +adjustment+ (relative);
23
- # +backorderable+ optional
20
+ # @param store [Spree::Store] whose shelves these are. Ids are resolved
21
+ # through it, so a row naming another store's variant is not found
22
+ # rather than written.
23
+ # @param rows [Array<Hash>] one per shelf: +variant_id+ and
24
+ # +stock_location_id+ naming it (prefixed ids, or raw), then either
25
+ # +count_on_hand+ (the figure it should end at) or +adjustment+ (a
26
+ # signed change); +backorderable+ optional
24
27
  # @return [Spree::ServiceModule::Result] +{ stock_level_count: N }+
25
- def call(rows:)
26
- rows = Array(rows).map { |row| row.with_indifferent_access }
28
+ def perform(store:, rows:)
29
+ @store = store
30
+ rows = resolve(Array(rows).map { |row| row.with_indifferent_access })
27
31
  keyed = rows.select { |row| row[:variant_id].present? && row[:stock_location_id].present? }
28
32
  return success(stock_level_count: 0) if keyed.empty?
29
33
 
@@ -41,6 +45,44 @@ module Spree
41
45
 
42
46
  private
43
47
 
48
+ # Names become this store's own ids, or nothing.
49
+ #
50
+ # Resolved here rather than by the caller, because a row reaching
51
+ # `find_or_initialize_by` unscoped writes to whichever shelf the id
52
+ # happens to name — including another merchant's. One caller decoding
53
+ # ids correctly does not make the workflow safe for the next one.
54
+ def resolve(rows)
55
+ variants = scoped_ids(store_variants, Spree::Variant, rows.map { |row| row[:variant_id] })
56
+ locations = scoped_ids(store_stock_locations, Spree::StockLocation,
57
+ rows.map { |row| row[:stock_location_id] })
58
+
59
+ rows.map do |row|
60
+ row.merge(
61
+ variant_id: variants[row[:variant_id]],
62
+ stock_location_id: locations[row[:stock_location_id]]
63
+ ).compact
64
+ end
65
+ end
66
+
67
+ # Accepts a prefixed id or a raw one: the Admin API speaks prefixed,
68
+ # while the importer and seeds hold records they already loaded.
69
+ def scoped_ids(scope, model, values)
70
+ decoded = values.compact.uniq.index_with do |value|
71
+ model.decode_own_prefixed_id(value) || value
72
+ end
73
+ found = scope.where(id: decoded.values.compact).pluck(:id).map(&:to_s).to_set
74
+
75
+ decoded.transform_values { |id| id if id && found.include?(id.to_s) }
76
+ end
77
+
78
+ def store_variants
79
+ @store.variants
80
+ end
81
+
82
+ def store_stock_locations
83
+ @store.stock_locations
84
+ end
85
+
44
86
  def apply_row(row)
45
87
  stock_level = Spree::StockLevel.find_or_initialize_by(
46
88
  variant_id: row[:variant_id], stock_location_id: row[:stock_location_id]
@@ -1,9 +1,12 @@
1
1
  module Spree
2
2
  module Variants
3
- # Creates a variant on a product. The single gate for variant creation —
4
- # the standalone variants endpoint and the nested payload on a product
5
- # write both run through it, so a :validate handler sees every variant a
6
- # catalog gains.
3
+ # Adds one variant to a product that already exists. A product being
4
+ # created carries its variants in the same write instead, which is one
5
+ # request and reconciles the list rather than adding to it.
6
+ #
7
+ # The single gate for variant creation — the standalone variants endpoint
8
+ # and the nested payload on a product write both run through it, so a
9
+ # :validate handler sees every variant a catalog gains.
7
10
  #
8
11
  # The product is a parameter rather than something read off the payload
9
12
  # because everything nested needs it: an option value attaches an option
@@ -429,6 +429,12 @@ en:
429
429
  attributes:
430
430
  company:
431
431
  must_be_legal_entity: must be a legal entity, not a division
432
+ tax_exemption_reason:
433
+ not_offered: is not a tax exemption reason this store offers
434
+ spree/tax_exemption_reason:
435
+ attributes:
436
+ base:
437
+ in_use: is recorded on tax exemption certificates and cannot be deleted
432
438
  spree/tax_identifier:
433
439
  attributes:
434
440
  company:
@@ -613,8 +619,10 @@ en:
613
619
  messages:
614
620
  already_confirmed: was already confirmed
615
621
  forbidden_reporting_member: references data you are not allowed to read
622
+ fragment_present: cannot contain a fragment
616
623
  invalid_document_number_affix: 'can only contain uppercase letters, digits, dashes and #'
617
624
  invalid_system_key: can only contain lowercase letters, digits and underscores
625
+ invalid_uri: must be a valid URL
618
626
  locked_after_first_number: cannot change once numbering has started
619
627
  not_a_number: is not a number
620
628
  not_found: not found
@@ -622,7 +630,10 @@ en:
622
630
  not_saved:
623
631
  one: '1 error prohibited this %{resource} from being saved:'
624
632
  other: "%{count} errors prohibited this %{resource} from being saved:"
633
+ relative_uri: must be an absolute URL
634
+ secured_uri: must start with https, because authorization codes are delivered to it
625
635
  seeded_report_read_only: is a built-in report and cannot be changed — save a copy instead
636
+ unspecified_scheme: must include a scheme, such as https
626
637
  number:
627
638
  currency:
628
639
  format:
@@ -688,6 +699,7 @@ en:
688
699
  pricing_unavailable: Prices could not be confirmed. Please try again.
689
700
  quantity_must_be_positive: Quantity must be at least 1. Use DELETE to remove the item.
690
701
  quantity_rule_violated: "%{li_name} cannot be ordered in that quantity. Try %{quantities}."
702
+ tax_unavailable: Tax could not be calculated. Please try again.
691
703
  catalogs:
692
704
  no_price_list_to_import: This catalog has no price list to import products from.
693
705
  product_not_in_store: One of those products belongs to another store.
@@ -1785,6 +1797,8 @@ en:
1785
1797
  gb_vat: UK VAT
1786
1798
  us_ein: US EIN
1787
1799
  tax_provider_unavailable: The tax service could not work out the tax to give back. Try again in a few minutes.
1800
+ tax_providers:
1801
+ internal: Spree tax rates
1788
1802
  terms_of_service: Terms of Service
1789
1803
  there_are_no_items_for_this_order: There are no items for this order
1790
1804
  tiered_flat_rate: Tiered Flat Rate
@@ -2,10 +2,12 @@ class FixPoliciesStoreAssociation < ActiveRecord::Migration[7.2]
2
2
  def change
3
3
  add_reference :spree_policies, :owner, polymorphic: true, index: true
4
4
 
5
- Spree::Policy.reset_column_information
6
- Spree::Policy.all.each do |policy|
7
- policy.update(owner_id: policy.store_id, owner_type: 'Spree::Store')
8
- end
5
+ policies = Arel::Table.new(:spree_policies)
6
+ connection.update(
7
+ Arel::UpdateManager.new.table(policies).set(
8
+ [[policies[:owner_id], policies[:store_id]], [policies[:owner_type], 'Spree::Store']]
9
+ )
10
+ )
9
11
 
10
12
  remove_index :spree_policies, [:store_id, :slug], unique: true, if_exists: true
11
13
  remove_index :spree_policies, [:store_id, :position], if_exists: true
@@ -5,8 +5,17 @@ class AddStoreIdToSpreeNewsletterSubscribers < ActiveRecord::Migration[7.2]
5
5
  # For spree_multi_tenant we need handle the backfill and indices there
6
6
  return if defined?(SpreeMultiTenant)
7
7
 
8
- default_store = Spree::Store.default
9
- Spree::NewsletterSubscriber.update_all(store_id: default_store.id) if default_store&.persisted?
8
+ # Read without the models: Spree::Store expects its columns (preferences,
9
+ # metadata) in the format later migrations convert them to, so loading a
10
+ # store here crashes on a database upgraded from an earlier release.
11
+ stores = Arel::Table.new(:spree_stores)
12
+ default_store_id = connection.select_value(
13
+ stores.project(stores[:id]).where(stores[:deleted_at].eq(nil).and(stores[:default].eq(true))).take(1)
14
+ )
15
+ if default_store_id.present?
16
+ subscribers = Arel::Table.new(:spree_newsletter_subscribers)
17
+ connection.update(Arel::UpdateManager.new.table(subscribers).set([[subscribers[:store_id], default_store_id]]))
18
+ end
10
19
 
11
20
  change_column_null :spree_newsletter_subscribers, :store_id, false
12
21
 
@@ -0,0 +1,16 @@
1
+ class AddAppliedTaxExemptionsToSpreeOrders < ActiveRecord::Migration[8.1]
2
+ # The exemption claims a placed order was priced with, frozen at completion
3
+ # beside its tax registration. Certificates lapse on their own date with
4
+ # nothing written to them, can be revoked, and are destroyed with their
5
+ # company — so resolving them again when a provider files or credits the sale
6
+ # is not the evidence the sale was priced with.
7
+ def change
8
+ change_table :spree_orders, bulk: true do |t|
9
+ if t.respond_to?(:jsonb)
10
+ t.jsonb :applied_tax_exemptions
11
+ else
12
+ t.json :applied_tax_exemptions
13
+ end
14
+ end
15
+ end
16
+ end
@@ -5,8 +5,18 @@ class AddStoreToSpreeOptionTypes < ActiveRecord::Migration[8.1]
5
5
  # Existing option types were installation-wide, so they belong to the
6
6
  # store that has been using them. Left nullable: a fresh install has no
7
7
  # store yet, and the association enforces presence from the model side.
8
- default_store = Spree::Store.where(default: true).first || Spree::Store.first
9
- Spree::OptionType.update_all(store_id: default_store.id) if default_store
8
+ # Read without the models: Spree::Store expects its columns (preferences,
9
+ # metadata) in the format later migrations convert them to, so loading a
10
+ # store here crashes on a database upgraded from an earlier release.
11
+ stores = Arel::Table.new(:spree_stores)
12
+ default_store_id = connection.select_value(
13
+ stores.project(stores[:id]).where(stores[:deleted_at].eq(nil))
14
+ .order(stores[:default].desc, stores[:id].asc).take(1)
15
+ )
16
+ if default_store_id.present?
17
+ option_types = Arel::Table.new(:spree_option_types)
18
+ connection.update(Arel::UpdateManager.new.table(option_types).set([[option_types[:store_id], default_store_id]]))
19
+ end
10
20
 
11
21
  # Names are unique within a store now, so two stores may each define `size`.
12
22
  remove_index :spree_option_types, :name, unique: true, if_exists: true