panier 0.0.1

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