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.
- checksums.yaml +4 -4
- data/app/mailers/spree/email_template_mailer.rb +5 -2
- data/app/models/concerns/spree/has_external_references.rb +7 -6
- data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
- data/app/models/concerns/spree/purchase/taxation.rb +44 -3
- data/app/models/concerns/spree/translatable_resource.rb +49 -0
- data/app/models/spree/cart.rb +1 -0
- data/app/models/spree/delivery_rate.rb +12 -4
- data/app/models/spree/delivery_rate_provider/base.rb +1 -1
- data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
- data/app/models/spree/fulfillment.rb +1 -0
- data/app/models/spree/gateway/bogus.rb +7 -0
- data/app/models/spree/line_item.rb +3 -8
- data/app/models/spree/oauth_access_grant.rb +11 -0
- data/app/models/spree/oauth_access_token.rb +16 -0
- data/app/models/spree/oauth_application.rb +97 -0
- data/app/models/spree/oauth_consent_request.rb +23 -0
- data/app/models/spree/oauth_protected_resource.rb +21 -0
- data/app/models/spree/oauth_redirect.rb +12 -0
- data/app/models/spree/option_type.rb +5 -0
- data/app/models/spree/option_value.rb +5 -0
- data/app/models/spree/order.rb +5 -1
- data/app/models/spree/payment.rb +14 -2
- data/app/models/spree/payment_capture_event.rb +2 -0
- data/app/models/spree/policy.rb +6 -0
- data/app/models/spree/price.rb +16 -33
- data/app/models/spree/price_rule.rb +3 -16
- data/app/models/spree/price_rules/market_rule.rb +0 -6
- data/app/models/spree/pricing_provider/base.rb +1 -1
- data/app/models/spree/product.rb +1 -1
- data/app/models/spree/purchase_order_item.rb +1 -1
- data/app/models/spree/return.rb +1 -0
- data/app/models/spree/search_provider/database.rb +1 -1
- data/app/models/spree/stock/estimator.rb +4 -20
- data/app/models/spree/store.rb +8 -0
- data/app/models/spree/tax_exemption/item_override.rb +14 -0
- data/app/models/spree/tax_exemption.rb +26 -0
- data/app/models/spree/tax_exemption_certificate.rb +13 -2
- data/app/models/spree/tax_exemption_reason.rb +62 -0
- data/app/models/spree/tax_provider/base.rb +24 -4
- data/app/models/spree/tax_provider/internal.rb +36 -29
- data/app/models/spree/tax_rate.rb +9 -35
- data/app/models/spree/translations/batch.rb +14 -4
- data/app/services/spree/carts/price_items.rb +2 -8
- data/app/services/spree/carts/split_by_seller.rb +6 -1
- data/app/services/spree/carts/update.rb +34 -7
- data/app/services/spree/email_templates/preview.rb +24 -3
- data/app/services/spree/seeds/store_resources.rb +1 -0
- data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
- data/app/services/spree/tax/resolve_exemptions.rb +11 -2
- data/app/services/spree/update_check.rb +8 -1
- data/app/tools/spree/agent_tools/create_export.rb +116 -0
- data/app/tools/spree/agent_tools/create_import.rb +117 -0
- data/app/tools/spree/agent_tools/create_media.rb +81 -0
- data/app/tools/spree/agent_tools/create_resource.rb +36 -0
- data/app/tools/spree/agent_tools/curate_products.rb +90 -0
- data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
- data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
- data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
- data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
- data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
- data/app/tools/spree/agent_tools/get_resource.rb +97 -0
- data/app/tools/spree/agent_tools/import_tool.rb +80 -0
- data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
- data/app/tools/spree/agent_tools/query_report.rb +124 -0
- data/app/tools/spree/agent_tools/resource_write.rb +99 -0
- data/app/tools/spree/agent_tools/search_resources.rb +155 -0
- data/app/tools/spree/agent_tools/set_stock.rb +52 -0
- data/app/tools/spree/agent_tools/update_resource.rb +42 -0
- data/app/tools/spree/agent_tools/upload_file.rb +104 -0
- data/app/workflows/spree/carts/complete.rb +115 -2
- data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
- data/app/workflows/spree/orders/cancel.rb +8 -0
- data/app/workflows/spree/orders/complete.rb +8 -0
- data/app/{services → workflows}/spree/orders/create.rb +9 -9
- data/app/workflows/spree/purchase_orders/create.rb +3 -0
- data/app/workflows/spree/returns/refund.rb +4 -0
- data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
- data/app/workflows/spree/variants/create.rb +7 -4
- data/config/locales/en.yml +14 -0
- data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
- data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
- data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
- data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
- data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
- data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
- data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
- data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
- data/db/sample_data/orders.rb +1 -1
- data/lib/spree/agent_tool.rb +188 -0
- data/lib/spree/agent_tools/api_dispatch.rb +204 -0
- data/lib/spree/agent_tools/context.rb +191 -0
- data/lib/spree/agent_tools/default_catalog.rb +180 -0
- data/lib/spree/agent_tools/membership_map.rb +61 -0
- data/lib/spree/agent_tools/record_summary.rb +219 -0
- data/lib/spree/agent_tools/registry.rb +131 -0
- data/lib/spree/agent_tools/resource_map.rb +352 -0
- data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
- data/lib/spree/agent_tools/workflow_schema.rb +279 -0
- data/lib/spree/agent_tools/workflow_tool.rb +654 -0
- data/lib/spree/agent_tools.rb +24 -0
- data/lib/spree/core/configuration.rb +1 -1
- data/lib/spree/core/controller_helpers/store.rb +3 -13
- data/lib/spree/core/dependencies.rb +9 -0
- data/lib/spree/core/engine.rb +35 -1
- data/lib/spree/core/model_extensions.rb +111 -0
- data/lib/spree/core/permissions/default_catalog.rb +6 -1
- data/lib/spree/core/pricing/price_resolution.rb +4 -4
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +27 -2
- data/lib/spree/service_module.rb +11 -0
- data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
- data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
- data/lib/tasks/markets.rake +47 -0
- data/lib/tasks/tax_exemption_reasons.rake +48 -0
- metadata +67 -7
- 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
|
|
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
|
|
94
|
-
#
|
|
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]
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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
|
|
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
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
|
26
|
-
|
|
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
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|