panier 0.0.1

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +22 -0
  3. data/Gemfile +3 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +33 -0
  6. data/Rakefile +6 -0
  7. data/bin/panier +14 -0
  8. data/docs/application.md +9 -0
  9. data/docs/domain_model.md +21 -0
  10. data/docs/examples.md +45 -0
  11. data/docs/implementation_notes.md +39 -0
  12. data/lib/panier/application/cli.rb +72 -0
  13. data/lib/panier/application/input_reader.rb +41 -0
  14. data/lib/panier/application/sales_tax_service.rb +28 -0
  15. data/lib/panier/decorators/decorator.rb +25 -0
  16. data/lib/panier/decorators/receipt_decorator.rb +29 -0
  17. data/lib/panier/domain/line_item.rb +85 -0
  18. data/lib/panier/domain/product.rb +33 -0
  19. data/lib/panier/domain/product_service.rb +47 -0
  20. data/lib/panier/domain/receipt.rb +40 -0
  21. data/lib/panier/domain/round_up_rounding.rb +38 -0
  22. data/lib/panier/domain/tax_class.rb +28 -0
  23. data/lib/panier/version.rb +4 -0
  24. data/lib/panier.rb +22 -0
  25. data/panier.gemspec +29 -0
  26. data/spec/factories/line_item.rb +17 -0
  27. data/spec/factories/product.rb +16 -0
  28. data/spec/factories/receipt.rb +21 -0
  29. data/spec/factories/tax_class.rb +8 -0
  30. data/spec/panier/application/input_reader_spec.rb +21 -0
  31. data/spec/panier/application/sales_tax_service_spec.rb +83 -0
  32. data/spec/panier/decorators/receipt_decorator_spec.rb +24 -0
  33. data/spec/panier/domain/line_item_spec.rb +100 -0
  34. data/spec/panier/domain/product_spec.rb +28 -0
  35. data/spec/panier/domain/receipt_spec.rb +33 -0
  36. data/spec/panier/domain/round_up_rounding_spec.rb +26 -0
  37. data/spec/panier/domain/tax_class_spec.rb +21 -0
  38. data/spec/spec_helper.rb +14 -0
  39. metadata +207 -0
@@ -0,0 +1,28 @@
1
+ module Panier
2
+ module Domain
3
+ ##
4
+ # A tax class is a value object that describes a particular type of tax or
5
+ # duty applicable to products sold.
6
+ #
7
+ class TaxClass
8
+ attr_reader :name, :rate
9
+
10
+ ##
11
+ # Initializes the tax class using the given name and rate.
12
+ #
13
+ # @param name [String] A display name for the tax class.
14
+ # @param rate [Float] The rate of tax, where 0.1 would represent 10% tax.
15
+ def initialize(name, rate)
16
+ @name = name
17
+ self.rate = rate
18
+ end
19
+
20
+ protected
21
+
22
+ def rate=(rate)
23
+ fail ArgumentError, ':rate must be non-negative' if rate < 0
24
+ @rate = rate
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,4 @@
1
+ # Encoding: utf-8
2
+ module Panier
3
+ VERSION = '0.0.1'
4
+ end
data/lib/panier.rb ADDED
@@ -0,0 +1,22 @@
1
+ # Encoding: utf-8
2
+ require 'active_support'
3
+ require 'active_support/dependencies'
4
+
5
+ require 'panier/version'
6
+
7
+ require 'panier/domain/line_item'
8
+ require 'panier/domain/receipt'
9
+ require 'panier/domain/product'
10
+ require 'panier/domain/tax_class'
11
+
12
+ require 'panier/application/input_reader'
13
+ require 'panier/application/cli'
14
+ require 'panier/application/sales_tax_service'
15
+
16
+ ##
17
+ # The module containing all the panier code.
18
+ #
19
+ module Panier
20
+ relative_load_paths = %w(lib)
21
+ ActiveSupport::Dependencies.autoload_paths += relative_load_paths
22
+ end
data/panier.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # Encoding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'panier/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'panier'
8
+ spec.version = Panier::VERSION
9
+ spec.authors = ['Luke Eller']
10
+ spec.email = ['luke.eller@bigcommerce.com']
11
+ spec.summary = 'A gem demonstrating the calculation of sales taxes.'
12
+ spec.homepage = ''
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(/^(test|spec|features)\//)
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.add_dependency 'money'
21
+ spec.add_dependency 'workflow'
22
+ spec.add_dependency 'activesupport'
23
+
24
+ spec.add_development_dependency 'bundler', '~> 1.6'
25
+ spec.add_development_dependency 'rake'
26
+ spec.add_development_dependency 'rspec'
27
+ spec.add_development_dependency 'factory_girl'
28
+ spec.add_development_dependency 'faker'
29
+ end
@@ -0,0 +1,17 @@
1
+ FactoryGirl.define do
2
+ factory :line_item, class: Panier::Domain::LineItem do
3
+
4
+ ignore do
5
+ product_price Money.new(200)
6
+ end
7
+
8
+ product { build :product, price: product_price }
9
+ quantity 1
10
+
11
+ initialize_with { new(product, quantity) }
12
+
13
+ factory :taxable_line_item do
14
+ product { build :taxable_product, price: product_price }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ require 'money'
2
+
3
+ FactoryGirl.define do
4
+ factory :product, class: Panier::Domain::Product do
5
+
6
+ name Faker::Commerce.product_name
7
+ price Money.new(200)
8
+ tax_classes []
9
+
10
+ initialize_with { new(name, price, tax_classes) }
11
+
12
+ factory :taxable_product do
13
+ tax_classes { [build(:tax_class)] }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ FactoryGirl.define do
2
+ factory :receipt, class: Panier::Domain::Receipt do
3
+ line_items { [build(:line_item)] }
4
+
5
+ initialize_with { new(line_items) }
6
+
7
+ factory :multi_item_receipt do
8
+
9
+ ignore do
10
+ item_count 5
11
+ product_price Money.new(100)
12
+ end
13
+
14
+ line_items do
15
+ Array.new(item_count) do
16
+ build(:line_item, product_price: product_price)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,8 @@
1
+ FactoryGirl.define do
2
+ factory :tax_class, class: Panier::Domain::TaxClass do
3
+ name 'Basic sales tax'
4
+ rate 0.1
5
+
6
+ initialize_with { new(name, rate) }
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ require 'spec_helper'
2
+
3
+ describe Panier::Application::InputReader do
4
+ describe '#create_line_item' do
5
+ it 'creates a line item from input' do
6
+
7
+ service = instance_double(Panier::Domain::ProductService)
8
+ product = build :product
9
+
10
+ expect(service).to receive(:find_by_name_and_price)
11
+ .with(product.name, product.price) { product }
12
+
13
+ reader = Panier::Application::InputReader.new(service)
14
+ line_item = reader.parse_line("1, #{product.name}, #{product.price}")
15
+
16
+ expect(line_item.quantity).to eq(1)
17
+ expect(line_item.description).to eq(product.name)
18
+ expect(line_item.unit_amount).to eq(product.price)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,83 @@
1
+ require 'spec_helper'
2
+
3
+ include Panier::Domain
4
+
5
+ describe Panier::Application::SalesTaxService do
6
+ shared_examples 'produces CSV output' do
7
+ it 'should produce a CSV formatted receipt' do
8
+ service = Panier::Application::SalesTaxService.new
9
+
10
+ actual = service.evaluate_input(input)
11
+
12
+ expect(actual).to eq(expected)
13
+ end
14
+ end
15
+
16
+ describe '#evaluate_input' do
17
+ context 'given the data from input 1' do
18
+ include_examples 'produces CSV output' do
19
+ let(:input) do
20
+ 'Quantity, Product, Price
21
+ 1, book, 12.49
22
+ 1, music CD, 14.99
23
+ 1, chocolate bar, 0.85
24
+ '
25
+ end
26
+
27
+ let(:expected) do
28
+ '1, book, 12.49
29
+ 1, music CD, 16.49
30
+ 1, chocolate bar, 0.85
31
+
32
+ Sales Taxes: 1.50
33
+ Total: 29.83
34
+ '
35
+ end
36
+ end
37
+ end
38
+
39
+ context 'given the data from input 2' do
40
+ include_examples 'produces CSV output' do
41
+ let(:input) do
42
+ 'Quantity, Product, Price
43
+ 1, imported box of chocolates, 10.00
44
+ 1, imported bottle of perfume, 47.50
45
+ '
46
+ end
47
+
48
+ let(:expected) do
49
+ '1, imported box of chocolates, 10.50
50
+ 1, imported bottle of perfume, 54.65
51
+
52
+ Sales Taxes: 7.65
53
+ Total: 65.15
54
+ '
55
+ end
56
+ end
57
+ end
58
+
59
+ context 'given the data from input 3' do
60
+ include_examples 'produces CSV output' do
61
+ let(:input) do
62
+ 'Quantity, Product, Price
63
+ 1, imported bottle of perfume, 27.99
64
+ 1, bottle of perfume, 18.99
65
+ 1, packet of headache pills, 9.75
66
+ 1, box of imported chocolates, 11.25
67
+ '
68
+ end
69
+
70
+ let(:expected) do
71
+ '1, imported bottle of perfume, 32.19
72
+ 1, bottle of perfume, 20.89
73
+ 1, packet of headache pills, 9.75
74
+ 1, box of imported chocolates, 11.85
75
+
76
+ Sales Taxes: 6.70
77
+ Total: 74.68
78
+ '
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,24 @@
1
+ require 'spec_helper'
2
+
3
+ include Panier::Domain
4
+
5
+ describe Panier::Decorators::ReceiptDecorator do
6
+ describe '#to_csv' do
7
+ it 'should generate CSV containing details of the receipt' do
8
+ line_items = [
9
+ LineItem.new(Product.new('product 1', Money.new(1000), []), 1),
10
+ LineItem.new(Product.new('product 2', Money.new(1500),
11
+ [build(:tax_class)]), 1)
12
+ ]
13
+ receipt = Domain::Receipt.new(line_items)
14
+ decorator = Panier::Decorators::ReceiptDecorator.new(receipt)
15
+
16
+ expect(decorator.to_csv).to eq('1, product 1, 10.00
17
+ 1, product 2, 16.50
18
+
19
+ Sales Taxes: 1.50
20
+ Total: 26.50
21
+ ')
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,100 @@
1
+ require 'spec_helper'
2
+ require 'money'
3
+
4
+ include Panier
5
+
6
+ describe Domain::LineItem do
7
+ describe '#initialize' do
8
+ before :each do
9
+ @product = build :taxable_product
10
+ @line_item = build :taxable_line_item, product: @product
11
+ end
12
+
13
+ it 'assigns the quantity argument to the quantity attribute' do
14
+ quantity = 5
15
+
16
+ line_item = build :line_item, quantity: quantity
17
+
18
+ expect(line_item.quantity).to eq(quantity)
19
+ end
20
+
21
+ it 'assigns the product argument to the product attribute' do
22
+ expect(@line_item.product).to eq(@product)
23
+ end
24
+
25
+ it 'assigns the description based on the product name' do
26
+ expect(@line_item.description).to eq(@product.name)
27
+ end
28
+
29
+ it 'assigns the unit amount based on the product price' do
30
+ expect(@line_item.unit_amount).to eq(@product.price)
31
+ end
32
+
33
+ it 'assigns the tax classes based on the product tax classes' do
34
+ expect(@line_item.tax_classes).to eq(@product.tax_classes)
35
+ end
36
+
37
+ it "does not hold a reference to the product's array of tax classes" do
38
+ expect(@line_item.tax_classes).to_not be(@product.tax_classes)
39
+ end
40
+
41
+ it 'rejects negative quantities' do
42
+ product = build :product
43
+ quantity = -5
44
+
45
+ expect { Domain::LineItem.new(product, quantity) }.to raise_exception
46
+ end
47
+ end
48
+
49
+ describe '#total_amount' do
50
+ it 'calculates the total value of the line item' do
51
+ line_item = build :taxable_line_item, quantity: 5
52
+ expect(line_item.total_amount).to eq(Money.new(1000))
53
+ end
54
+ end
55
+
56
+ describe '#total_tax' do
57
+ it 'calculates the total amount of tax included in the line item' do
58
+ line_item = build :taxable_line_item, quantity: 5
59
+ expect(line_item.total_tax).to eq(Money.new(100))
60
+ end
61
+ end
62
+
63
+ describe '#total_amount_inc_tax' do
64
+ it 'calculates the total value of the line item including tax' do
65
+ line_item = build :taxable_line_item, quantity: 5
66
+ expect(line_item.total_amount_inc_tax).to eq(Money.new(1100))
67
+ end
68
+ end
69
+
70
+ describe '#unit_amount_inc_tax' do
71
+ it 'calculates the total value of the line item including tax' do
72
+ line_item = build :taxable_line_item, quantity: 5
73
+ expect(line_item.unit_amount_inc_tax).to eq(Money.new(220))
74
+ end
75
+ end
76
+
77
+ describe '#unit_tax' do
78
+ describe 'tax rounding' do
79
+ it 'rounds low values up to the nearest 5 cents' do
80
+ line_item = build :taxable_line_item, product_price: Money.new(310)
81
+ expect(line_item.unit_tax).to eq(Money.new(35))
82
+ end
83
+
84
+ it 'rounds median values up to the nearest 5 cents' do
85
+ line_item = build :taxable_line_item, product_price: Money.new(125)
86
+ expect(line_item.unit_tax).to eq(Money.new(15))
87
+ end
88
+
89
+ it 'rounds high values up to the nearest 5 cents' do
90
+ line_item = build :taxable_line_item, product_price: Money.new(140)
91
+ expect(line_item.unit_tax).to eq(Money.new(15))
92
+ end
93
+
94
+ it 'does not change values divisible by 5' do
95
+ line_item = build :taxable_line_item, product_price: Money.new(150)
96
+ expect(line_item.unit_tax).to eq(Money.new(15))
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,28 @@
1
+ require 'spec_helper'
2
+
3
+ include Panier
4
+
5
+ describe Domain::Product do
6
+
7
+ describe '#initialize' do
8
+ it 'assigns its arguments to attributes' do
9
+ name = Faker::Commerce.product_name
10
+ price = Money.new(200)
11
+ tax_classes = [build(:tax_class)]
12
+ product = build :taxable_product,
13
+ name: name, price: price, tax_classes: tax_classes
14
+
15
+ expect(product.name).to eq(name)
16
+ expect(product.price).to eq(price)
17
+ expect(product.tax_classes).to eq(tax_classes)
18
+ end
19
+ end
20
+
21
+ describe '#price=' do
22
+ it 'rejects negative prices' do
23
+ product = build :product
24
+
25
+ expect { product.price = Money.new(-1) }.to raise_exception
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ require 'spec_helper'
2
+
3
+ include Panier
4
+
5
+ describe Domain::Receipt do
6
+ let(:line_items) do
7
+ [
8
+ build(:taxable_line_item, product_price: Money.new(100)),
9
+ build(:taxable_line_item, product_price: Money.new(200))
10
+ ]
11
+ end
12
+
13
+ describe '#initialize' do
14
+ it 'should initialize the line items' do
15
+ receipt = Domain::Receipt.new(line_items)
16
+ expect(receipt.line_items).to eq(line_items)
17
+ end
18
+ end
19
+
20
+ describe '#total_amount' do
21
+ it 'should calculate the total value of the receipt including tax' do
22
+ receipt = Domain::Receipt.new(line_items)
23
+ expect(receipt.total_amount).to eq(Money.new(330))
24
+ end
25
+ end
26
+
27
+ describe '#total_tax' do
28
+ it 'should calculate the total tax present on the receipt' do
29
+ receipt = Domain::Receipt.new(line_items)
30
+ expect(receipt.total_tax).to eq(Money.new(30))
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ require 'rspec'
2
+
3
+ include Panier
4
+
5
+ describe Domain::RoundUpRounding do
6
+
7
+ it 'rounds low values up to the nearest increment' do
8
+ strategy = Domain::RoundUpRounding.new
9
+ expect(strategy.round(Money.new(11))).to eq(Money.new(15))
10
+ end
11
+
12
+ it 'rounds median values up to the nearest increment' do
13
+ strategy = Domain::RoundUpRounding.new(4)
14
+ expect(strategy.round(Money.new(2))).to eq(Money.new(4))
15
+ end
16
+
17
+ it 'rounds high values up to the nearest increment' do
18
+ strategy = Domain::RoundUpRounding.new
19
+ expect(strategy.round(Money.new(14))).to eq(Money.new(15))
20
+ end
21
+
22
+ it 'does not change values divisible by the increment' do
23
+ strategy = Domain::RoundUpRounding.new
24
+ expect(strategy.round(Money.new(15))).to eq(Money.new(15))
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ require 'spec_helper'
2
+
3
+ include Panier
4
+
5
+ describe Domain::TaxClass do
6
+ describe '#initialize' do
7
+ it 'assigns its arguments to attributes' do
8
+ name = 'Basic sales tax'
9
+ rate = 0.1
10
+ tax_class = Domain::TaxClass.new(name, rate)
11
+ expect(tax_class.name).to eq(name)
12
+ expect(tax_class.rate).to eq(rate)
13
+ end
14
+
15
+ it 'rejects negative tax rates' do
16
+ name = 'Backwards sales tax'
17
+ rate = -0.1
18
+ expect { Domain::TaxClass.new(name, rate) }.to raise_error
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ require 'rspec'
2
+ require 'faker'
3
+ require 'factory_girl'
4
+ require 'active_support'
5
+ require 'active_support/dependencies'
6
+ relative_load_paths = %w(lib)
7
+ ActiveSupport::Dependencies.autoload_paths += relative_load_paths
8
+
9
+ FactoryGirl.find_definitions
10
+
11
+ # rspec
12
+ RSpec.configure do |config|
13
+ config.include FactoryGirl::Syntax::Methods
14
+ end