Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor by creating Term, Amount and ExciseTaxRate classes #13

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 41 additions & 88 deletions lib/jct.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
require 'date'
require 'bigdecimal'
require 'bigdecimal/util'
require 'jct/excise_tax_rate'
require 'jct/term'
require 'jct/amount'
require 'jct/version'

module Jct
Expand All @@ -11,57 +14,37 @@ module Jct
RATE105 = 1.05r.freeze
RATE108 = 1.08r.freeze
RATE110 = 1.10r.freeze
EXCISE_HASHES = [
EXCISE_TAX_RATES = [
# 1873/1/1 is the date when Japan changed its calendar to the solar calendar (Meiji era).
{ rate: RATE100, start_on: Date.new(1873, 1, 1), end_on: Date.new(1989, 3, 31) },
{ rate: RATE103, start_on: Date.new(1989, 4, 1), end_on: Date.new(1997, 3, 31) },
{ rate: RATE105, start_on: Date.new(1997, 4, 1), end_on: Date.new(2014, 3, 31) },
{ rate: RATE108, start_on: Date.new(2014, 4, 1), end_on: Date.new(2019, 9, 30) },
# If we were to use Date::Infinity.new for end_on, an exception would occur in the later calculation,
ExciseTaxRate.new(rate: RATE100, term: Term.new(start_on: Date.new(1873, 1, 1), end_on: Date.new(1989, 3, 31))),
ExciseTaxRate.new(rate: RATE103, term: Term.new(start_on: Date.new(1989, 4, 1), end_on: Date.new(1997, 3, 31))),
ExciseTaxRate.new(rate: RATE105, term: Term.new(start_on: Date.new(1997, 4, 1), end_on: Date.new(2014, 3, 31))),
ExciseTaxRate.new(rate: RATE108, term: Term.new(start_on: Date.new(2014, 4, 1), end_on: Date.new(2019, 9, 30))),
# If we were to use Date::Infinity.new for end_on, an exception would occur in the later calculation,
# so here we will use a date far in the future.
{ rate: RATE110, start_on: Date.new(2019, 10, 1), end_on: Date.new(2999, 1, 1) }
ExciseTaxRate.new(rate: RATE110, term: Term.new(start_on: Date.new(2019, 10, 1), end_on: Date.new(2999, 1, 1)))
]

private_constant :EXCISE_HASHES
private_constant :EXCISE_TAX_RATES, :ExciseTaxRate, :Term, :Amount

def amount_with_tax(amount, date: Date.today, fraction: :truncate)
return amount if amount < 0

(BigDecimal("#{amount}") * rate(date)).__send__(fraction)
(BigDecimal(amount.to_s) * rate(date)).__send__(fraction)
end

def yearly_amount_with_tax(amount:, start_on:, end_on:, fraction: :truncate)
# You can convert Integer/BigDecimal/Float/String/Rational classes to Rational,
# but the `amount` keyword argument does not accept BigDeciaml, Float and String for the following reasons.
# - Rational objects may be implicitly converted to BigDecimal type when performing arithmetic operations using BigDecimal and Rational.
# - Also, when you try to convert BigDecimal to Rational, the resulting value may not be Rational, but BigDecimal.
# - Float is not accepted because it is not suitable for calculating sales tax rates.
# - String is not accepted because an exception is raised by data that cannot be converted, such as 1.1.1, for example.
raise ArgumentError.new('amount data-type must be Integer or Rational') unless amount.is_a?(Integer) || amount.is_a?(Rational)
raise ArgumentError.new('start_on data-type must be Date') unless start_on.is_a?(Date)
raise ArgumentError.new('end_on data-type must be Date') unless end_on.is_a?(Date)
raise ArgumentError.new('start_on must not be after than end_on') if start_on > end_on
return amount if amount < 0

daily_amount = Rational(amount, (start_on..end_on).count)

EXCISE_HASHES.inject(0) do |sum, hash|
# It determines whether there are overlapping periods by comparing the start and end dates of a certain consumption tax with
# the start and end dates of the period for which the tax-inclusive price is to be calculated this time.
# If there is an overlap, the tax-inclusive price is calculated by multiplying the consumption tax rate for the applicable period
# by the number of days and pro rata amount for the overlapping period.
larger_start_on = [start_on, hash[:start_on]].max
smaller_end_on = [end_on, hash[:end_on]].min

# Check if there is an overlapping period
if larger_start_on <= smaller_end_on
# Number of days of overlapping period
number_of_days_in_this_excise_rate_term = (larger_start_on..smaller_end_on).count
amount = Amount.new(amount)
return amount.value if amount.value < 0

sum += (daily_amount * number_of_days_in_this_excise_rate_term * hash[:rate]).__send__(fraction)
end
term = Term.new(start_on: start_on, end_on: end_on)

sum
EXCISE_TAX_RATES.inject(0) do |sum, excise_tax_rate|
sum + (
amount.per_day(term.number_of_days) *
term.number_of_days_that_overlap_with(excise_tax_rate.term) *
excise_tax_rate.rate
).__send__(fraction)
end
end

Expand All @@ -74,90 +57,60 @@ def yearly_amount_with_tax(amount:, start_on:, end_on:, fraction: :truncate)
# and there are other charges that should be combined (e.g., the annual basic fee and the optional fee),
# if this method returns the amount including tax, it cannot be combined with the other charges.
def amount_separated_by_rate(amount:, start_on:, end_on:)
# You can convert Integer/BigDecimal/Float/String/Rational classes to Rational,
# but the `amount` keyword argument does not accept BigDeciaml, Float and String in for the following reasons.
# - Rational objects may be implicitly converted to BigDecimal or Float type
# when performing arithmetic operations using BigDecimal and Rational, or Float and Rational.
# - String is not accepted because an exception is raised by data that cannot be converted, such as 1.1.1, for example.
raise ArgumentError.new('amount data-type must be Integer or Rational') unless amount.is_a?(Integer) || amount.is_a?(Rational)
raise ArgumentError.new('start_on data-type must be Date') unless start_on.is_a?(Date)
raise ArgumentError.new('end_on data-type must be Date') unless end_on.is_a?(Date)

# By using the modified Julian date, we can handle all Date as Integer. This speeds up the process.
start_on_mjd = start_on.mjd
end_on_mjd = end_on.mjd
amount = Amount.new(amount)
raise ArgumentError.new('amount must be greater than or equal to zero') if amount.value < 0

raise ArgumentError.new('start_on must not be after than end_on') if start_on_mjd > end_on_mjd
raise ArgumentError.new('start_on must bigger than 1873/1/1') if start_on_mjd < EXCISE_HASHES.first[:start_on].mjd
raise ArgumentError.new('amount must be greater than or equal to zero') if amount < 0

# Use the number of days until end_on_mjd.
daily_amount = Rational(amount, (start_on_mjd..end_on_mjd).count)
term = Term.new(start_on: start_on, end_on: end_on)
raise ArgumentError.new('start_on must bigger than 1873/1/1') if start_on < EXCISE_TAX_RATES.first.term.start_on

{}.tap do |return_hash|
EXCISE_HASHES.inject(0) do |sum, hash|
# It determines whether there are overlapping periods by comparing the start and end dates of a certain consumption tax with
# the start and end dates of the period for which the tax-inclusive price is to be calculated this time.
# If there is an overlap, the price for the subject period is calculated by multiplying the number of days of the overlapping period
# by the pro rata amount.
larger_start_on_mjd = [start_on_mjd, hash[:start_on].mjd].max
smaller_end_on_mjd = [end_on_mjd, hash[:end_on].mjd].min
EXCISE_TAX_RATES.each do |excise_tax_rate|
next unless excise_tax_rate.is_in_effect_for?(term)

# Check if there is an overlapping period
if larger_start_on_mjd <= smaller_end_on_mjd
# Number of days of overlapping period
number_of_days_in_this_excise_rate_term = (larger_start_on_mjd..smaller_end_on_mjd).count
return_hash[hash[:rate]] = (daily_amount * number_of_days_in_this_excise_rate_term).truncate
end
return_hash[excise_tax_rate.rate] = (
amount.per_day(term.number_of_days) *
term.number_of_days_that_overlap_with(excise_tax_rate.term)
).truncate
end

# If the divided amount is not divisible by the number of target tax rates,
# If the divided amount is not divisible by the number of target tax rates,
# the sum of the amount in the argument and the divided amount may be less than the actual value.
# This is because the undivided value is truncated at the time of division.
# e.g.
# amount: 100000, start_on: 1997/3/31, end_on 2014/4/1の場合
# amount: 100000, start_on: 1997/3/31, end_on 2014/4/1
# 3%:16
# 5%:99_967
# 8%:16
# => 16+99967+16=99999
# Add the amount that is out of alignment to the amount that belongs to the lowest sales tax amount
# to equal the sum of the argument amount and the divided amount.
# The reason for adding the shortfall to the amount that belongs to the least amount of consumption tax
# The reason for adding the shortfall to the amount that belongs to the least amount of consumption tax
# is so that the user will have an advantage when the consumption tax is calculated based on this amount.
# Example 1
# amount: 100000, start_on: 1997/3/31, end_on 2014/4/1の場合
# amount: 100000, start_on: 1997/3/31, end_on 2014/4/1
# 3%:17 <- Actually 16, but add 1 yen.
# 5%:99_967
# 8%:16
# => 17+99967+16=100000
#
# Example 2:
# amount: 100000, start_on: 2014/3/31, end_on 2019/10/1の場合
# amount: 100000, start_on: 2014/3/31, end_on 2019/10/1
# 5%:51 <- Actually 49, but add 2 yen.
# 8%:99_900
# 10%:49
# => 51+99900+49=100000
#
# FIXME: `Enumerable#sum` has been supported since ruby 2.4, but this gem uses `reduce` because it still needs to support ruby 2.3 series.
summarize_separated_amount = return_hash.each_value.reduce(&:+)
if amount != summarize_separated_amount
return_hash[return_hash.each_key.min] += (amount - summarize_separated_amount)
if amount.value != summarize_separated_amount
return_hash[return_hash.each_key.min] += (amount.value - summarize_separated_amount)
end
end
end

def rate(date = Date.today)
case date
when Date.new(1989, 4, 1)..Date.new(1997, 3, 31)
RATE103
when Date.new(1997, 4, 1)..Date.new(2014, 3, 31)
RATE105
when Date.new(2014, 4, 1)..Date.new(2019, 9, 30)
RATE108
when Date.new(2019, 10, 1)..Date::Infinity.new
RATE110
else
RATE100
end
EXCISE_TAX_RATES.find {
|excise_tax_rate| excise_tax_rate.is_in_effect_on?(date)
}.rate || RATE100
end
end
24 changes: 24 additions & 0 deletions lib/jct/amount.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module Jct
class Amount
attr_reader :value

def initialize(amount)
# You can convert Integer/BigDecimal/Float/String/Rational classes to Rational,
# but the `amount` does not accept BigDeciaml, Float and String for the following reasons.
# - Rational objects may be implicitly converted to BigDecimal type when performing arithmetic operations using BigDecimal and Rational.
# - Also, when you try to convert BigDecimal to Rational, the resulting value may not be Rational, but BigDecimal.
# - Float is not accepted because it is not suitable for calculating sales tax rates.
# - String is not accepted because an exception is raised by data that cannot be converted, such as 1.1.1, for example.
raise ArgumentError.new('amount data-type must be Integer or Rational') unless amount.is_a?(Integer) || amount.is_a?(Rational)

@value = amount
end

def per_day(number_of_days)
raise ArgumentError.new('number_of_days data-type must be Integer') unless number_of_days.is_a?(Integer)
raise ArgumentError.new('number_of_days must be greater than zero') if number_of_days <= 0

Rational(value, number_of_days)
end
end
end
18 changes: 18 additions & 0 deletions lib/jct/excise_tax_rate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module Jct
class ExciseTaxRate
attr_reader :rate, :term

def initialize(rate:, term:)
@rate = rate
@term = term
end

def is_in_effect_on?(date)
term.includes?(date)
end

def is_in_effect_for?(term)
self.term.overlaps_with?(term)
end
end
end
56 changes: 56 additions & 0 deletions lib/jct/term.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
module Jct
class Term
attr_reader :start_on, :end_on

def initialize(start_on:, end_on:)
raise ArgumentError.new('start_on data-type must be Date') unless start_on.is_a?(Date)
raise ArgumentError.new('end_on data-type must be Date') unless end_on.is_a?(Date)
raise ArgumentError.new('start_on must not be after than end_on') if start_on > end_on

@start_on = start_on
@end_on = end_on
end

def includes?(date)
start_on <= date && date <= end_on
end

def overlaps_with?(term)
# Patterns that self and term **donot** overlap
# term.end_on < self.start_on
# self: |---|
# term: |---|
# OR
# self.end_on < term.start_on
# self: |---|
# term: |---|
!(term.end_on < start_on || end_on < term.start_on)
end

def number_of_days
(end_on - start_on).to_i + 1
Copy link
Member Author

@mi-wada mi-wada Apr 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Until now, the number of days has been calculated using (start_on..end_on).count. However, from the following simple benchmark, this method was faster than (start_on..end_on).count, so I changed it.

# $ ruby -v
# ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [arm64-darwin21]

require 'benchmark'
require 'date'

n = 10000

d20200101 = Date.new(2020, 01, 01)
d20210101 = Date.new(2021, 01, 01)

use_count = Benchmark.realtime do
  n.times do |_|
    _count = (d20200101..d20210101).count
  end
end

unuse_count = Benchmark.realtime do
  n.times do |_|
    _count = (d20200101 - d20210101 + 1).to_i
  end
end

puts "use_count execution time: #{use_count}"
puts "unuse_count execution time: #{unuse_count}"

# => use_count execution time: 0.39297799998894334
# => unuse_count execution time: 0.0031369999051094055

end

def number_of_days_that_overlap_with(term)
return 0 unless overlaps_with?(term)

if start_on <= term.start_on && term.end_on <= end_on
# self: |-------|
# term: |---|
term.number_of_days
elsif term.start_on <= start_on && end_on <= term.end_on
# self: |---|
# term: |-------|
number_of_days
elsif term.start_on <= start_on && term.end_on <= end_on
# self: |---|
# term: |---|
Term.new(start_on: start_on, end_on: term.end_on).number_of_days
else
# self: |---|
# term: |---|
Term.new(start_on: term.start_on, end_on: end_on).number_of_days
end
end
end
end
45 changes: 45 additions & 0 deletions test/jct/amount_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
require_relative '../test_helper'

Amount = Jct.const_get(:Amount)

class AmountTest < Minitest::Test
def test_initialize_failure
error = assert_raises ArgumentError do
Amount.new('100')
end
assert_equal 'amount data-type must be Integer or Rational', error.message
end

def test_initialize
[
100,
Rational(100),
0,
Rational(0),
-100,
Rational(-100)
].each do |amount|
assert_equal amount, Amount.new(amount).value
end
end

def test_per_day_failure
amount = Amount.new(100)

error = assert_raises ArgumentError do
amount.per_day(1.1)
end
assert_equal 'number_of_days data-type must be Integer', error.message

error = assert_raises ArgumentError do
amount.per_day(0)
end
assert_equal 'number_of_days must be greater than zero', error.message
end

def test_per_day
amount = Amount.new(100)

assert_equal Rational(amount.value, 10), amount.per_day(10)
end
end
Loading