solidus_tax_cloud 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +35 -0
  3. data/.gem_release.yml +5 -0
  4. data/.gitignore +20 -0
  5. data/.rspec +2 -0
  6. data/.rubocop.yml +7 -0
  7. data/.rubocop_todo.yml +132 -0
  8. data/CHANGELOG.md +52 -0
  9. data/Gemfile +33 -0
  10. data/LICENSE +26 -0
  11. data/README.md +130 -0
  12. data/Rakefile +6 -0
  13. data/TaxCloudImplementationVerificationGuide.pdf +0 -0
  14. data/app/assets/javascripts/spree/backend/solidus_tax_cloud.js +2 -0
  15. data/app/assets/javascripts/spree/frontend/solidus_tax_cloud.js +2 -0
  16. data/app/assets/stylesheets/spree/backend/solidus_tax_cloud.css +4 -0
  17. data/app/assets/stylesheets/spree/backend/solidus_tax_cloud.scss +0 -0
  18. data/app/assets/stylesheets/spree/frontend/solidus_tax_cloud.css +4 -0
  19. data/app/decorators/models/solidus_tax_cloud/spree/app_configuration_decorator.rb +18 -0
  20. data/app/decorators/models/solidus_tax_cloud/spree/line_item_decorator.rb +47 -0
  21. data/app/decorators/models/solidus_tax_cloud/spree/order_decorator.rb +42 -0
  22. data/app/decorators/models/solidus_tax_cloud/spree/product_decorator.rb +25 -0
  23. data/app/decorators/models/solidus_tax_cloud/spree/shipment_decorator.rb +27 -0
  24. data/app/models/spree/calculator/tax_cloud_calculator.rb +111 -0
  25. data/app/models/spree/tax_cloud.rb +66 -0
  26. data/app/overrides/spree/admin/products/_form.rb +9 -0
  27. data/app/overrides/spree/admin/shared/_configuration_menu.rb +9 -0
  28. data/bin/console +17 -0
  29. data/bin/r +15 -0
  30. data/bin/rails +15 -0
  31. data/bin/rake +7 -0
  32. data/bin/sandbox +84 -0
  33. data/bin/sandbox_rails +18 -0
  34. data/bin/setup +8 -0
  35. data/config/initializers/tax_cloud_usps_username.rb +5 -0
  36. data/config/locales/en.yml +13 -0
  37. data/config/routes.rb +7 -0
  38. data/db/migrate/20121220192438_create_spree_tax_cloud_transactions.rb +13 -0
  39. data/db/migrate/20121220193944_create_spree_tax_cloud_cart_items.rb +22 -0
  40. data/db/migrate/20130829215819_fix_scale_of_cart_item_prices.rb +15 -0
  41. data/db/migrate/20140623225628_add_tic_to_products.rb +11 -0
  42. data/lib/assets/javascripts/spree/frontend/solidus_tax_cloud.js.erb +1 -0
  43. data/lib/assets/stylesheets/spree/frontend/solidus_tax_cloud.css.erb +5 -0
  44. data/lib/controllers/backend/spree/admin/tax_cloud_settings_controller.rb +29 -0
  45. data/lib/decorators/backend/controllers/solidus_tax_cloud/spree/admin/orders_controller_decorator.rb +21 -0
  46. data/lib/decorators/frontend/controllers/solidus_tax_cloud/spree/checkout_controller_decorator.rb +24 -0
  47. data/lib/generators/solidus_tax_cloud/install/install_generator.rb +29 -0
  48. data/lib/generators/solidus_tax_cloud/templates/ca-bundle.crt +3895 -0
  49. data/lib/solidus_tax_cloud.rb +10 -0
  50. data/lib/solidus_tax_cloud/engine.rb +23 -0
  51. data/lib/solidus_tax_cloud/error.rb +6 -0
  52. data/lib/solidus_tax_cloud/factories.rb +4 -0
  53. data/lib/solidus_tax_cloud/version.rb +5 -0
  54. data/lib/tasks/.gitkeep +0 -0
  55. data/lib/tasks/tax_cloud.rake +74 -0
  56. data/lib/views/backend/spree/admin/products/_edit_tax_cloud_tic.html.erb +6 -0
  57. data/lib/views/backend/spree/admin/tax_cloud_settings/edit.html.erb +32 -0
  58. data/solidus_tax_cloud.gemspec +39 -0
  59. data/spec/features/checkout_spec.rb +511 -0
  60. data/spec/models/tax_cloud_api_spec.rb +192 -0
  61. data/spec/spec_helper.rb +27 -0
  62. data/spec/support/capybara.rb +20 -0
  63. data/spec/support/tax_cloud.rb +20 -0
  64. data/spec/support/transactions.rb +5 -0
  65. metadata +204 -0
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'solidus_dev_support/rake_tasks'
4
+ SolidusDevSupport::RakeTasks.install
5
+
6
+ task default: 'extension:specs'
@@ -0,0 +1,2 @@
1
+ // Placeholder manifest file.
2
+ // the installer will append this file to the app vendored assets here: vendor/assets/javascripts/spree/backend/all.js'
@@ -0,0 +1,2 @@
1
+ // Placeholder manifest file.
2
+ // the installer will append this file to the app vendored assets here: vendor/assets/javascripts/spree/frontend/all.js'
@@ -0,0 +1,4 @@
1
+ /*
2
+ Placeholder manifest file.
3
+ the installer will append this file to the app vendored assets here: 'vendor/assets/stylesheets/spree/backend/all.css'
4
+ */
@@ -0,0 +1,4 @@
1
+ /*
2
+ Placeholder manifest file.
3
+ the installer will append this file to the app vendored assets here: 'vendor/assets/stylesheets/spree/frontend/all.css'
4
+ */
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusTaxCloud
4
+ module Spree
5
+ module AppConfigurationDecorator
6
+ def self.prepended(base)
7
+ base.class_eval do
8
+ preference :taxcloud_default_product_tic, :string, default: '00000'
9
+ preference :taxcloud_shipping_tic, :string, default: '11010'
10
+ end
11
+
12
+ Rails.application.config.spree.calculators.tax_rates << ::Spree::Calculator::TaxCloudCalculator
13
+ end
14
+
15
+ ::Spree::AppConfiguration.prepend self
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusTaxCloud
4
+ module Spree
5
+ module LineItemDecorator
6
+ def tax_cloud_cache_key
7
+ if ActiveRecord::Base.try(:cache_versioning)
8
+ cache_key
9
+ else
10
+ key = "Spree::LineItem #{id}: #{quantity}x<#{variant.cache_key}>@#{total_excluding_vat}#{currency}"
11
+
12
+ if order.ship_address
13
+ key += "shipped_to<#{order.ship_address.try(:cache_key)}>"
14
+ elsif order.billing_address
15
+ key += "billed_to<#{order.bill_address.try(:cache_key)}>"
16
+ end
17
+
18
+ key
19
+ end
20
+ end
21
+
22
+ def tax_cloud_cache_version
23
+ if ActiveRecord::Base.try(:cache_versioning)
24
+ key = "Spree::LineItem #{id}: #{quantity}x<#{variant.cache_version}>@#{total_excluding_vat}#{currency}"
25
+
26
+ if order.ship_address
27
+ key += "shipped_to<#{order.ship_address.try(:cache_version)}>"
28
+ elsif order.billing_address
29
+ key += "billed_to<#{order.bill_address.try(:cache_version)}>"
30
+ end
31
+
32
+ key
33
+ end
34
+ end
35
+
36
+ def price_with_discounts
37
+ round_to_two_places(total_excluding_vat / quantity)
38
+ end
39
+
40
+ def round_to_two_places(amount)
41
+ BigDecimal(amount.to_s).round(2, BigDecimal::ROUND_HALF_UP)
42
+ end
43
+
44
+ ::Spree::LineItem.prepend self
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusTaxCloud
4
+ module Spree
5
+ module OrderDecorator
6
+ def self.prepended(base)
7
+ base.class_eval do
8
+ state_machine.after_transition to: :complete, do: :capture_tax_cloud
9
+ end
10
+ end
11
+
12
+ def capture_tax_cloud
13
+ return unless is_taxed_using_tax_cloud?
14
+
15
+ response = ::Spree::TaxCloud.transaction_from_order(self).authorized_with_capture
16
+ if response != 'OK'
17
+ Rails.logger.error "ERROR: TaxCloud returned an order capture response of #{response}."
18
+ end
19
+ log_tax_cloud(response)
20
+ end
21
+
22
+ # Order.tax_zone.tax_rates is used here to check if the order is taxable by Tax Cloud.
23
+ # It's not possible check against the order's tax adjustments because
24
+ # an adjustment is not created for 0% rates. However, US orders must be
25
+ # submitted to Tax Cloud even when the rate is 0%.
26
+ # Note that we explicitly use ship_address instead of tax_address,
27
+ # as per compliance with Tax Cloud instructions.
28
+ def is_taxed_using_tax_cloud?
29
+ ::Spree::TaxRate.for_address(ship_address).any? { |rate| rate.calculator_type == 'Spree::Calculator::TaxCloudCalculator' }
30
+ end
31
+
32
+ def log_tax_cloud(response)
33
+ # Implement into your own application.
34
+ # You could create your own Log::TaxCloud model then use either HStore or
35
+ # JSONB to store the response.
36
+ # The response argument carries the response from an order transaction.
37
+ end
38
+
39
+ ::Spree::Order.prepend self
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusTaxCloud
4
+ module Spree
5
+ module ProductDecorator
6
+ def self.prepended(base)
7
+ base.class_eval do
8
+ validates_format_of :tax_cloud_tic, with: /\A\d{5}\z/, message: I18n.t('spree.standard_taxcloud_tic')
9
+ end
10
+ end
11
+
12
+ # Use the store-default TaxCloud product TIC if none is defined for this product
13
+ def tax_cloud_tic
14
+ read_attribute(:tax_cloud_tic) || ::Spree::Config.taxcloud_default_product_tic
15
+ end
16
+
17
+ # Empty strings are written as nil (which avoids the format validation)
18
+ def tax_cloud_tic=(tic)
19
+ write_attribute(:tax_cloud_tic, tic.presence)
20
+ end
21
+
22
+ ::Spree::Product.prepend self
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusTaxCloud
4
+ module Spree
5
+ module ShipmentDecorator
6
+ def tax_cloud_cache_key
7
+ if ActiveRecord::Base.try(:cache_versioning)
8
+ cache_key
9
+ else
10
+ "#{cache_key}--from:#{stock_location.cache_key}--to:#{order.shipping_address.cache_key}"
11
+ end
12
+ end
13
+
14
+ def tax_cloud_cache_version
15
+ if ActiveRecord::Base.try(:cache_versioning)
16
+ "#{cache_version}--from:#{stock_location.cache_version}--to:#{order.shipping_address.cache_version}"
17
+ end
18
+ end
19
+
20
+ def price_with_discounts
21
+ total_excluding_vat
22
+ end
23
+
24
+ ::Spree::Shipment.prepend self
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class Calculator::TaxCloudCalculator < Calculator::DefaultTax
5
+ def self.description
6
+ I18n.t('spree.tax_cloud')
7
+ end
8
+
9
+ # Default tax calculator still needs to support orders for legacy reasons
10
+ # Orders created before Spree 2.1 had tax adjustments applied to the order, as a whole.
11
+ # Orders created with Spree 2.2 and after, have them applied to the line items individually.
12
+ def compute_order(_order)
13
+ raise 'Spree::TaxCloud is designed to calculate taxes at the shipment and line-item levels.'
14
+ end
15
+
16
+ # When it comes to computing shipments or line items: same same.
17
+ def compute_shipment_or_line_item(item)
18
+ if rate.included_in_price
19
+ raise 'TaxCloud cannot calculate inclusive sales taxes.'
20
+ else
21
+ round_to_two_places(tax_for_item(item))
22
+ # TODO: take discounted_amount into account. This is a problem because TaxCloud API does not take discounts nor does it return percentage rates.
23
+ end
24
+ end
25
+
26
+ alias compute_shipment compute_shipment_or_line_item
27
+ alias compute_line_item compute_shipment_or_line_item
28
+
29
+ def compute_shipping_rate(_shipping_rate)
30
+ if rate.included_in_price
31
+ raise 'TaxCloud cannot calculate inclusive sales taxes.'
32
+ else
33
+ # Sales tax will be applied to the Shipment itself, rather than to the Shipping Rates.
34
+ # Note that this method is called from ShippingRate.display_price, so if we returned
35
+ # the shipping sales tax here, it would display as part of the display_price of the
36
+ # ShippingRate, which is not consistent with how US sales tax typically works -- i.e.,
37
+ # it is an additional amount applied to a sale at the end, rather than being part of
38
+ # the displayed cost of a good or service.
39
+ 0
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def tax_for_item(item)
46
+ order = item.order
47
+ item_address = order.ship_address || order.billing_address
48
+
49
+ # Only calculate tax when we have an address and it's in our jurisdiction
50
+ return 0 unless item_address.present? && calculable.zone.include?(item_address)
51
+
52
+ # Cache will expire if the order, any of its line items, or any of its shipments change.
53
+ # When the cache expires, we will need to make another API call to TaxCloud.
54
+ Rails.cache.fetch(
55
+ ['TaxCloudRatesForItem', item.tax_cloud_cache_key],
56
+ version: item.tax_cloud_cache_version,
57
+ time_to_idle: 30.minutes,
58
+ ) do
59
+ # In the case of a cache miss, we recompute the amounts for _all_ the LineItems and Shipments for this Order.
60
+ # TODO An ideal implementation will break the order down by Shipments / Packages
61
+ # and use the actual StockLocation address for each separately, and create Adjustments
62
+ # for the Shipments to reflect tax on shipping.
63
+ transaction = Spree::TaxCloud.transaction_from_order(order)
64
+ lookup_cart_items = transaction.lookup.cart_items
65
+
66
+ # Now we will loop back through the items and assign them amounts from the lookup.
67
+ # This inefficient method is due to the fact that item_id isn't preserved in the lookup.
68
+ # TODO There may be a way to refactor this,
69
+ # possibly by overriding the TaxCloud::Responses::Lookup model
70
+ # or the CartItems model.
71
+ index = -1 # array is zero-indexed
72
+
73
+ item_tax_amount = nil
74
+
75
+ # Retrieve line_items from lookup
76
+ order.line_items.each do |line_item|
77
+ tax_amount = lookup_cart_items[index += 1].tax_amount
78
+
79
+ if line_item == item
80
+ item_tax_amount = tax_amount
81
+ else
82
+ Rails.cache.write(
83
+ ['TaxCloudRatesForItem', line_item.tax_cloud_cache_key],
84
+ tax_amount,
85
+ version: line_item.tax_cloud_cache_version,
86
+ time_to_idle: 30.minutes,
87
+ )
88
+ end
89
+ end
90
+
91
+ order.shipments.each do |shipment|
92
+ tax_amount = lookup_cart_items[index += 1].tax_amount
93
+
94
+ if shipment == item
95
+ item_tax_amount = tax_amount
96
+ else
97
+ Rails.cache.write(
98
+ ['TaxCloudRatesForItem', shipment.tax_cloud_cache_key],
99
+ tax_amount,
100
+ version: shipment.tax_cloud_cache_version,
101
+ time_to_idle: 30.minutes,
102
+ )
103
+ end
104
+ end
105
+
106
+ # Lastly, return the particular rate that we were initially looking for
107
+ item_tax_amount
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class TaxCloud
5
+ def self.transaction_from_order(order)
6
+ stock_location = order.shipments.first.try(:stock_location) || Spree::StockLocation.active.where('city IS NOT NULL and state_id IS NOT NULL').first
7
+ raise I18n.t('spree.ensure_one_valid_stock_location') unless stock_location
8
+
9
+ destination = address_from_spree_address(order.ship_address || order.billing_address)
10
+ begin
11
+ destination = destination.verify # Address validation may fail, and that is okay.
12
+ rescue ::TaxCloud::Errors::ApiError
13
+ end
14
+
15
+ transaction = ::TaxCloud::Transaction.new(
16
+ customer_id: order.user_id || order.email,
17
+ order_id: order.number,
18
+ cart_id: order.number,
19
+ origin: address_from_spree_address(stock_location),
20
+ destination: destination
21
+ )
22
+
23
+ index = -1 # array is zero-indexed
24
+ # Prepare line_items for lookup
25
+ order.line_items.each { |line_item| transaction.cart_items << cart_item_from_item(line_item, index += 1) }
26
+ # Prepare shipments for lookup
27
+ order.shipments.each { |shipment| transaction.cart_items << cart_item_from_item(shipment, index += 1) }
28
+ transaction
29
+ end
30
+
31
+ # Note that this method can take either a Spree::StockLocation (which has address
32
+ # attributes directly on it) or a Spree::Address object
33
+ def self.address_from_spree_address(address)
34
+ ::TaxCloud::Address.new(
35
+ address1: address.address1,
36
+ address2: address.address2,
37
+ city: address.city,
38
+ state: address.try(:state).try(:abbr),
39
+ zip5: address.zipcode.try(:[], 0...5)
40
+ )
41
+ end
42
+
43
+ def self.cart_item_from_item(item, index)
44
+ case item
45
+ when Spree::LineItem
46
+ ::TaxCloud::CartItem.new(
47
+ index: index,
48
+ item_id: item.try(:variant).try(:sku).presence || "LineItem #{item.id}",
49
+ tic: (item.product.tax_cloud_tic || Spree::Config.taxcloud_default_product_tic),
50
+ price: item.price_with_discounts,
51
+ quantity: item.quantity
52
+ )
53
+ when Spree::Shipment
54
+ ::TaxCloud::CartItem.new(
55
+ index: index,
56
+ item_id: "Shipment #{item.number}",
57
+ tic: Spree::Config.taxcloud_shipping_tic,
58
+ price: item.price_with_discounts,
59
+ quantity: 1
60
+ )
61
+ else
62
+ raise I18n.t('spree.cart_item_cannot_be_made')
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ Deface::Override.new(
4
+ virtual_path: 'spree/admin/products/_form',
5
+ name: 'add_tic_to_admin_product_edit',
6
+ insert_after: "[data-hook='admin_product_form_tax_category']",
7
+ partial: 'spree/admin/products/edit_tax_cloud_tic',
8
+ original: 'a6d7d1941bde020c34025a78466febd8453cf71a'
9
+ )
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ Deface::Override.new(
4
+ virtual_path: 'spree/admin/shared/_configuration_menu',
5
+ name: 'add_tax_cloud_admin_menu_link',
6
+ insert_bottom: "[data-hook='admin_configurations_sidebar_menu']",
7
+ text: "<%= configurations_sidebar_menu_item 'TaxCloud Settings', edit_admin_tax_cloud_settings_path %>",
8
+ original: 'dcaffe234d5b43d5b7673bc13f227500957c9e73'
9
+ )
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "bundler/setup"
6
+ require "solidus_tax_cloud"
7
+
8
+ # You can add fixtures and/or initialization code here to make experimenting
9
+ # with your gem easier. You can also use a different console, if you like.
10
+ $LOAD_PATH.unshift(*Dir["#{__dir__}/../app/*"])
11
+
12
+ # (If you use this, don't forget to add pry to your Gemfile!)
13
+ # require "pry"
14
+ # Pry.start
15
+
16
+ require "irb"
17
+ IRB.start(__FILE__)
data/bin/r ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # This command will automatically be run when you run "rails" with Rails gems
5
+ # installed from the root of your application.
6
+
7
+ ENGINE_ROOT = File.expand_path('..', __dir__)
8
+ ENGINE_PATH = File.expand_path('../lib/solidus_tax_cloud/engine', __dir__)
9
+
10
+ # Set up gems listed in the Gemfile.
11
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
12
+ require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
13
+
14
+ require 'rails/all'
15
+ require 'rails/engine/commands'
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ app_root = 'spec/dummy'
6
+
7
+ unless File.exist? "#{app_root}/bin/rails"
8
+ system "bin/rake", app_root or begin # rubocop:disable Style/AndOr
9
+ warn "Automatic creation of the dummy app failed"
10
+ exit 1
11
+ end
12
+ end
13
+
14
+ Dir.chdir app_root
15
+ exec 'bin/rails', *ARGV