defmodule QRNBU.Validators.TaxID do
@moduledoc """
Validates Ukrainian Tax Identification Numbers using the ukraine_tax_id library.
This validator wraps the ukraine_tax_id library (developed by the same author) to provide
validation for both EDRPOU (legal entities) and ITIN (individuals) tax IDs.
## Tax ID Types
- **EDRPOU**: 8-digit code for legal entities (companies, organizations)
- **ITIN**: 10-digit code for individuals (Individual Taxpayer Identification Number)
## Validation Rules
- EDRPOU: Exactly 8 digits with checksum validation
- ITIN: Exactly 10 digits with checksum validation, includes birth date and gender
- Only numeric characters allowed
- Checksum must be valid per Ukrainian tax authority algorithms
## Examples
iex> QRNBU.Validators.TaxID.validate("12345678")
{:ok, "12345678", :edrpou}
iex> QRNBU.Validators.TaxID.validate("1234567890")
{:ok, "1234567890", :itin}
iex> QRNBU.Validators.TaxID.validate("123")
{:error, "Invalid tax ID length"}
iex> QRNBU.Validators.TaxID.validate("1234567X")
{:error, "Tax ID must contain only digits"}
## References
- Ukrainian State Tax Service regulations
- Uses ukraine_tax_id library: https://hex.pm/packages/ukraine_tax_id
"""
@doc """
Validates a Ukrainian tax identification number (EDRPOU or ITIN).
Automatically detects the type based on length:
- 8 digits → EDRPOU (legal entity)
- 10 digits → ITIN (individual)
Returns `{:ok, normalized_tax_id, type}` where type is `:edrpou` or `:itin`,
or `{:error, reason}` if validation fails.
"""
@spec validate(String.t()) :: {:ok, String.t(), :edrpou | :itin} | {:error, String.t()}
def validate(tax_id) when is_binary(tax_id) do
normalized = tax_id |> String.trim() |> String.replace(~r/\s/, "")
# First check basic format
with :ok <- validate_format(normalized) do
case UkraineTaxidEx.parse(normalized) do
{:ok, _parsed, UkraineTaxidEx.Edrpou} ->
{:ok, normalized, :edrpou}
{:ok, _parsed, UkraineTaxidEx.Itin} ->
{:ok, normalized, :itin}
{:error, error, UkraineTaxidEx.Edrpou} ->
{:error, format_edrpou_error(error)}
{:error, error, UkraineTaxidEx.Itin} ->
{:error, format_itin_error(error)}
{:error, "Invalid tax ID length"} ->
{:error, "Tax ID must be 8 digits (EDRPOU) or 10 digits (ITIN)"}
{:error, error} ->
{:error, to_string(error)}
end
end
end
def validate(_), do: {:error, "Tax ID must be a string"}
@doc """
Validates specifically an EDRPOU (8-digit legal entity code).
Returns `{:ok, edrpou}` or `{:error, reason}`.
"""
@spec validate_edrpou(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def validate_edrpou(edrpou) when is_binary(edrpou) do
normalized = edrpou |> String.trim() |> String.replace(~r/\s/, "")
case String.length(normalized) do
8 ->
case UkraineTaxidEx.Edrpou.Parser.parse(normalized) do
{:ok, _parsed} -> {:ok, normalized}
{:error, error} -> {:error, format_edrpou_error(error)}
end
_ ->
{:error, "EDRPOU must be exactly 8 digits"}
end
end
@doc """
Validates specifically an ITIN (10-digit individual taxpayer code).
Returns `{:ok, itin}` or `{:error, reason}`.
"""
@spec validate_itin(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def validate_itin(itin) when is_binary(itin) do
normalized = itin |> String.trim() |> String.replace(~r/\s/, "")
case String.length(normalized) do
10 ->
case UkraineTaxidEx.Itin.Parser.parse(normalized) do
{:ok, _parsed} -> {:ok, normalized}
{:error, error} -> {:error, format_itin_error(error)}
end
_ ->
{:error, "ITIN must be exactly 10 digits"}
end
end
@doc """
Parses an ITIN to extract birth date and gender information.
Returns `{:ok, %{birth_date: Date.t(), gender: :male | :female}}` or `{:error, reason}`.
## Examples
iex> QRNBU.Validators.TaxID.parse_itin("3456789012")
{:ok, %{birth_date: ~D[2003-12-31], gender: :female}}
"""
@spec parse_itin(String.t()) :: {:ok, map()} | {:error, String.t()}
def parse_itin(itin) when is_binary(itin) do
normalized = itin |> String.trim() |> String.replace(~r/\s/, "")
case UkraineTaxidEx.Itin.Parser.parse(normalized) do
{:ok, parsed} ->
gender = if parsed.gender == 0, do: :male, else: :female
{:ok,
%{
birth_date: parsed.birth_date,
gender: gender,
number: parsed.number
}}
{:error, error} ->
{:error, format_itin_error(error)}
end
end
@doc """
Determines the type of tax ID without full validation.
Returns `:edrpou`, `:itin`, or `:unknown`.
"""
@spec determine_type(String.t()) :: :edrpou | :itin | :unknown
def determine_type(tax_id) when is_binary(tax_id) do
normalized = tax_id |> String.trim() |> String.replace(~r/\s/, "")
case String.length(normalized) do
8 -> :edrpou
10 -> :itin
_ -> :unknown
end
end
# Basic format validation (digits only)
@spec validate_format(String.t()) :: :ok | {:error, String.t()}
defp validate_format(tax_id) do
if String.match?(tax_id, ~r/^\d+$/) do
:ok
else
{:error, "Tax ID must contain only digits"}
end
end
# Format EDRPOU-specific errors
defp format_edrpou_error(:invalid_length), do: "EDRPOU must be exactly 8 digits"
defp format_edrpou_error(:invalid_format), do: "EDRPOU must contain only digits"
defp format_edrpou_error(:invalid_checksum), do: "Invalid EDRPOU checksum"
defp format_edrpou_error(error), do: "EDRPOU validation failed: #{error}"
# Format ITIN-specific errors
defp format_itin_error(:invalid_length), do: "ITIN must be exactly 10 digits"
defp format_itin_error(:invalid_format), do: "ITIN must contain only digits"
defp format_itin_error(:invalid_checksum), do: "Invalid ITIN checksum"
defp format_itin_error(:invalid_birth_date), do: "ITIN contains invalid birth date"
defp format_itin_error(error), do: "ITIN validation failed: #{error}"
end