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.
- 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
|