Commons and EDRPOU basic functionality (parser (but complete only), checksum with test coverage)

This commit is contained in:
Danil Negrienko 2024-12-11 03:02:07 -05:00
parent 9db28e7277
commit a7692124e9
16 changed files with 477 additions and 19 deletions

View File

@ -21,4 +21,4 @@ IEx.configure(
|> IO.chardata_to_string()
)
alias UkraineTaxidEx.{Itin, Edrpou}
alias UkraineTaxidEx.{Commons, Edrpou, Itin}

View File

@ -2,17 +2,4 @@ defmodule UkraineTaxidEx do
@moduledoc """
Documentation for `UkraineTaxidEx`.
"""
@doc """
Hello world.
## Examples
iex> UkraineTaxidEx.hello()
:world
"""
def hello do
:world
end
end

View File

@ -0,0 +1,27 @@
defmodule UkraineTaxidEx.Base do
@callback to_map(data :: term) :: map()
@callback to_string(data :: term) :: String.t()
@callback length() :: non_neg_integer()
defmacro __using__(_) do
quote do
@behaviour UkraineTaxidEx.Base
alias UkraineTaxidEx.{Base, Serialize, Commons}
@impl Base
@spec length() :: non_neg_integer()
def length(), do: @length
@impl Base
@spec to_map(data :: t()) :: map()
defdelegate to_map(data), to: Serialize
@impl Base
@spec to_string(data :: t()) :: binary()
defdelegate to_string(data), to: Serialize
defoverridable to_string: 1, to_map: 1, length: 0
end
end
end

View File

@ -0,0 +1,19 @@
defmodule UkraineTaxidEx.BaseParser do
@type options :: [incomplete: boolean]
@callback parse(string :: String.t(), options :: options()) :: {:ok, term} | {:error, atom}
defmacro __using__(_) do
quote do
@behaviour UkraineTaxidEx.BaseParser
alias UkraineTaxidEx.BaseParser
@impl BaseParser
@spec parse(string :: String.t(), options :: BaseParser.options()) ::
{:ok, term} | {:error, atom}
def parse(data, options \\ [incomplete: false])
defoverridable parse: 2, parse: 1
end
end
end

View File

@ -0,0 +1,57 @@
defmodule UkraineTaxidEx.Commons do
@moduledoc """
Common functions for UkraineTaxidEx.
"""
@typedoc "A one digit of EDRPOU or ITIN it's non-negative integer from 0 to 9"
@type digit :: non_neg_integer() | nil
@typedoc "List of digits of EDRPOU or ITIN"
@type digits :: [non_neg_integer()] | []
@pad "0"
@doc """
Converts a string or integer to a list of digits.
Takes a value and optional length parameter.
When length is provided, pads the result with leading zeros.
Returns list of digits as integers.
## Examples
iex> UkraineTaxidEx.Commons.digits("123")
[1, 2, 3]
iex> UkraineTaxidEx.Commons.digits(123, 5)
[0, 0, 1, 2, 3]
iex> UkraineTaxidEx.Commons.digits("987", 5)
[0, 0, 9, 8, 7]
"""
@spec digits(value :: String.t() | integer, length :: non_neg_integer()) :: digits
def digits(value, length \\ 0)
def digits(value, length) when is_integer(value), do: digits("#{value}", length)
def digits(value, length) when is_binary(value) do
value
|> clean()
|> String.pad_leading(length, @pad)
|> String.graphemes()
|> Enum.map(&String.to_integer/1)
end
@spec check_digit(digits :: digits) :: digit
def check_digit(digits), do: List.last(digits)
@spec value_digits(digits :: digits) :: digits
def value_digits(digits), do: Enum.take(digits, length(digits) - 1)
@spec value_and_check_digits(digits :: digits) :: {digits, digit}
def value_and_check_digits(digits), do: {value_digits(digits), check_digit(digits)}
@spec clean(string :: String.t()) :: String.t()
defp clean(string), do: String.replace(string, ~r/[^\d]/, "")
def ok(data), do: {:ok, data}
def error(error), do: {:error, error}
end

View File

@ -0,0 +1,18 @@
defmodule UkraineTaxidEx.Edrpou do
@moduledoc """
Documentation for `UkraineTaxidEx.Edrpou`.
The EDRPOU is a unique 8-digit number that identifies legal entities in Ukraine. It is issued to all companies when they are formed and registered in the official business register. The Unified State Register of Enterprises and Organizations of Ukraine (USREOU/EDRPOU) is an automated system that collects, stores, and processes data on legal entities in Ukraine.
"""
@length 8
use UkraineTaxidEx.Base
@type t :: %__MODULE__{
code: String.t(),
check_digit: C.digit(),
check_sum: C.digit()
}
defstruct code: nil, check_digit: nil, check_sum: nil
end

View File

@ -0,0 +1,68 @@
defmodule UkraineTaxidEx.Edrpou.CheckSum do
alias UkraineTaxidEx.Commons, as: C
import UkraineTaxidEx.Commons, only: [value_digits: 1]
@typedoc """
Coefficients (weights) for digits to calculate EDRPOU check sum may be two types:
base ([1, 2, 3, 4, 5, 6, 7] for EDRPOU < 30M or EDRPOU > 60M)
or alternative ([7, 1, 2, 3, 4, 5, 6] if EDRPOU between 30M and 60M)
"""
@type weights_type :: :base | :alternative
@doc """
Calculate checksum for EDRPOU number.
The checksum for EDRPOU is calculated in several steps:
1. Define the type of weights (base or alternative) as described in `weights_type/1`
2. Multiply each digit by its corresponding weight
3. Sum the products
4. Take mod 11 of the sum
5. If mod 11 is greater or equal than 10, repeat steps 2-4 with doubled weights
"""
@spec check_sum(digits :: C.digits()) :: integer()
def check_sum(digits) do
type =
digits
|> Integer.undigits()
|> weights_type()
value_digits = value_digits(digits)
case calculate_check_sum(value_digits, weights(type, false)) do
s when s >= 10 -> calculate_check_sum(value_digits, weights(type, true))
s -> s
end
end
defguardp is_base_weights(value) when value < 30_000_000 or value > 60_000_000
@spec weights(type :: weights_type, double_added? :: boolean()) :: C.digits()
defp weights(type \\ :base, double_added \\ false)
defp weights(:base, false), do: Enum.to_list(1..7)
defp weights(:alternative, false) do
base = weights()
[List.last(base) | Enum.take(base, length(base) - 1)]
end
defp weights(type, true) do
type
|> weights(false)
|> Enum.map(&(&1 + 2))
end
@spec divider() :: non_neg_integer()
defp divider(), do: 11
@spec weights_type(value :: pos_integer()) :: C.digits()
defp weights_type(value) when is_base_weights(value), do: :base
defp weights_type(_value), do: :alternative
@spec calculate_check_sum(digits :: C.digits(), weights :: C.digits()) :: non_neg_integer()
defp calculate_check_sum(digits, weights) do
digits
|> Enum.zip(weights)
|> Enum.reduce(0, fn {digit, weight}, acc -> digit * weight + acc end)
|> rem(divider())
end
end

View File

@ -0,0 +1,24 @@
defmodule UkraineTaxidEx.Edrpou.Error do
@type error() ::
:invalid_length
| :invalid_checksum
| :length_to_long
| :length_to_short
@type errors() :: [error()]
@errors [
:invalid_length,
:invalid_checksum,
:length_to_long,
:length_to_short
]
@messages [
invalid_length: "EDRPOU violates the required length",
invalid_checksum: "EDRPOU checksum is invalid",
length_to_long: "EDRPOU longer then required length",
length_to_short: "EDRPOU shorter then required length"
]
@spec message(error()) :: String.t()
def message(error) when error in @errors, do: @messages[error]
def message(_error), do: "Undefined error"
end

View File

@ -0,0 +1,23 @@
defmodule UkraineTaxidEx.Edrpou.Parser do
alias UkraineTaxidEx.Edrpou
import UkraineTaxidEx.Edrpou, only: [length: 0]
import UkraineTaxidEx.Edrpou.CheckSum, only: [check_sum: 1]
import UkraineTaxidEx.Commons, only: [check_digit: 1, digits: 2, ok: 1]
use UkraineTaxidEx.BaseParser
def parse(edrpou_string, incomplete: false) do
digits = digits(edrpou_string, length())
%{
code: edrpou_string,
check_sum: check_sum(digits),
check_digit: check_digit(digits)
}
|> create_struct()
|> ok()
end
defp create_struct(map), do: struct(Edrpou, map)
end

View File

@ -0,0 +1,24 @@
defmodule UkraineTaxidEx.Itin do
@moduledoc """
Documentation for `UkraineTaxidEx.Itin`.
The ITIN is a unique 10-digit number that identifies individuals in Ukraine. It is issued to all individuals when they are registered in the official tax register. The Individual Taxpayer Identification Number (ITIN) is an automated system that collects, stores, and processes data on individuals in Ukraine.
"""
@base_date Date.new!(1899, 12, 31)
@length 10
use UkraineTaxidEx.Base
@type gender :: 0 | 1
@type t :: %__MODULE__{
code: String.t(),
birth_date: Date.t(),
gender: gender,
check_digit: C.digit(),
check_sum: C.digit()
}
defstruct code: nil, birth_date: nil, gender: nil, check_digit: nil, check_sum: nil
def base_date(), do: @base_date
end

View File

@ -0,0 +1,24 @@
defmodule UkraineTaxidEx.Itin.Error do
@type error() ::
:invalid_length
| :invalid_checksum
| :length_to_long
| :length_to_short
@type errors() :: [error()]
@errors [
:invalid_length,
:invalid_checksum,
:length_to_long,
:length_to_short
]
@messages [
invalid_length: "EDRPOU violates the required length",
invalid_checksum: "EDRPOU checksum is invalid",
length_to_long: "EDRPOU longer then required length",
length_to_short: "EDRPOU shorter then required length"
]
@spec message(error()) :: String.t()
def message(error) when error in @errors, do: @messages[error]
def message(_error), do: "Undefined error"
end

View File

@ -0,0 +1,7 @@
defmodule UkraineTaxidEx.Serialize do
@spec to_string(Edrpou.t() | Itin.t()) :: String.t()
def to_string(data), do: data.code
@spec to_map(Edrpou.t() | Itin.t()) :: map()
def to_map(data), do: Map.from_struct(data)
end

View File

@ -0,0 +1,92 @@
defmodule UkraineTaxidEx.CommonsTest do
use ExUnit.Case
alias UkraineTaxidEx.Commons
doctest UkraineTaxidEx.Commons
describe "digits/2" do
test "converts string to list of digits" do
assert Commons.digits("123") == [1, 2, 3]
assert Commons.digits("456") == [4, 5, 6]
assert Commons.digits("789") == [7, 8, 9]
end
test "converts integer to list of digits" do
assert Commons.digits(123) == [1, 2, 3]
assert Commons.digits(456) == [4, 5, 6]
assert Commons.digits(789) == [7, 8, 9]
end
test "pads with zeros when length is specified" do
assert Commons.digits("123", 5) == [0, 0, 1, 2, 3]
assert Commons.digits(123, 5) == [0, 0, 1, 2, 3]
assert Commons.digits("45", 4) == [0, 0, 4, 5]
end
test "handles strings with non-digit characters" do
assert Commons.digits("1-2-3") == [1, 2, 3]
assert Commons.digits("A1B2C3") == [1, 2, 3]
assert Commons.digits("12.34") == [1, 2, 3, 4]
end
test "handles empty string" do
assert Commons.digits("") == []
assert Commons.digits("", 3) == [0, 0, 0]
end
end
describe "check_digit/1" do
test "returns the last digit from the list" do
assert Commons.check_digit([1, 2, 3, 4, 5]) == 5
assert Commons.check_digit([9, 8, 7]) == 7
end
test "returns nil for empty list" do
assert Commons.check_digit([]) == nil
end
end
describe "value_digits/1" do
test "returns all digits except the last one" do
assert Commons.value_digits([1, 2, 3, 4, 5]) == [1, 2, 3, 4]
assert Commons.value_digits([9, 8, 7]) == [9, 8]
end
test "returns empty list for empty input" do
assert Commons.value_digits([]) == []
end
test "returns empty list for single digit input" do
assert Commons.value_digits([1]) == []
end
end
describe "value_and_check_digits/1" do
test "returns tuple with value digits and check digit" do
assert Commons.value_and_check_digits([1, 2, 3, 4, 5]) == {[1, 2, 3, 4], 5}
assert Commons.value_and_check_digits([9, 8, 7]) == {[9, 8], 7}
end
test "handles empty list" do
assert Commons.value_and_check_digits([]) == {[], nil}
end
test "handles single digit" do
assert Commons.value_and_check_digits([1]) == {[], 1}
end
end
describe "ok/1" do
test "wraps data in ok tuple" do
assert Commons.ok(123) == {:ok, 123}
assert Commons.ok([1, 2, 3]) == {:ok, [1, 2, 3]}
assert Commons.ok("test") == {:ok, "test"}
end
end
describe "error/1" do
test "wraps error in error tuple" do
assert Commons.error("invalid") == {:error, "invalid"}
assert Commons.error(:invalid_format) == {:error, :invalid_format}
end
end
end

View File

@ -0,0 +1,66 @@
defmodule UkraineTaxidEx.Edrpou.CheckSumTest do
use ExUnit.Case
alias UkraineTaxidEx.Edrpou.CheckSum
describe "check_sum/1" do
test "calculates correct checksum for EDRPOU with base weights (< 30M) short EDRPOU with leading zeros" do
# Example EDRPOU: 0003212[9] (where 2 is the check digit)
digits = [0, 0, 0, 3, 2, 1, 2, 9]
assert CheckSum.check_sum(digits) == 9
end
test "calculates correct checksum for EDRPOU with base weights (< 30M)" do
# Example EDRPOU: 1436050[6] (where 2 is the check digit)
digits = [1, 4, 3, 6, 0, 5, 0, 6]
assert CheckSum.check_sum(digits) == 6
end
test "calculates correct checksum for EDRPOU with base weights (> 60M)" do
# Example EDRPOU: 6543217[6]
digits = [6, 5, 4, 3, 2, 1, 7, 6]
assert CheckSum.check_sum(digits) == 6
end
test "calculates correct checksum for EDRPOU with alternative weights (30M-60M)" do
# Example EDRPOU: 3145193[2]
digits = [3, 1, 4, 5, 1, 9, 3, 2]
assert CheckSum.check_sum(digits) == 2
end
test "calculates correct checksum for EDRPOU with alternative weights (30M-60M) when first calculation >= 10" do
# Example EDRPOU: 3702668[4]
digits = [3, 7, 0, 2, 6, 6, 8, 4]
assert CheckSum.check_sum(digits) == 4
end
test "calculates correct checksum for EDRPOU with base weights when first calculation >= 10" do
# Example EDRPOU: 2113335[2]
digits = [2, 1, 1, 3, 3, 3, 5, 2]
assert CheckSum.check_sum(digits) == 2
end
test "handles edge case at 30M boundary" do
# Just below 30M
digits = [2, 9, 9, 9, 9, 9, 9]
result_below = CheckSum.check_sum(digits)
# Just at 30M
digits = [3, 0, 0, 0, 0, 0, 0]
result_at = CheckSum.check_sum(digits)
assert result_below != result_at
end
test "handles edge case at 60M boundary" do
# Just below 60M
digits = [5, 9, 9, 9, 9, 9, 9]
result_below = CheckSum.check_sum(digits)
# Just at 60M
digits = [6, 0, 0, 0, 0, 0, 0]
result_at = CheckSum.check_sum(digits)
assert result_below != result_at
end
end
end

View File

@ -0,0 +1,26 @@
defmodule UkraineTaxidEx.EdrpouTest do
use ExUnit.Case
# alias UkraineTaxidEx.Edrpou
# describe "weights/2" do
# test "returns base weights by default" do
# assert Edrpou.weights() == [1, 2, 3, 4, 5, 6, 7]
# end
# test "returns base weights when not doubled" do
# assert Edrpou.weights(:base, false) == [1, 2, 3, 4, 5, 6, 7]
# end
# test "returns alternative weights when not doubled" do
# assert Edrpou.weights(:alternative, false) == [7, 1, 2, 3, 4, 5, 6]
# end
# test "returns doubled base weights" do
# assert Edrpou.weights(:base, true) == [2, 4, 6, 8, 10, 12, 14]
# end
# test "returns doubled alternative weights" do
# assert Edrpou.weights(:alternative, true) == [14, 2, 4, 6, 8, 10, 12]
# end
# end
end

View File

@ -1,8 +1,4 @@
defmodule UkraineTaxidExTest do
use ExUnit.Case
use ExUnit.Case, async: true
doctest UkraineTaxidEx
test "greets the world" do
assert UkraineTaxidEx.hello() == :world
end
end