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
data/app/models/spree/price.rb
CHANGED
|
@@ -9,7 +9,6 @@ module Spree
|
|
|
9
9
|
|
|
10
10
|
has_prefix_id :price
|
|
11
11
|
|
|
12
|
-
include Spree::VatPriceCalculation
|
|
13
12
|
include Spree::StorePreferences
|
|
14
13
|
include Spree::CanonicalDecimals
|
|
15
14
|
|
|
@@ -135,44 +134,28 @@ module Spree
|
|
|
135
134
|
alias_attribute :price, :amount
|
|
136
135
|
alias_attribute :compare_at_price, :compare_at_amount
|
|
137
136
|
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
# chose. Lists narrowed by anything else (a quantity, a group) were not set
|
|
143
|
-
# for a country, so their prices restate like any other.
|
|
144
|
-
#
|
|
145
|
-
# @return [Boolean]
|
|
146
|
-
def final_for_destination?
|
|
147
|
-
rules = price_list&.price_rules.to_a
|
|
148
|
-
return false unless rules.any?(&:geographic?)
|
|
149
|
-
|
|
150
|
-
# Under 'any' the list can win on a non-geographic rule alone, so a
|
|
151
|
-
# geographic rule being present does not mean this buyer's geography is why
|
|
152
|
-
# the price applied. Under 'all' every rule matched, so it does.
|
|
153
|
-
price_list.match_policy == 'all' || rules.all?(&:geographic?)
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def price_including_vat_for(price_options)
|
|
157
|
-
return price if final_for_destination?
|
|
158
|
-
|
|
159
|
-
options = price_options.merge(tax_category: variant.tax_category)
|
|
160
|
-
gross_amount(price, options)
|
|
137
|
+
# @deprecated Prices are charged as entered for every buyer since 6.0. Use {#price}; removed in 6.1.
|
|
138
|
+
def price_including_vat_for(_price_options = {})
|
|
139
|
+
Spree::Deprecation.warn('Spree::Price#price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #price instead.')
|
|
140
|
+
price
|
|
161
141
|
end
|
|
162
142
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
gross_amount(compare_at_price, options)
|
|
143
|
+
# @deprecated Use {#compare_at_price}; removed in 6.1.
|
|
144
|
+
def compare_at_price_including_vat_for(_price_options = {})
|
|
145
|
+
Spree::Deprecation.warn('Spree::Price#compare_at_price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #compare_at_price instead.')
|
|
146
|
+
compare_at_price
|
|
168
147
|
end
|
|
169
148
|
|
|
170
|
-
|
|
171
|
-
|
|
149
|
+
# @deprecated Use {#display_price}; removed in 6.1.
|
|
150
|
+
def display_price_including_vat_for(_price_options = {})
|
|
151
|
+
Spree::Deprecation.warn('Spree::Price#display_price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #display_price instead.')
|
|
152
|
+
display_price
|
|
172
153
|
end
|
|
173
154
|
|
|
174
|
-
|
|
175
|
-
|
|
155
|
+
# @deprecated Use {#display_compare_at_price}; removed in 6.1.
|
|
156
|
+
def display_compare_at_price_including_vat_for(_price_options = {})
|
|
157
|
+
Spree::Deprecation.warn('Spree::Price#display_compare_at_price_including_vat_for is deprecated and will be removed in Spree 6.1. Prices are charged as entered; use #display_compare_at_price instead.')
|
|
158
|
+
display_compare_at_price
|
|
176
159
|
end
|
|
177
160
|
|
|
178
161
|
# returns the name of the price in a format of variant name and currency
|
|
@@ -16,18 +16,6 @@ module Spree
|
|
|
16
16
|
raise NotImplementedError, "#{self.class.name} must implement #applicable?"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
# Whether this rule narrows its price list by geography. A list carrying one
|
|
20
|
-
# states the price for that geography outright, so its prices are charged as
|
|
21
|
-
# entered and never restated for the buyer's VAT rate
|
|
22
|
-
# (Spree::VatPriceCalculation). A rule that narrows by anything else — a
|
|
23
|
-
# quantity, a group, a channel — leaves its prices open to restatement, since
|
|
24
|
-
# they were not set for a country.
|
|
25
|
-
#
|
|
26
|
-
# @return [Boolean]
|
|
27
|
-
def geographic?
|
|
28
|
-
false
|
|
29
|
-
end
|
|
30
|
-
|
|
31
19
|
# Whether this rule narrows by something about the purchase rather than
|
|
32
20
|
# about the buyer. A catalog-owned price list has already been matched on
|
|
33
21
|
# audience by the time the resolver holds it — the catalog assignment is
|
|
@@ -45,10 +33,9 @@ module Spree
|
|
|
45
33
|
false
|
|
46
34
|
end
|
|
47
35
|
|
|
48
|
-
# Instance-side reader, so rules
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# as MarketRule does for `geographic?`.
|
|
36
|
+
# Instance-side reader, so rules can be classified per row
|
|
37
|
+
# (`price_rules.select(&:contextual?)`). A kind whose answer depends on how it is
|
|
38
|
+
# configured overrides this.
|
|
52
39
|
#
|
|
53
40
|
# @return [Boolean]
|
|
54
41
|
def contextual?
|
|
@@ -23,12 +23,6 @@ module Spree
|
|
|
23
23
|
preferred_market_ids.map(&:to_s).include?(context.market.id.to_s)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# An empty market list matches every buyer (see #applicable?), so it names
|
|
27
|
-
# no geography — and that is the state a freshly created rule row is in.
|
|
28
|
-
def geographic?
|
|
29
|
-
preferred_market_ids.present?
|
|
30
|
-
end
|
|
31
|
-
|
|
32
26
|
def self.description
|
|
33
27
|
'Apply pricing based on the market'
|
|
34
28
|
end
|
|
@@ -6,7 +6,7 @@ module Spree
|
|
|
6
6
|
# A provider returns a {Spree::Price}: {Internal} returns the persisted
|
|
7
7
|
# catalog row, an external provider an **unsaved, readonly** instance. The
|
|
8
8
|
# Active Record shape is deliberately the contract, so everything
|
|
9
|
-
# downstream —
|
|
9
|
+
# downstream — the line item, +discounted?+, the serializers —
|
|
10
10
|
# keeps working without knowing where the number came from. +readonly!+ is
|
|
11
11
|
# what stops an external answer being written back into the catalog.
|
|
12
12
|
#
|
data/app/models/spree/product.rb
CHANGED
|
@@ -887,7 +887,7 @@ module Spree
|
|
|
887
887
|
next unless variant.should_track_inventory?
|
|
888
888
|
next if variant.sku.blank? && variant != default_variant
|
|
889
889
|
|
|
890
|
-
variant.stock_levels.each do |stock_level|
|
|
890
|
+
variant.stock_levels.sort_by(&:id).each do |stock_level|
|
|
891
891
|
location = stock_level.stock_location
|
|
892
892
|
next if location.nil? || location == default_stock_location
|
|
893
893
|
next unless location.store_id == store.id && location_owner_ids.include?(location.seller_id)
|
|
@@ -11,7 +11,7 @@ module Spree
|
|
|
11
11
|
belongs_to :purchase_order, class_name: 'Spree::PurchaseOrder', inverse_of: :items, touch: true
|
|
12
12
|
|
|
13
13
|
validates :quantity_ordered, numericality: { greater_than: 0, only_integer: true }
|
|
14
|
-
validates :unit_cost, numericality: { greater_than_or_equal_to: 0 }
|
|
14
|
+
validates :unit_cost, presence: true, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
|
|
15
15
|
validates :variant_id, uniqueness: { scope: :purchase_order_id }
|
|
16
16
|
|
|
17
17
|
delegate :currency, to: :purchase_order, allow_nil: true
|
data/app/models/spree/return.rb
CHANGED
|
@@ -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
|
|
@@ -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
|