panier 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +33 -0
- data/Rakefile +6 -0
- data/bin/panier +14 -0
- data/docs/application.md +9 -0
- data/docs/domain_model.md +21 -0
- data/docs/examples.md +45 -0
- data/docs/implementation_notes.md +39 -0
- data/lib/panier/application/cli.rb +72 -0
- data/lib/panier/application/input_reader.rb +41 -0
- data/lib/panier/application/sales_tax_service.rb +28 -0
- data/lib/panier/decorators/decorator.rb +25 -0
- data/lib/panier/decorators/receipt_decorator.rb +29 -0
- data/lib/panier/domain/line_item.rb +85 -0
- data/lib/panier/domain/product.rb +33 -0
- data/lib/panier/domain/product_service.rb +47 -0
- data/lib/panier/domain/receipt.rb +40 -0
- data/lib/panier/domain/round_up_rounding.rb +38 -0
- data/lib/panier/domain/tax_class.rb +28 -0
- data/lib/panier/version.rb +4 -0
- data/lib/panier.rb +22 -0
- data/panier.gemspec +29 -0
- data/spec/factories/line_item.rb +17 -0
- data/spec/factories/product.rb +16 -0
- data/spec/factories/receipt.rb +21 -0
- data/spec/factories/tax_class.rb +8 -0
- data/spec/panier/application/input_reader_spec.rb +21 -0
- data/spec/panier/application/sales_tax_service_spec.rb +83 -0
- data/spec/panier/decorators/receipt_decorator_spec.rb +24 -0
- data/spec/panier/domain/line_item_spec.rb +100 -0
- data/spec/panier/domain/product_spec.rb +28 -0
- data/spec/panier/domain/receipt_spec.rb +33 -0
- data/spec/panier/domain/round_up_rounding_spec.rb +26 -0
- data/spec/panier/domain/tax_class_spec.rb +21 -0
- data/spec/spec_helper.rb +14 -0
- 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
|
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,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
|
data/spec/spec_helper.rb
ADDED
@@ -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
|