solidus_tax_cloud 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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