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
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
module Spree
|
|
2
2
|
module Stock
|
|
3
3
|
class Estimator
|
|
4
|
-
include Spree::VatPriceCalculation
|
|
5
|
-
|
|
6
4
|
attr_reader :order, :currency
|
|
7
5
|
|
|
8
6
|
def initialize(order)
|
|
@@ -69,7 +67,7 @@ module Spree
|
|
|
69
67
|
# through the calculator, carrier providers call their API and may
|
|
70
68
|
# return several estimates (one per carrier service). An empty result
|
|
71
69
|
# suppresses the method, matching the calculator contract. Service
|
|
72
|
-
# filtering, markup, naming,
|
|
70
|
+
# filtering, markup, naming, tax resolution and sorting
|
|
73
71
|
# stay here so every provider gets them identically.
|
|
74
72
|
def calculate_delivery_rates(package, audience)
|
|
75
73
|
delivery_methods(package, audience).flat_map do |delivery_method|
|
|
@@ -103,14 +101,12 @@ module Spree
|
|
|
103
101
|
end
|
|
104
102
|
|
|
105
103
|
# An unpriced quote has no amount to mark up or tax — a percentage of an
|
|
106
|
-
# unknown price is still unknown
|
|
107
|
-
#
|
|
108
|
-
# display surface reads +unpriced+ instead.
|
|
104
|
+
# unknown price is still unknown. Its cost stays zero and every display
|
|
105
|
+
# surface reads +unpriced+ instead.
|
|
109
106
|
def rate_cost(estimate, delivery_method, service_row, provider)
|
|
110
107
|
return 0 if estimate.unpriced
|
|
111
108
|
|
|
112
|
-
|
|
113
|
-
gross_amount(cost, taxation_options_for(delivery_method))
|
|
109
|
+
apply_markup(estimate.cost, delivery_method, service_row, provider)
|
|
114
110
|
end
|
|
115
111
|
|
|
116
112
|
# Handling fee on top of provider quotes: the service row's values when
|
|
@@ -132,18 +128,6 @@ module Spree
|
|
|
132
128
|
[estimate.carrier, estimate.service_level].compact.join(' ').presence
|
|
133
129
|
end
|
|
134
130
|
|
|
135
|
-
# Override this if you need the prices for delivery methods to be handled just like the
|
|
136
|
-
# prices for products in terms of included tax manipulation.
|
|
137
|
-
#
|
|
138
|
-
def taxation_options_for(delivery_method)
|
|
139
|
-
{
|
|
140
|
-
tax_category: delivery_method.tax_category,
|
|
141
|
-
address: @order.tax_address,
|
|
142
|
-
country: @order.tax_country,
|
|
143
|
-
market: @order.market
|
|
144
|
-
}
|
|
145
|
-
end
|
|
146
|
-
|
|
147
131
|
def first_tax_rate_for(tax_category)
|
|
148
132
|
return unless @order.tax_address && tax_category
|
|
149
133
|
|
data/app/models/spree/store.rb
CHANGED
|
@@ -8,6 +8,12 @@ module Spree
|
|
|
8
8
|
|
|
9
9
|
include FriendlyId
|
|
10
10
|
include Spree::TranslatableResource
|
|
11
|
+
|
|
12
|
+
# The store itself is settings — see Admin::StoreController.
|
|
13
|
+
def self.translation_write_permission
|
|
14
|
+
'write_settings'
|
|
15
|
+
end
|
|
16
|
+
|
|
11
17
|
include Spree::HasCustomFields
|
|
12
18
|
include Spree::Metadata
|
|
13
19
|
include Spree::Stores::Setup
|
|
@@ -209,6 +215,7 @@ module Spree
|
|
|
209
215
|
has_many :claim_reasons, class_name: 'Spree::ClaimReason', inverse_of: :store, dependent: :destroy
|
|
210
216
|
has_many :refund_reasons, class_name: 'Spree::RefundReason', inverse_of: :store, dependent: :destroy
|
|
211
217
|
has_many :order_cancellation_reasons, class_name: 'Spree::OrderCancellationReason', inverse_of: :store, dependent: :destroy
|
|
218
|
+
has_many :tax_exemption_reasons, class_name: 'Spree::TaxExemptionReason', inverse_of: :store, dependent: :destroy
|
|
212
219
|
|
|
213
220
|
# :nullify (not :destroy) — clearing the collection must not cascade into
|
|
214
221
|
# Promotion#not_used? / payment records; orphaned rows are detached, not deleted.
|
|
@@ -339,6 +346,7 @@ module Spree
|
|
|
339
346
|
has_many :catalogs, class_name: 'Spree::Catalog', dependent: :destroy, inverse_of: :store
|
|
340
347
|
|
|
341
348
|
has_many :api_keys, class_name: 'Spree::ApiKey', dependent: :destroy
|
|
349
|
+
has_many :oauth_applications, class_name: 'Spree::OauthApplication', dependent: :destroy
|
|
342
350
|
has_many :allowed_origins, class_name: 'Spree::AllowedOrigin', dependent: :destroy
|
|
343
351
|
|
|
344
352
|
#
|
|
@@ -17,6 +17,20 @@ module Spree
|
|
|
17
17
|
def exempt?
|
|
18
18
|
exempt
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
# @return [Hash]
|
|
22
|
+
def to_snapshot
|
|
23
|
+
attributes
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Unknown keys are dropped rather than raising: a snapshot is read back
|
|
27
|
+
# long after it was written, possibly by a different version of Spree.
|
|
28
|
+
#
|
|
29
|
+
# @param snapshot [Hash]
|
|
30
|
+
# @return [Spree::TaxExemption::ItemOverride]
|
|
31
|
+
def self.from_snapshot(snapshot)
|
|
32
|
+
new(snapshot.to_h.stringify_keys.slice(*attribute_names).symbolize_keys)
|
|
33
|
+
end
|
|
20
34
|
end
|
|
21
35
|
end
|
|
22
36
|
end
|
|
@@ -14,6 +14,9 @@ module Spree
|
|
|
14
14
|
# Why the sale is exempt, in the provider's vocabulary — Avalara's
|
|
15
15
|
# entityUseCode territory.
|
|
16
16
|
attribute :reason_code, :string
|
|
17
|
+
# The merchant's wording for the reason, kept with the claim so a placed
|
|
18
|
+
# order still reads the way it did when it was taxed.
|
|
19
|
+
attribute :reason_name, :string
|
|
17
20
|
attribute :certificate_number, :string
|
|
18
21
|
|
|
19
22
|
# Where the claim holds. Nil country = everywhere the provider taxes,
|
|
@@ -62,6 +65,29 @@ module Spree
|
|
|
62
65
|
override_for(item)&.reason_code.presence || reason_code
|
|
63
66
|
end
|
|
64
67
|
|
|
68
|
+
# The claim as plain data, for freezing onto a placed order. Certificates
|
|
69
|
+
# lapse, get revoked and are edited, so a provider filing or crediting that
|
|
70
|
+
# sale later has to read the evidence the sale was priced with rather than
|
|
71
|
+
# whatever resolves on the day.
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def to_snapshot
|
|
75
|
+
attributes.except('item_overrides').
|
|
76
|
+
merge('item_overrides' => Array(item_overrides).map(&:to_snapshot))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Unknown keys are dropped rather than raising: a snapshot is read back long
|
|
80
|
+
# after it was written, possibly by a different version of Spree.
|
|
81
|
+
#
|
|
82
|
+
# @param snapshot [Hash]
|
|
83
|
+
# @return [Spree::TaxExemption]
|
|
84
|
+
def self.from_snapshot(snapshot)
|
|
85
|
+
snapshot = snapshot.to_h.stringify_keys
|
|
86
|
+
overrides = Array(snapshot['item_overrides']).map { |override| ItemOverride.from_snapshot(override) }
|
|
87
|
+
|
|
88
|
+
new(snapshot.slice(*attribute_names).symbolize_keys.merge(item_overrides: overrides))
|
|
89
|
+
end
|
|
90
|
+
|
|
65
91
|
private
|
|
66
92
|
|
|
67
93
|
# An override that cannot say which line it applies to does not narrow this
|
|
@@ -22,6 +22,7 @@ module Spree
|
|
|
22
22
|
has_status :pending, :verified, :expired, :revoked, default: :pending
|
|
23
23
|
|
|
24
24
|
belongs_to :company, class_name: 'Spree::Company', inverse_of: :tax_exemption_certificates
|
|
25
|
+
belongs_to :tax_exemption_reason, class_name: 'Spree::TaxExemptionReason', inverse_of: :tax_exemption_certificates
|
|
25
26
|
belongs_to :verified_by, class_name: Spree.admin_user_class.to_s, optional: true
|
|
26
27
|
|
|
27
28
|
# Confidential, so the private service rather than the public one that
|
|
@@ -29,12 +30,15 @@ module Spree
|
|
|
29
30
|
has_one_attached :document, service: Spree.private_storage_service_name
|
|
30
31
|
signed_id_attachments :document
|
|
31
32
|
|
|
32
|
-
validates :certificate_number,
|
|
33
|
+
validates :certificate_number, presence: true
|
|
33
34
|
# Certificates exist only on legal-entity nodes — a division's purchases
|
|
34
35
|
# read exemption through its legal entity. A validation rather than a
|
|
35
36
|
# schema difference, so a per-division registration can be allowed later
|
|
36
37
|
# by relaxing this alone.
|
|
37
38
|
validate :company_is_legal_entity
|
|
39
|
+
# Checked when the reason is set, not on every save: a reason the merchant
|
|
40
|
+
# retires later leaves the certificates that already record it valid.
|
|
41
|
+
validate :reason_offered_by_store, if: :will_save_change_to_tax_exemption_reason_id?
|
|
38
42
|
|
|
39
43
|
# A certificate is a scanned form, not an archive. Bounded because the
|
|
40
44
|
# download action reads the whole blob into memory to serve it, so an
|
|
@@ -64,7 +68,7 @@ module Spree
|
|
|
64
68
|
where(state_code: [address.state_code.presence, nil].uniq)
|
|
65
69
|
}
|
|
66
70
|
|
|
67
|
-
self.whitelisted_ransackable_attributes = %w[certificate_number
|
|
71
|
+
self.whitelisted_ransackable_attributes = %w[certificate_number tax_exemption_reason_id status expires_at]
|
|
68
72
|
|
|
69
73
|
delegate :store, :store_id, to: :company
|
|
70
74
|
|
|
@@ -94,5 +98,12 @@ module Spree
|
|
|
94
98
|
|
|
95
99
|
errors.add(:company, :must_be_legal_entity)
|
|
96
100
|
end
|
|
101
|
+
|
|
102
|
+
def reason_offered_by_store
|
|
103
|
+
return if tax_exemption_reason.nil? || company.nil?
|
|
104
|
+
return if tax_exemption_reason.active? && tax_exemption_reason.store_id == store_id
|
|
105
|
+
|
|
106
|
+
errors.add(:tax_exemption_reason, :not_offered)
|
|
107
|
+
end
|
|
97
108
|
end
|
|
98
109
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# Why a business buys without tax — "Resale", "Government", "Charitable
|
|
3
|
+
# organisation". Certificates point at a reason the way products point at a
|
|
4
|
+
# tax category: the `name` is the merchant's own wording, and the `code` is
|
|
5
|
+
# what a tax engine reads. Spree's own engine only keeps the code for
|
|
6
|
+
# reference, while an external one (Avalara's entity use codes) decides the
|
|
7
|
+
# exemption by it, so changing the code changes what every certificate
|
|
8
|
+
# holding the reason sends from then on.
|
|
9
|
+
class TaxExemptionReason < Spree.base_class
|
|
10
|
+
has_prefix_id :txr
|
|
11
|
+
|
|
12
|
+
include Spree::NamedType
|
|
13
|
+
include Spree::Metadata
|
|
14
|
+
|
|
15
|
+
# Guarded by hand rather than with :restrict_with_error, which would also
|
|
16
|
+
# refuse when the whole store is being destroyed.
|
|
17
|
+
has_many :tax_exemption_certificates, class_name: 'Spree::TaxExemptionCertificate',
|
|
18
|
+
inverse_of: :tax_exemption_reason, dependent: nil
|
|
19
|
+
|
|
20
|
+
self.whitelisted_ransackable_attributes = %w[name code active]
|
|
21
|
+
|
|
22
|
+
# Upper case because tax engines read codes case-insensitively: `g` and
|
|
23
|
+
# `G` would be two reasons here and one to Avalara.
|
|
24
|
+
normalizes :code, with: ->(value) { value.to_s.strip.upcase.presence }
|
|
25
|
+
|
|
26
|
+
validates :code, presence: true, length: { maximum: 64 }
|
|
27
|
+
validates_store_uniqueness :code
|
|
28
|
+
|
|
29
|
+
before_destroy :ensure_unused
|
|
30
|
+
|
|
31
|
+
# Finds the reason already holding the code, else the one holding the
|
|
32
|
+
# name, so seeding a store never collides with a reason the merchant added
|
|
33
|
+
# or renamed. Code first: it is what a tax engine reads, so a match on it
|
|
34
|
+
# is the same reason whatever the merchant called it.
|
|
35
|
+
#
|
|
36
|
+
# @param code [String]
|
|
37
|
+
# @param name [String]
|
|
38
|
+
# @return [Spree::TaxExemptionReason]
|
|
39
|
+
def self.find_or_create_by_code_or_name!(code:, name:)
|
|
40
|
+
code = normalize_value_for(:code, code)
|
|
41
|
+
|
|
42
|
+
find_by(code: code) || find_by(name: name) || create!(code: code, name: name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Mirrors the destroy guard, so the dashboard can hide the delete control
|
|
46
|
+
# rather than offer one the model will refuse.
|
|
47
|
+
#
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def can_be_deleted?
|
|
50
|
+
!tax_exemption_certificates.exists?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def ensure_unused
|
|
56
|
+
return if destroyed_by_association.present? || can_be_deleted?
|
|
57
|
+
|
|
58
|
+
errors.add(:base, :in_use)
|
|
59
|
+
throw :abort
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -22,10 +22,11 @@ module Spree
|
|
|
22
22
|
# core doesn't know about registers its reason here. The bar for a new
|
|
23
23
|
# value: it distinguishes a treatment that reporting or invoicing has to
|
|
24
24
|
# tell apart, not merely a different amount.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
BUILT_IN_TAXABILITY_REASONS = %w[standard_rated reduced_rated zero_rated reverse_charge
|
|
26
|
+
intra_community_supply export customer_exempt product_exempt
|
|
27
|
+
not_collecting not_subject_to_tax].freeze
|
|
28
|
+
|
|
29
|
+
class_attribute :taxability_reasons, default: BUILT_IN_TAXABILITY_REASONS.dup
|
|
29
30
|
|
|
30
31
|
# Provider payloads (jurisdiction breakdowns, external ids) read nil-safe.
|
|
31
32
|
attribute :data, default: -> { {} }
|
|
@@ -41,6 +41,16 @@ module Spree
|
|
|
41
41
|
name.demodulize.titleize
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# The exemption reason codes this engine understands, `{ code => label }`,
|
|
45
|
+
# offered to a merchant adding a {Spree::TaxExemptionReason}. Nil for an
|
|
46
|
+
# engine that does not read the code, Spree's own included: any code a
|
|
47
|
+
# merchant chooses is then as good as another.
|
|
48
|
+
#
|
|
49
|
+
# @return [Hash{String => String}, nil]
|
|
50
|
+
def self.exemption_reason_codes
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
44
54
|
# The declared limits with the strings a merchant can act on. A capability
|
|
45
55
|
# with no translation degrades to its humanized key rather than a missing
|
|
46
56
|
# translation, so an extension that declares one without shipping strings
|
|
@@ -69,6 +79,7 @@ module Spree
|
|
|
69
79
|
name: display_name,
|
|
70
80
|
available: available_for_store?(store),
|
|
71
81
|
unsupported_capabilities: unsupported_capability_details,
|
|
82
|
+
exemption_reason_codes: exemption_reason_codes&.map { |code, label| { value: code.to_s, label: label.to_s } },
|
|
72
83
|
default: name == Spree.default_tax_provider.to_s
|
|
73
84
|
}
|
|
74
85
|
end
|
|
@@ -85,10 +96,18 @@ module Spree
|
|
|
85
96
|
# @param tax_identifier [Spree::TaxIdentifier, nil] resolved buyer
|
|
86
97
|
# registration, nil for a consumer sale
|
|
87
98
|
# @param exemptions [Array] exemption evidence to apply
|
|
99
|
+
# @param tax_inclusive [Boolean, nil] whether the owner's prices already
|
|
100
|
+
# include tax, which its market decides (Spree::Market#tax_inclusive);
|
|
101
|
+
# nil = ask the owner (+owner.tax_inclusive?+)
|
|
88
102
|
# @param context [Hash] untyped provider extras from set_tax_line_context
|
|
89
|
-
# @return [void] the written rows are the output
|
|
90
|
-
#
|
|
91
|
-
|
|
103
|
+
# @return [void] the written rows are the output
|
|
104
|
+
# @raise [Spree::Tax::ProviderUnavailable] when the engine could not
|
|
105
|
+
# answer — refused, timed out, unreachable. Raise this (or a subclass,
|
|
106
|
+
# as a provider's own request error may) rather than a bare error: core
|
|
107
|
+
# turns it into a refusal the customer can retry, where anything else
|
|
108
|
+
# reaches them as a broken checkout. A provider a merchant switched off
|
|
109
|
+
# is not this — it has no opinion and writes no rows.
|
|
110
|
+
def estimate(owner, items = nil, tax_date: nil, tax_identifier: nil, exemptions: [], tax_inclusive: nil, context: {})
|
|
92
111
|
raise NotImplementedError, "Please implement 'estimate' in your tax provider: #{self.class.name}"
|
|
93
112
|
end
|
|
94
113
|
|
|
@@ -171,9 +190,10 @@ module Spree
|
|
|
171
190
|
# @param tax_date [Time, nil] date whose rates apply; nil = now
|
|
172
191
|
# @param tax_identifier [Spree::TaxIdentifier, nil] the order's frozen registration
|
|
173
192
|
# @param exemptions [Array] exemption evidence to apply
|
|
193
|
+
# @param tax_inclusive [Boolean, nil] as for +estimate+
|
|
174
194
|
# @return [void]
|
|
175
195
|
# @raise [Spree::Tax::ProviderError] when the tax could not be worked out
|
|
176
|
-
def estimate_replacement(order, items, tax_date: nil, tax_identifier: nil, exemptions: [])
|
|
196
|
+
def estimate_replacement(order, items, tax_date: nil, tax_identifier: nil, exemptions: [], tax_inclusive: nil)
|
|
177
197
|
raise NotImplementedError, "Please implement 'estimate_replacement' in your tax provider: #{self.class.name}"
|
|
178
198
|
end
|
|
179
199
|
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
module Spree
|
|
2
2
|
module TaxProvider
|
|
3
3
|
# Computes tax directly from TaxRate configuration: rate × discounted
|
|
4
|
-
# base,
|
|
4
|
+
# base, backed out of the price instead when the market's prices include
|
|
5
|
+
# tax.
|
|
5
6
|
# Rates are matched on the owner's tax address, each naming its own country
|
|
6
7
|
# and optionally a state. Fees without a tax category are taxed with the
|
|
7
8
|
# default tax category's rates — except customs duties, which +taxable_items+
|
|
8
9
|
# withholds (see Spree::Purchase::Taxation).
|
|
9
10
|
class Internal < Base
|
|
11
|
+
# What a merchant sees when picking a market's tax engine: the class name
|
|
12
|
+
# says nothing to someone who never read the code.
|
|
13
|
+
def self.display_name
|
|
14
|
+
I18n.t('spree.tax_providers.internal')
|
|
15
|
+
end
|
|
16
|
+
|
|
10
17
|
# Rates are configured per country or state with no local-tax breakdown, no buyer
|
|
11
18
|
# registration handling, no distance-selling thresholds, and delivery taxed
|
|
12
19
|
# at its own method's rate rather than apportioned across the rates of the
|
|
@@ -20,7 +27,8 @@ module Spree
|
|
|
20
27
|
# period, so there is only ever one version of the configuration to read.
|
|
21
28
|
# +tax_identifier+ likewise — reverse charge is on the unsupported list,
|
|
22
29
|
# so a buyer registration changes nothing here.
|
|
23
|
-
def estimate(owner, items = nil, tax_date: nil, tax_identifier: nil, exemptions: [], context: {})
|
|
30
|
+
def estimate(owner, items = nil, tax_date: nil, tax_identifier: nil, exemptions: [], tax_inclusive: nil, context: {})
|
|
31
|
+
tax_inclusive = owner.tax_inclusive? if tax_inclusive.nil?
|
|
24
32
|
full_estimate = items.nil?
|
|
25
33
|
items ||= owner.taxable_items
|
|
26
34
|
# A duty this engine taxed before it was excluded — or through an
|
|
@@ -38,7 +46,7 @@ module Spree
|
|
|
38
46
|
Spree::TaxLine.where(Spree::TaxLine.adjustable_key_for(klass) => group.map(&:id)).delete_all
|
|
39
47
|
end
|
|
40
48
|
|
|
41
|
-
items.each { |item| tax_item(owner, item, rates, exemptions) }
|
|
49
|
+
items.each { |item| tax_item(owner, item, rates, exemptions, tax_inclusive) }
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
# Gives back each credited unit's share of the TaxLines the sale actually
|
|
@@ -57,16 +65,17 @@ module Spree
|
|
|
57
65
|
# An exemption is read as it applied to the line being replaced: a
|
|
58
66
|
# certificate's per-line carve-out names that line, and the exchange line
|
|
59
67
|
# replacing it would otherwise match no carve-out and come out exempt.
|
|
60
|
-
def estimate_replacement(order, items, tax_date: nil, tax_identifier: nil, exemptions: [])
|
|
68
|
+
def estimate_replacement(order, items, tax_date: nil, tax_identifier: nil, exemptions: [], tax_inclusive: nil)
|
|
61
69
|
return if items.empty?
|
|
62
70
|
|
|
71
|
+
tax_inclusive = order.tax_inclusive? if tax_inclusive.nil?
|
|
63
72
|
Spree::TaxLine.charges.where(exchange_line_item_id: items.map(&:id)).delete_all
|
|
64
73
|
|
|
65
74
|
rates = rates_for(order)
|
|
66
75
|
items.each do |item|
|
|
67
76
|
next unless item.credited_quantity.to_i.positive?
|
|
68
77
|
|
|
69
|
-
tax_item(order, item, rates, exemptions, exemption_subject: item.line_item || item)
|
|
78
|
+
tax_item(order, item, rates, exemptions, tax_inclusive, exemption_subject: item.line_item || item)
|
|
70
79
|
end
|
|
71
80
|
end
|
|
72
81
|
|
|
@@ -74,11 +83,8 @@ module Spree
|
|
|
74
83
|
# else, matched on where the invoiced business sits and taxed under the
|
|
75
84
|
# store's default category — this engine has no separate notion of a
|
|
76
85
|
# services category, and a marketplace commission is standard-rated
|
|
77
|
-
# nearly everywhere.
|
|
78
|
-
#
|
|
79
|
-
# Additional (not included-in-price) rates only: a commission is quoted
|
|
80
|
-
# net and its VAT is added on top, so a rate configured as
|
|
81
|
-
# tax-inclusive describes consumer pricing rather than this supply.
|
|
86
|
+
# nearly everywhere. A commission is quoted net and its tax added on top,
|
|
87
|
+
# whatever the store's markets do with their prices.
|
|
82
88
|
#
|
|
83
89
|
# Nil when the jurisdiction is unknown or configured with no matching
|
|
84
90
|
# rate at all, so the caller falls back to its own default instead of
|
|
@@ -91,8 +97,7 @@ module Spree
|
|
|
91
97
|
|
|
92
98
|
rates = Spree::TaxRate.for_store(store).
|
|
93
99
|
for_jurisdiction(address.country_code, address.state_code).
|
|
94
|
-
for_tax_category(category)
|
|
95
|
-
where(included_in_price: false)
|
|
100
|
+
for_tax_category(category)
|
|
96
101
|
return nil if rates.empty?
|
|
97
102
|
|
|
98
103
|
rates.sum(:amount)
|
|
@@ -114,7 +119,7 @@ module Spree
|
|
|
114
119
|
Spree::TaxRate.for_store(owner.store).for_jurisdiction(owner.tax_country&.iso, owner.tax_address&.state_code).to_a
|
|
115
120
|
end
|
|
116
121
|
|
|
117
|
-
def tax_item(owner, item, rates, exemptions, exemption_subject: item)
|
|
122
|
+
def tax_item(owner, item, rates, exemptions, tax_inclusive, exemption_subject: item)
|
|
118
123
|
relevant_rates = rates.select { |rate| rate.tax_category_id == tax_category_id_for(item, owner) }
|
|
119
124
|
|
|
120
125
|
# A matched rate always produces a row, zero-amount ones included:
|
|
@@ -129,15 +134,15 @@ module Spree
|
|
|
129
134
|
# An exempt item has no tax to back out of its price, so its whole
|
|
130
135
|
# basis is pre-tax.
|
|
131
136
|
exempt_rates = taxed.select { |_rate, _jurisdiction, exemption| exemption }.map(&:first)
|
|
132
|
-
store_pre_tax_amount(item, relevant_rates - exempt_rates)
|
|
137
|
+
store_pre_tax_amount(item, relevant_rates - exempt_rates, tax_inclusive)
|
|
133
138
|
|
|
134
139
|
taxed.each do |rate, jurisdiction, exemption|
|
|
135
140
|
if exemption
|
|
136
|
-
write_tax_line(owner, item, rate, 0, 'customer_exempt', jurisdiction,
|
|
141
|
+
write_tax_line(owner, item, rate, 0, 'customer_exempt', jurisdiction, tax_inclusive,
|
|
137
142
|
data: exemption_data(exemption, exemption_subject))
|
|
138
143
|
else
|
|
139
|
-
write_tax_line(owner, item, rate, compute_tax(rate, item, relevant_rates, owner.currency),
|
|
140
|
-
reason_for(rate), jurisdiction)
|
|
144
|
+
write_tax_line(owner, item, rate, compute_tax(rate, item, relevant_rates, owner.currency, tax_inclusive),
|
|
145
|
+
reason_for(rate), jurisdiction, tax_inclusive)
|
|
141
146
|
end
|
|
142
147
|
end
|
|
143
148
|
end
|
|
@@ -171,13 +176,11 @@ module Spree
|
|
|
171
176
|
|
|
172
177
|
# Rounded per line, half up, to the order's currency — the internal
|
|
173
178
|
# provider's choice; other providers round as their jurisdictions require.
|
|
174
|
-
def compute_tax(rate, item, relevant_rates, currency)
|
|
179
|
+
def compute_tax(rate, item, relevant_rates, currency, tax_inclusive)
|
|
175
180
|
basis = taxable_basis(item)
|
|
176
181
|
|
|
177
|
-
tax = if
|
|
178
|
-
|
|
179
|
-
included_sum = rate.amount if included_sum.zero?
|
|
180
|
-
basis / (1 + included_sum) * rate.amount
|
|
182
|
+
tax = if tax_inclusive
|
|
183
|
+
basis / (1 + relevant_rates.sum(&:amount)) * rate.amount
|
|
181
184
|
else
|
|
182
185
|
basis * rate.amount
|
|
183
186
|
end
|
|
@@ -186,12 +189,11 @@ module Spree
|
|
|
186
189
|
|
|
187
190
|
# Pre-tax amounts are stored so refund and reporting code can weigh
|
|
188
191
|
# discounted, tax-exclusive values later (see #4318).
|
|
189
|
-
def store_pre_tax_amount(item, rates)
|
|
192
|
+
def store_pre_tax_amount(item, rates, tax_inclusive)
|
|
190
193
|
return unless item.respond_to?(:pre_tax_amount)
|
|
191
194
|
|
|
192
195
|
pre_tax_amount = taxable_basis(item)
|
|
193
|
-
|
|
194
|
-
pre_tax_amount /= (1 + included_rates.sum(&:amount)) if included_rates.any?
|
|
196
|
+
pre_tax_amount /= (1 + rates.sum(&:amount)) if tax_inclusive && rates.any?
|
|
195
197
|
|
|
196
198
|
item.update_column(:pre_tax_amount, pre_tax_amount)
|
|
197
199
|
end
|
|
@@ -226,15 +228,20 @@ module Spree
|
|
|
226
228
|
# a genuinely exempt supply depends on which exemption applied, which the
|
|
227
229
|
# reason alone cannot say.
|
|
228
230
|
def exemption_data(exemption, item)
|
|
231
|
+
reason_code = exemption.reason_code_for(item)
|
|
232
|
+
|
|
229
233
|
{
|
|
230
234
|
'exemption' => {
|
|
231
|
-
'reason_code' =>
|
|
235
|
+
'reason_code' => reason_code,
|
|
236
|
+
# A per-line override names only a code, so the entry's name would
|
|
237
|
+
# describe a different reason.
|
|
238
|
+
'reason_name' => (exemption.reason_name if reason_code == exemption.reason_code),
|
|
232
239
|
'certificate_number' => exemption.certificate_number
|
|
233
240
|
}.compact
|
|
234
241
|
}
|
|
235
242
|
end
|
|
236
243
|
|
|
237
|
-
def write_tax_line(owner, item, rate, amount, reason, jurisdiction, data: {})
|
|
244
|
+
def write_tax_line(owner, item, rate, amount, reason, jurisdiction, tax_inclusive, data: {})
|
|
238
245
|
owner_key = owner.is_a?(Spree::Order) ? :order : :cart
|
|
239
246
|
Spree::TaxLine.create!(
|
|
240
247
|
{
|
|
@@ -243,8 +250,8 @@ module Spree
|
|
|
243
250
|
tax_rate: rate,
|
|
244
251
|
amount: amount,
|
|
245
252
|
rate: rate.amount,
|
|
246
|
-
label: rate.adjustment_label,
|
|
247
|
-
included:
|
|
253
|
+
label: rate.adjustment_label(included: tax_inclusive),
|
|
254
|
+
included: tax_inclusive,
|
|
248
255
|
provider_id: 'internal',
|
|
249
256
|
taxability_reason: reason,
|
|
250
257
|
data: data
|
|
@@ -9,6 +9,10 @@ module Spree
|
|
|
9
9
|
# zone_id any more, but the column stays in the database through 6.0: it is
|
|
10
10
|
# what `spree:migrate_tax_zones` converts from, and that task runs after
|
|
11
11
|
# db:migrate. The physical drop lands in 6.1.
|
|
12
|
+
#
|
|
13
|
+
# The same goes for included_in_price: whether tax sits inside a price is
|
|
14
|
+
# the market's choice (Spree::Market#tax_inclusive), and the column is read
|
|
15
|
+
# only by `spree:markets:backfill_tax_inclusive`, which clears it.
|
|
12
16
|
|
|
13
17
|
acts_as_paranoid
|
|
14
18
|
|
|
@@ -52,15 +56,10 @@ module Spree
|
|
|
52
56
|
}
|
|
53
57
|
# The same question asked with an address in hand.
|
|
54
58
|
scope :for_address, ->(address) { for_jurisdiction(address&.country_code, address&.state_code) }
|
|
55
|
-
# Country-wide: unlike for_jurisdiction(country_code, nil) this leaves the
|
|
56
|
-
# state unconstrained, so it sums a country's state-level rates too. Used
|
|
57
|
-
# where the state is irrelevant — backing VAT out of a gross price.
|
|
58
|
-
scope :for_country, ->(country_code) { where(country_code: [country_code.presence, nil].uniq) }
|
|
59
59
|
scope :for_tax_category,
|
|
60
60
|
->(category) { where(tax_category_id: category.try(:id)) }
|
|
61
|
-
scope :included_in_price, -> { where(included_in_price: true) }
|
|
62
61
|
|
|
63
|
-
self.whitelisted_ransackable_attributes = %w[amount rate country_code state_code tax_category_id
|
|
62
|
+
self.whitelisted_ransackable_attributes = %w[amount rate country_code state_code tax_category_id name]
|
|
64
63
|
ransack_alias :rate, :amount
|
|
65
64
|
search_by :name
|
|
66
65
|
|
|
@@ -92,38 +91,13 @@ module Spree
|
|
|
92
91
|
self.rate_percent = value
|
|
93
92
|
end
|
|
94
93
|
|
|
95
|
-
# The included-in-price rate to back out of a gross price.
|
|
96
|
-
#
|
|
97
|
-
# @param options [Hash] +:country+ (or +:address+) and +:tax_category+
|
|
98
|
-
# @return [BigDecimal] 0 when the jurisdiction or category is unknown
|
|
99
|
-
def self.included_tax_amount_for(options)
|
|
100
|
-
# +:country+ is a documented override point and the VAT path still hands
|
|
101
|
-
# it a Spree::Country, so take the code off whichever arrives.
|
|
102
|
-
country = options[:country]
|
|
103
|
-
country_code = country.try(:iso) || country.presence || options[:address]&.country_code
|
|
104
|
-
return 0 unless country_code && options[:tax_category]
|
|
105
|
-
|
|
106
|
-
scope = options[:address] ? for_address(options[:address]) : for_country(country_code)
|
|
107
|
-
# Per-store tax configuration is the norm since 6.0, so without this the
|
|
108
|
-
# sum would add up every store's rate for the same country.
|
|
109
|
-
store = options[:store] || Spree::Current.store
|
|
110
|
-
scope = scope.for_store(store) if store
|
|
111
|
-
scope.included_in_price.for_tax_category(options[:tax_category]).sum(:amount)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def included?
|
|
115
|
-
included_in_price
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def additional?
|
|
119
|
-
!included_in_price
|
|
120
|
-
end
|
|
121
|
-
|
|
122
94
|
# The customer-facing label snapshotted onto TaxLine rows.
|
|
123
95
|
#
|
|
96
|
+
# @param included [Boolean] whether the tax sits inside the price, which
|
|
97
|
+
# the sale's market decides (Spree::Market#tax_inclusive)
|
|
124
98
|
# @return [String]
|
|
125
|
-
def adjustment_label
|
|
126
|
-
I18n.t "spree.adjustment_labels.tax_rates.#{
|
|
99
|
+
def adjustment_label(included: false)
|
|
100
|
+
I18n.t "spree.adjustment_labels.tax_rates.#{included ? :including_tax : :excluding_tax}",
|
|
127
101
|
name: name.presence || tax_category.name,
|
|
128
102
|
amount: amount_for_label
|
|
129
103
|
end
|
|
@@ -45,12 +45,22 @@ module Spree
|
|
|
45
45
|
@records = []
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Distinct
|
|
49
|
-
#
|
|
50
|
-
#
|
|
48
|
+
# Distinct write permissions a caller needs to authorize this batch —
|
|
49
|
+
# one per resource type present.
|
|
50
|
+
#
|
|
51
|
+
# Each resource names its own, because several are guarded by a
|
|
52
|
+
# permission not named after them: an option type rides
|
|
53
|
+
# `write_products`, a policy rides `write_settings`. Deriving the name
|
|
54
|
+
# from the type produced strings outside the permission catalog, which
|
|
55
|
+
# no credential could hold and which the API therefore skipped — so a
|
|
56
|
+
# grant narrowed to reading could still rewrite a store's policy text.
|
|
57
|
+
#
|
|
58
|
+
# A type that resolves to nothing is left out; `resolve_record!` is what
|
|
59
|
+
# reports it, one entry at a time with its index.
|
|
60
|
+
#
|
|
51
61
|
# @return [Array<String>]
|
|
52
62
|
def required_scopes
|
|
53
|
-
@entries.
|
|
63
|
+
@entries.filter_map { |entry| resource_class(entry[:resource_type])&.translation_write_permission }.uniq
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
# Upserts every entry in one transaction. Yields each resolved record
|
|
@@ -52,9 +52,14 @@ module Spree
|
|
|
52
52
|
success(cart: cart)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Lets go of everything the cart was only borrowing, so the cascade that
|
|
56
|
+
# follows reaches nothing but the cart's own rows. An entry in somebody's
|
|
57
|
+
# address book stays in it, and a row a fulfillment or a completed order
|
|
58
|
+
# still points at is detached rather than soft-deleted out from under
|
|
59
|
+
# them.
|
|
55
60
|
def clear_addresses(cart:)
|
|
56
|
-
cart.ship_address = nil unless cart.ship_address
|
|
57
|
-
cart.bill_address = nil unless cart.bill_address
|
|
61
|
+
cart.ship_address = nil unless cart.owns_address?(cart.ship_address) && cart.ship_address.can_be_deleted?
|
|
62
|
+
cart.bill_address = nil unless cart.owns_address?(cart.bill_address) && cart.bill_address.can_be_deleted?
|
|
58
63
|
|
|
59
64
|
success(cart: cart)
|
|
60
65
|
end
|
|
@@ -71,20 +71,14 @@ module Spree
|
|
|
71
71
|
resolutions = Array(resolutions)
|
|
72
72
|
return if resolutions.empty?
|
|
73
73
|
|
|
74
|
-
# One derivation for the batch: every line in a resolution set belongs
|
|
75
|
-
# to the same cart, and tax_address / tax_country walk associations and
|
|
76
|
-
# a store preference that would otherwise be re-read per line.
|
|
77
|
-
owner = resolutions.first.first.owner
|
|
78
|
-
vat_inputs = { address: owner&.tax_address, country: owner&.tax_country, market: owner&.market }
|
|
79
|
-
|
|
80
74
|
resolutions.each do |line_item, price|
|
|
81
75
|
# Probe-based flows resolve against stand-ins, so the guard has to
|
|
82
76
|
# hold at the write too.
|
|
83
77
|
next if line_item.manual_price?
|
|
84
78
|
|
|
85
|
-
amount = price.
|
|
79
|
+
amount = price.price
|
|
86
80
|
# Marked before the blank guard: the provider has already answered,
|
|
87
|
-
# so a
|
|
81
|
+
# so a price that comes back blank must still not send the
|
|
88
82
|
# after_save callback back to the provider from inside the caller's
|
|
89
83
|
# transaction. Only the assign-for-a-coming-save case needs it — a
|
|
90
84
|
# persisted write goes through update_columns and fires no callbacks.
|