Upgrade to Pro — share decks privately, control downloads, hide ads and more …

VAT WAT - Value Added Taxes in Spree and Solidus

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.

VAT WAT - Value Added Taxes in Spree and Solidus

As a merchant, you want to play nice with local authorities and correctly calculate whatever taxes apply to your merchandise. SpreeCommerce and Solidus have a strong history and good support for US-American style sales tax, but the support for Value-Added Tax as used in many regions in the world has been a little sketchy.

Avatar for Martin Meyerhoff

Martin Meyerhoff

May 12, 2016
Tweet

More Decks by Martin Meyerhoff

Other Decks in Programming

Transcript

  1. Martin Meyerhoff Developer with bitspire, creators of AlchemyCMS kiosk.brandeins.de -

    lots of VAT craziness Rebuilt Spree / Solidus’ taxation system … 3 times Solidus core member
  2. A consumption tax Relevant for e-commerce USA / Canada: Sales

    Tax Rest of the World: VAT / GST What's VAT?
  3. nunuShirts 1: nunuShirts buys a Shirt Nunu Shirts buys T-Shirts

    for 10 € incl. 10% VAT: Net price: 10 / (1 + 10%) = 9.09090909 ≈ 9.09 Included VAT: 0.81 Price: 10
  4. nunuShirts 2: nunuShirts sells a shirt Nunu Shirts sells T-Shirts

    for 25 € incl. 10% VAT: Net price: 25 / (1 + 10%) = 22.72727272 ≈ 22.73 Included VAT: 2.27 Price: 25
  5. nunuShirts 3: What tax does nunuShirts pay? At the end

    of the month, nunuShirts pays VAT for the value they add: VAT on revenue: 2.27 - VAT on inputs: 0.81 —————————————————————— = VAT to pay: 1.46
  6. nunuShirts 4: nunuShirts sells many shirts Nunu Shirts sells 250

    Shirts at 25 € each with 10% VAT: Total: 6250 Net revenue: 6250 / 1.1 ≈ 5681.82 Correct VAT: 6250 - 5681.82 = 568.18 Wrong VAT: 2.27 * 250 = 567.50
  7. nunuShirts 4: nunuShirts sells many shirts Nunu Shirts sells 250

    Shirts at 25 € each with 10% VAT: Total: 6250 Net revenue: 6250 / 1.1 ≈ 5681.82 Correct VAT: 6250 - 5681.82 = 568.18 Wrong VAT: 2.27 * 250 = 567.50
  8. nunuShirts 5: nunuShirts sells a Shirt to the USA USA

    is outside the EU nunuShirts sells at net price (22.73) Total: 22.73 Net revenue: 22.73 Correct VAT: 0
  9. nunuShirts 5: Displaying prices Prices have to be displayed including

    VAT on PDPs No surprises at checkout They also have to detail percentage OR included tax amount Good: 25.00 € incl. 10% VAT Good: 25.00 € incl. 2.73 € VAT Bad: 22.73 + VAT Bad: 25.00
  10. nunuShirts 5: Displaying prices Prices have to be displayed including

    VAT on PDPs No surprises at checkout They also have to detail percentage OR included tax amount Good: 25.00 € incl. 10% VAT Good: 25.00 € incl. 2.73 € VAT Bad: 22.73 + VAT Bad: 25.00
  11. Which rates apply? Sale inside EU: Warehouse VAT rate Export

    outside EU: none Sale inside EU, lots of $$: Customers country VAT
  12. Which rates apply? Sale inside EU: Warehouse VAT rate Export

    outside EU: none Sale inside EU, lots of $$: Shipping address country VAT Sale inside EU, digital goods: Shipping address country VAT
  13. Taxes in Solidus (basic) Variant Tax Category Tax Rate Zone

    Country/State Shirt Clothes Most things 19% VAT Europe Germany
  14. Sales tax in Solidus Spree::LineItem amount: 25.0 additional_tax_total: 2.5 total:

    27.5 Spree::Adjustment source: "Spree::TaxRate" included: false
  15. VAT in Solidus Spree::LineItem total: 25.00 included_tax_total: 2.73 amount: 25.00

    Spree::Adjustment source: "Spree::TaxRate" included: true
  16. The VAT export hack Solidus has one price per variant

    No dynamic changing of price depending on circumstance What do we do?
  17. The VAT export hack # This method is used by

    Adjustment#update to recalculate the cost. def compute_amount(item) if included_in_price if default_zone_or_zone_match?(item.order.tax_zone) calculator.compute(item) else # In this case, it's a refund. calculator.compute(item) * - 1 end else calculator.compute(item) end end
  18. The VAT export hack # This method is used by

    Adjustment#update to recalculate the cost. def compute_amount(item) if included_in_price if default_zone_or_zone_match?(item.order.tax_zone) calculator.compute(item) else # In this case, it's a refund. calculator.compute(item) * - 1 end else calculator.compute(item) end end
  19. The VAT export hack def adjust(order_tax_zone, item) amount = compute_amount(item)

    return if amount == 0 included = included_in_price && default_zone_or_zone_match? (order_tax_zone) if amount < 0 label = Spree.t(:refund) + ' ' + create_label end self.adjustments.create!({ :adjustable => item, :amount => amount, :order_id => item.order_id, :label => label || create_label, :included => included }) end
  20. The VAT export hack def adjust(order_tax_zone, item) amount = compute_amount(item)

    return if amount == 0 included = included_in_price && default_zone_or_zone_match? (order_tax_zone) if amount < 0 label = Spree.t(:refund) + ' ' + create_label end self.adjustments.create!({ :adjustable => item, :amount => amount, :order_id => item.order_id, :label => label || create_label, :included => included }) end
  21. The VAT export hack We now have: - an additional

    adjustment - from a VAT rate that's included_in_price - whose amount is negative. Yummy. Where does this lead to?
  22. The VAT export hack: Consequences def display_price price = display_base_price.to_s

    if tax_rate tax_amount = calculate_tax_amount if tax_amount != 0 if tax_rate.included_in_price? if tax_amount > 0 amount = "#{display_tax_amount(tax_amount)} #{tax_rate.name}" price += " (#{Spree.t(:incl)} #{amount})" else amount = "#{display_tax_amount(tax_amount*-1)} #{tax_rate.name}" price += " (#{Spree.t(:excl)} #{amount})" end else amount = "#{display_tax_amount(tax_amount)} #{tax_rate.name}" price += " (+ #{amount})" end end end price end
  23. Solution: Store prices per country We unlink pricing from taxation

    No more Franken-Adjustments Prices know where they apply Speed Speed Speed Bonus: This yielded an API for customizing pricing in general
  24. The terrible, terrible `default_zone` • A boolean on the spree_zones

    table • "The zone whose taxes are assumed to be inside all prices" • "The tax zone of an order that has no address" • Hopefully a zone created for taxation (not shipping)
  25. The terrible, terrible `default_zone` • A zone has_many members •

    Order has a zone • Tax rate has a zone • Now we have to see whether ALL order zone members are inside any tax rate zones
  26. The terrible, terrible `default_zone` # Returns the relevant zone (if

    any) to be used for taxation purposes. # Uses default tax zone unless there is a specific match def tax_zone @tax_zone ||= Zone.match(tax_address) || Zone.default_tax end # Returns the address for taxation based on configuration def tax_address Spree::Config[:tax_using_ship_address] ? ship_address : bill_address end def reload(options=nil) remove_instance_variable(:@tax_zone) if defined?(@tax_zone) super end
  27. The terrible, terrible `default_zone` # Returns the relevant zone (if

    any) to be used for taxation purposes. # Uses default tax zone unless there is a specific match def tax_zone @tax_zone ||= Zone.match(tax_address) || Zone.default_tax end # Returns the address for taxation based on configuration def tax_address Spree::Config[:tax_using_ship_address] ? ship_address : bill_address end def reload(options=nil) remove_instance_variable(:@tax_zone) if defined?(@tax_zone) super end
  28. The terrible, terrible `default_zone` # Returns the matching zone with

    the highest priority zone type (State, Country, Zone.) # Returns nil in the case of no matches. def self.match(address) return unless address and matches = self.includes(:zone_members). order(:zone_members_count, :created_at, :id). where("(spree_zone_members.zoneable_type = 'Spree::Country' AND spree_zone_members.zoneable_id = ?) OR (spree_zone_members.zoneable_type = 'Spree::State' AND spree_zone_members.zoneable_id = ?)", address.country_id, address.state_id). references(:zones) ['state', 'country'].each do |zone_kind| if match = matches.detect { |zone| zone_kind == zone.kind } return match end end matches.first end
  29. Solution: Matching addresses to zones Much Simpler. Spree::Order # Returns

    the address for taxation based on configuration def tax_address if Spree::Config[:tax_using_ship_address] ship_address else bill_address end || store.default_cart_tax_location end Spree::TaxRate # Finds all tax rates whose zones match a given address scope :for_address, ->(address) { joins(:zone).merge(Spree::Zone.for_address(address)) }
  30. Solutions: Many more things Persisted shipping rates Separate business and

    taxation logic Better i18n More consistent naming of columns (`amount` everywhere)
  31. Outlook Extract taxation from core Define high-level API for taxation

    Limit API calls to Avalara/TaxCloud for US friends