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.
- 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/addresses.rb +95 -32
- data/app/models/concerns/spree/purchase/taxation.rb +44 -3
- data/app/models/concerns/spree/translatable_resource.rb +49 -0
- data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
- data/app/models/spree/ability.rb +8 -0
- data/app/models/spree/address.rb +6 -0
- data/app/models/spree/cart.rb +4 -6
- data/app/models/spree/company.rb +1 -1
- data/app/models/spree/data_request.rb +34 -1
- 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 +6 -2
- 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/sales_context.rb +162 -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_line.rb +5 -4
- 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/destroy.rb +7 -2
- 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 +35 -11
- data/app/services/spree/data_requests/confirm.rb +37 -0
- data/app/services/spree/data_requests/create.rb +61 -13
- 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/customers/anonymize.rb +38 -17
- data/app/workflows/spree/data_requests/fulfill.rb +20 -6
- 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 +50 -26
- 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 +18 -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 +37 -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/data_request_factory.rb +4 -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 +69 -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?)
|
|
@@ -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(
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
96
|
+
refusal = nil
|
|
84
97
|
|
|
85
98
|
customer.with_lock do
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
@@ -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
|
-
|
|
64
|
-
|
|
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]
|
|
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 =
|
|
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(
|
|
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?
|