tin_valid 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f5b010dba783fef5da5341515542f830f73ac1baeca9fd8334fee16867d07a5a
4
+ data.tar.gz: 42d87f203dd942dc281636c0f3ff6b21e4104d9ae22904974cc81d7f503ead3a
5
+ SHA512:
6
+ metadata.gz: 886d2bfe8eb377af5fed28166a3a97f2a56f0c5dddea52c06f5e844d0a6d7194e73750a601422ce9e2a90ed93fe5f7ee99f323c6af56c375d53b5b4a83bb1b30
7
+ data.tar.gz: '09cd02962705707d397f82dba292263a5414ecf1993bb67c5d87645f6f782a50a74cfe75c97e3cddba494767a8442de2ad6515872b0b5a60d16e4ad22d6f5875'
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,63 @@
1
+ plugins:
2
+ - rubocop-rspec
3
+ - rubocop-rake
4
+
5
+ AllCops:
6
+ TargetRubyVersion: 3.1
7
+ NewCops: enable
8
+
9
+ # Layout
10
+
11
+ Layout/MultilineMethodCallIndentation:
12
+ # On multiline calls, indent starting from the receiver.
13
+ # E.g.:
14
+ # while apples_and_oranges
15
+ # .sorted_by_color
16
+ # .ripe
17
+ # .pick
18
+ # # …
19
+ # end
20
+ EnforcedStyle: indented_relative_to_receiver
21
+ Exclude:
22
+ - spec/**/*
23
+
24
+ # Metrics
25
+
26
+ Metrics/BlockLength:
27
+ # Limit the number of lines in a block.
28
+ Exclude:
29
+ - spec/**/*
30
+
31
+ # Style
32
+
33
+ Style/Documentation:
34
+ # Do not require a comment for every class.
35
+ Enabled: false
36
+
37
+ Style/NumericPredicate:
38
+ # Prefer `== 0` over `.zero?`.
39
+ EnforcedStyle: comparison
40
+
41
+ Style/RegexpLiteral:
42
+ # Allow using %r{…} or /…/ for regular expressions.
43
+ EnforcedStyle: mixed
44
+
45
+ Style/StringLiterals:
46
+ # Prefer "double quotes" over 'single quotes'.
47
+ EnforcedStyle: double_quotes
48
+
49
+ Style/TrailingCommaInArguments:
50
+ # Prefer trailing commas in multiline arguments:
51
+ # call(
52
+ # a: 1,
53
+ # b: 2,
54
+ # )
55
+ EnforcedStyleForMultiline: consistent_comma
56
+
57
+ Style/TrailingCommaInArrayLiteral:
58
+ # Prefer trailing commas in arrays:
59
+ # [
60
+ # 1,
61
+ # 2,
62
+ # ]
63
+ EnforcedStyleForMultiline: diff_comma
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-04-14
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Sunny Ripert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # TinValid
2
+
3
+ Validate Tax Identification Numbers (TINs) for the following European countries:
4
+
5
+ - Austria
6
+ - Belgium
7
+ - Bulgaria
8
+ - Croatia
9
+ - Cyprus
10
+ - Czechia
11
+ - Denmark
12
+ - Estonia
13
+ - Sweden
14
+
15
+ ## Installation
16
+
17
+ Install the gem and add to the application's Gemfile by executing:
18
+
19
+ ```bash
20
+ bundle add tin_valid
21
+ ```
22
+
23
+ If bundler is not being used to manage dependencies, install the gem by
24
+ executing:
25
+
26
+ ```bash
27
+ gem install tin_valid
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```rb
33
+ # Austria
34
+ TinValid::AustriaTin.new(tin: "…").valid?
35
+
36
+ # Belgium
37
+ # Optional birth_date
38
+ TinValid::BelgiumTin.new(tin: "…", birth_date: Date.new(…)).valid?
39
+
40
+ # Bulgaria
41
+ # Optional birth_date
42
+ TinValid::BulgariaTin.new(tin: "…", birth_date: Date.new(…)).valid?
43
+
44
+ # Croatia
45
+ TinValid::CroatiaTin.new(tin: "…").valid?
46
+
47
+ # Cyprus
48
+ # Optional kind ("individual" or "company")
49
+ TinValid::CyprusTin.new(tin: "…", kind: "individual").valid?
50
+
51
+ # Czechia
52
+ # Optional birth_date
53
+ TinValid::CzechiaTin.new(tin: "…", birth_date: Date.new(…)).valid?
54
+
55
+ # Denmark
56
+ # Optional birth_date
57
+ TinValid::DenmarkTin.new(tin: "…", birth_date: Date.new(…)).valid?
58
+
59
+ # Estonia
60
+ # Optional birth_date
61
+ TinValid::EstoniaTin.new(tin: "…", birth_date: Date.new(…)).valid?
62
+
63
+ # Sweden
64
+ # Optional birth_date
65
+ TinValid::SwedenTin.new(tin: "…", birth_date: Date.new(…)).valid?
66
+ ```
67
+
68
+ ## Development
69
+
70
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
71
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
72
+ prompt that will allow you to experiment.
73
+
74
+ To install this gem onto your local machine, run `bundle exec rake install`. To
75
+ release a new version, update the version number in `version.rb`, and then run
76
+ `bundle exec rake release`, which will create a git tag for the version, push
77
+ git commits and the created tag, and push the `.gem` file to
78
+ [rubygems.org](https://rubygems.org).
79
+
80
+ ## Contributing
81
+
82
+ Bug reports and pull requests are welcome on GitHub at
83
+ https://github.com/sunny/tin_valid. This project is intended to be a safe,
84
+ welcoming space for collaboration, and contributors are expected to adhere to
85
+ the
86
+ [code of conduct](https://github.com/sunny/tin_valid/blob/main/CODE_OF_CONDUCT.md).
87
+
88
+ ## License
89
+
90
+ The gem is available as open source under the terms of the
91
+ [MIT License](https://opensource.org/licenses/MIT).
92
+
93
+ ## Code of Conduct
94
+
95
+ Everyone interacting in the TinValid project's codebases, issue trackers, chat
96
+ rooms and mailing lists is expected to follow the
97
+ [code of conduct](https://github.com/sunny/tin_valid/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinValid
4
+ class AustriaTin < Data.define(:tin)
5
+ def valid?
6
+ return false unless /\A[0-9]{9}\z/.match?(tin)
7
+
8
+ tin[-1] == check
9
+ end
10
+
11
+ private
12
+
13
+ # rubocop:disable Metrics/AbcSize
14
+ def check
15
+ # 1. Multiply the values of each position by the corresponding weight:
16
+ # - C1: 1
17
+ # - C2: 2
18
+ # - C3: 1
19
+ # - C4: 2
20
+ # - C5: 1
21
+ # - C6: 2
22
+ # - C7: 1
23
+ # - C8: 2
24
+ values_by_weight =
25
+ tin
26
+ .chars
27
+ .each_with_index
28
+ .map { |n, i| n.to_i * (i.even? ? 2 : 1) }
29
+
30
+ # 2. If the product of a doubling operation is > 9, sum the digits of the
31
+ # product;
32
+ values_by_weight =
33
+ values_by_weight.map { |n| n > 9 ? n.to_s.chars.sum(&:to_i) : n }
34
+
35
+ # 3. Add up the results of the above multiplications;
36
+ sum = values_by_weight.sum
37
+
38
+ # 4. The result of the sum of the digits is subtracted from 100 and the
39
+ # unit digit of this operation is the check digit.
40
+ (100 - sum).to_s[-1]
41
+ end
42
+ # rubocop:enable Metrics/AbcSize
43
+ end
44
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinValid
4
+ class BelgiumTin
5
+ def initialize(tin:, birth_date: nil)
6
+ @tin = tin
7
+ @birth_date = birth_date
8
+ end
9
+
10
+ def valid?
11
+ return false unless match
12
+
13
+ number = tin[..-3]
14
+ valid_check?("19#{year}", month, day, number) ||
15
+ valid_check?("20#{year}", month, day, "2#{number}")
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :tin, :birth_date
21
+
22
+ MATCHER = %r{
23
+ \A
24
+ (?<year>[0-9]{2})
25
+ (?<month>[0-1][0-9])
26
+ (?<day>[0-3][0-9])
27
+ [0-9]{3}
28
+ (?<check>[0-9]{2})
29
+ \z
30
+ }x
31
+ private_constant :MATCHER
32
+
33
+ def match = @match ||= MATCHER.match(tin)
34
+ def year = match[:year]
35
+ def month = match[:month]
36
+ def day = match[:day]
37
+ def check = match[:check].to_i
38
+
39
+ def valid_check?(year, month, day, number)
40
+ tin_date = date(year, month, day)
41
+ return false if tin_date.nil?
42
+ return false if birth_date && birth_date != tin_date
43
+
44
+ check == digit_check(number)
45
+ end
46
+
47
+ # 1. Get the remainder of the division by 97 of the number composed by
48
+ # C1, C2, C3, C4, C5, C6, C7, C8 and C9;
49
+ #
50
+ # Also, alternatively:
51
+ #
52
+ # 1. Get the remainder of the division by 97 of the number composed by
53
+ # number 2 and C1, C2, C3, C4, C5, C6, C7, C8 and C9;
54
+ #
55
+ # 2. 97 - remainder of the previous division is the check number
56
+ def digit_check(number) = 97 - (number.to_i % 97)
57
+
58
+ def date(year, month, day)
59
+ # A month (in the range 00...12, 00 is acceptable for person not born in
60
+ # Belgium and with an uncertain date of birth).
61
+ month = 1 if month == "00"
62
+
63
+ # A day of month (in the range 00...31 depending on month and year, 00 is
64
+ # acceptable for person not born in Belgium and with an uncertain date of
65
+ # birth).
66
+ day = 1 if day == "00"
67
+
68
+ Date.new(year.to_i, month.to_i, day.to_i)
69
+ rescue Date::Error
70
+ nil
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinValid
4
+ class BulgariaTin < Data.define(:tin, :birth_date)
5
+ def initialize(tin:, birth_date: nil)
6
+ super
7
+ end
8
+
9
+ def valid?
10
+ match = MATCHER.match(tin)
11
+ return false unless match
12
+
13
+ year = match[:year]
14
+ month = match[:month]
15
+ day = match[:day]
16
+ return false unless accepted_date?(year, month, day)
17
+
18
+ tin[-1].to_i == check
19
+ end
20
+
21
+ private
22
+
23
+ MATCHER = %r{
24
+ \A
25
+ (?<year>[0-9]{2})
26
+ (?<month>[0-5][0-9])
27
+ (?<day>[0-3][0-9])
28
+ [0-9]{4}
29
+ \z
30
+ }x
31
+ private_constant :MATCHER
32
+
33
+ # rubocop:disable Metrics/AbcSize
34
+ # rubocop:disable Metrics/MethodLength
35
+ def accepted_date?(year, month, day)
36
+ month = month.to_i
37
+ day = day.to_i
38
+
39
+ if birth_date.nil?
40
+ past_date?("18#{year}", month - 20, day) ||
41
+ past_date?("19#{year}", month, day) ||
42
+ past_date?("20#{year}", month - 40, day)
43
+ elsif birth_year < 1900
44
+ birth_date == date("18#{year}", month - 20, day)
45
+ elsif birth_year < 2000
46
+ birth_date == date("19#{year}", month, day)
47
+ else
48
+ birth_date == date("20#{year}", month - 40, day)
49
+ end
50
+ end
51
+ # rubocop:enable Metrics/AbcSize
52
+ # rubocop:enable Metrics/MethodLength
53
+
54
+ def birth_year = birth_date.year
55
+
56
+ def past_date?(year, month, day)
57
+ found_date = date(year, month, day)
58
+ found_date && found_date < Date.today
59
+ end
60
+
61
+ def date(year, month, day)
62
+ Date.new(year.to_i, month.to_i, day.to_i)
63
+ rescue Date::Error
64
+ nil
65
+ end
66
+
67
+ def check
68
+ weights = [2, 4, 8, 5, 10, 9, 7, 3, 6]
69
+
70
+ # 1. Multiply the values of each position by the corresponding weight
71
+ values_by_weight =
72
+ tin[..-2]
73
+ .chars
74
+ .zip(weights)
75
+ .map { |char, weight| char.to_i * weight }
76
+
77
+ # 2. Add up the results of the above multiplications
78
+ sum = values_by_weight.sum
79
+
80
+ # 3. Get modulo 11 of the result of the previous addition
81
+ remainder = sum % 11
82
+
83
+ # 4. Check digit = remainder if remainder < 10
84
+ # Check digit = 0 if remainder = 10
85
+ remainder < 10 ? remainder : 0
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinValid
4
+ class CroatiaTin < Data.define(:tin)
5
+ def valid?
6
+ return false unless /\A[0-9]{11}\z/.match?(tin)
7
+
8
+ tin[-1].to_i == check
9
+ end
10
+
11
+ private
12
+
13
+ # Check digit by the international standard ISO 7064 (MOD 11, 10).
14
+ def check
15
+ last_rest = tin[..-2].chars.inject(10) do |rest, digit|
16
+ # 1. C1 is summed with 10
17
+ sum = digit.to_i + rest
18
+
19
+ # 2. The sum integer is divided by 10, and the rest is kept;
20
+ # if that number is 0 it gets replaced by number 10 (this latter number
21
+ # is called subtotal);
22
+ subtotal = sum % 10 == 0 ? 10 : sum % 10
23
+
24
+ # 3. The obtained subtotal is multiplied by 2
25
+ subtotal *= 2
26
+
27
+ # 4. The obtained number is divided by 11, and the rest is kept; this
28
+ # number mathematically cannot be 0 because the result of the previous
29
+ # step is always an even number
30
+ #
31
+ # 5. The next digit is summed with the rest from the previous step;
32
+ # 6. Steps 2, 3, 4 and 5 are repeated until all digits are expanded
33
+ subtotal % 11
34
+ end
35
+
36
+ # 7. If the rest of the final step is equal to 1, the check digit is 0.
37
+ # Otherwise, the check digit is a difference between 11 and the rest in
38
+ # the last step
39
+ last_rest == 1 ? 0 : 11 - last_rest
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinValid
4
+ class CyprusTin < Data.define(:tin, :kind)
5
+ def initialize(tin:, kind: nil)
6
+ super
7
+ end
8
+
9
+ def valid?
10
+ matcher.match?(tin) && check == tin[-1]
11
+ end
12
+
13
+ private
14
+
15
+ def matcher
16
+ if kind == "individual"
17
+ /\A[069][0-9]{7}[A-Z]\z/
18
+ elsif kind == "company"
19
+ /\A[1234578][0-9]{7}[A-Z]\z/
20
+ else
21
+ /\A[0-9]{8}[A-Z]\z/
22
+ end
23
+ end
24
+
25
+ def check
26
+ # 1. Add up the numbers in the even positions;
27
+ even_sum = numbers.each_with_index.sum do |number, index|
28
+ index.odd? ? number : 0
29
+ end
30
+
31
+ # 2. Consider all the numbers at the odd positions of the field and for
32
+ # each number find the corresponding value from the table below, and add
33
+ # them up:
34
+ odd_number_values = [1, 0, 5, 7, 9, 13, 15, 17, 19, 21]
35
+ odd_sum = numbers.each_with_index.sum do |number, index|
36
+ index.even? ? odd_number_values[number] : 0
37
+ end
38
+
39
+ # 3. Add the two sums obtained;
40
+ addition = even_sum + odd_sum
41
+
42
+ # 4. Get modulo 26 of the result of the previous addition;
43
+ remainder = addition % 26
44
+
45
+ # 5. Remainder + 65 gives the American Standard Code for Information
46
+ # Interchange (ASCII) code of a character (A to Z) which is the check
47
+ # character.
48
+ (remainder + 65).chr
49
+ end
50
+
51
+ def numbers = tin[..-2].chars.map(&:to_i)
52
+ end
53
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinValid
4
+ class CzechiaTin < Data.define(:tin, :birth_date)
5
+ def initialize(tin:, birth_date: nil)
6
+ super
7
+ end
8
+
9
+ def valid? = valid_v1? || valid_v2?
10
+
11
+ private
12
+
13
+ MATCHER_V1 = %r{
14
+ \A
15
+ (?<year>[0-9]{2})
16
+ (?<month>[0156][0-9])
17
+ (?<day>[0-3][0-9])
18
+ [0-9]{3}
19
+ \z
20
+ }x
21
+ private_constant :MATCHER_V1
22
+
23
+ MATCHER_V2 = %r{
24
+ \A
25
+ (?<year>[0-9]{2})
26
+ (?<month>[0-8][0-9])
27
+ (?<day>[0-3][0-9])
28
+ [0-9]{4}
29
+ \z
30
+ }x
31
+ private_constant :MATCHER_V2
32
+
33
+ def valid_v1?
34
+ match = MATCHER_V1.match(tin)
35
+ return false unless match
36
+ return true if birth_date.nil?
37
+ return false if year_of_birth >= 1954
38
+
39
+ year = "#{birth_century}#{match[:year]}"
40
+
41
+ # Month (in the range 1...12 for men) or month + 50 (in the range 51…62
42
+ # for women).
43
+ month = match[:month].to_i
44
+ month -= 50 if month > 50
45
+
46
+ birth_date == date(year, month, match[:day])
47
+ end
48
+
49
+ # rubocop:disable Metrics/AbcSize
50
+ # rubocop:disable Metrics/MethodLength
51
+ def valid_v2?
52
+ match = MATCHER_V2.match(tin)
53
+ return false unless match
54
+ return true if birth_date.nil?
55
+ return false if year_of_birth < 1954
56
+
57
+ # Two last digits of a year: Must be within the range:
58
+ # 00 - last two digits of current year for people born in 2000 and later;
59
+ # 54 - 99 for people born between 1954 and 1999.
60
+ year = "#{birth_century}#{match[:year]}"
61
+
62
+ # Month (in the range 1...12 only for men) or month + 20 (in the range
63
+ # 21…32 only for men) or month + 50 (in the range 51...62 only for women)
64
+ # or month + 70 (in the range 71…82 only for women).
65
+ month = match[:month].to_i
66
+ if month > 70
67
+ month -= 70
68
+ elsif month > 50
69
+ month -= 50
70
+ elsif month > 20
71
+ month -= 20
72
+ end
73
+
74
+ birth_date == date(year, month, match[:day])
75
+ end
76
+ # rubocop:enable Metrics/AbcSize
77
+ # rubocop:enable Metrics/MethodLength
78
+
79
+ def year_of_birth = birth_date&.year
80
+ def birth_century = birth_date.strftime("%Y")[..1]
81
+
82
+ def date(year, month, day)
83
+ Date.new(year.to_i, month.to_i, day.to_i)
84
+ rescue Date::Error
85
+ nil
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinValid
4
+ class DenmarkTin < Data.define(:tin, :birth_date)
5
+ def initialize(tin:, birth_date: nil)
6
+ super
7
+ end
8
+
9
+ def valid?
10
+ match = MATCHER.match(tin)
11
+ return false unless match
12
+
13
+ if birth_date
14
+ year = "#{birth_century}#{match[:year]}"
15
+ return false if birth_date != date(year, match[:month], match[:day])
16
+
17
+ return false unless check_serial(match[:serial], match[:year])
18
+ end
19
+
20
+ match[:check].to_i == checksum
21
+ end
22
+
23
+ private
24
+
25
+ MATCHER = %r{
26
+ \A
27
+ (?<day>[0-3][0-9])
28
+ (?<month>[0-1][0-9])
29
+ (?<year>[0-9]{2})
30
+ (?<serial>
31
+ [0-9]{3}
32
+ (?<check>[0-9])
33
+ )
34
+ \z
35
+ }x
36
+ private_constant :MATCHER
37
+
38
+ def birth_century = birth_date.strftime("%Y")[..1]
39
+ def birth_year = birth_date.year
40
+
41
+ def date(year, month, day)
42
+ Date.new(year.to_i, month.to_i, day.to_i)
43
+ rescue Date::Error
44
+ nil
45
+ end
46
+
47
+ def checksum
48
+ # 1. Multiply the values of each position by the corresponding weight:
49
+ weights =
50
+ [4, 3, 2, 7, 6, 5, 4, 3, 2]
51
+ .each_with_index
52
+ .map { |char, index| char.to_i * tin[index].to_i }
53
+
54
+ # 2. Add up the results of the above multiplications;
55
+ sum = weights.sum
56
+
57
+ # 3. Get modulo 11 of the result of the previous addition. The remainder
58
+ # must not be 1;
59
+ remainder = sum % 11
60
+
61
+ return if remainder == 1
62
+
63
+ # 4. Check digit = 11 – remainder, or check digit = 0 if the result of the
64
+ # modulo operation of the third step is 0.
65
+ remainder == 0 ? 0 : 11 - remainder
66
+ end
67
+
68
+ # rubocop:disable Metrics/CyclomaticComplexity
69
+ # rubocop:disable Metrics/MethodLength
70
+ # rubocop:disable Metrics/PerceivedComplexity
71
+ def check_serial(serial, year)
72
+ case serial.to_i
73
+ in 1..3999
74
+ (1900..1999).cover?(birth_year)
75
+ in 4000..4999, 9000..9999
76
+ case year.to_i
77
+ in 0..36 then (2000..2036).cover?(birth_year)
78
+ in 36..99 then (1937..1999).cover?(birth_year)
79
+ end
80
+ in 5000..8999
81
+ case year.to_i
82
+ in 0..36 then (2000..2036).cover?(birth_year)
83
+ in 37..57 then false
84
+ in 58..99 then (1858..1899).cover?(birth_year)
85
+ end
86
+ end
87
+ end
88
+ # rubocop:enable Metrics/CyclomaticComplexity
89
+ # rubocop:enable Metrics/MethodLength
90
+ # rubocop:enable Metrics/PerceivedComplexity
91
+ end
92
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinValid
4
+ class EstoniaTin < Data.define(:tin, :birth_date)
5
+ def initialize(tin:, birth_date: nil)
6
+ super
7
+ end
8
+
9
+ def valid?
10
+ match = MATCHER.match(tin)
11
+ return false unless match
12
+ return false unless (1..710).cover?(match[:id].to_i)
13
+
14
+ if birth_date
15
+ year = "#{birth_century}#{match[:year]}"
16
+ return false if birth_date != date(year, match[:month], match[:day])
17
+ end
18
+
19
+ match[:check].to_i == checksum
20
+ end
21
+
22
+ private
23
+
24
+ MATCHER = %r{
25
+ \A
26
+ [1-6]
27
+ (?<year>[0-9]{2})
28
+ (?<month>[0-1][0-9])
29
+ (?<day>[0-3][0-9])
30
+ (?<id>[0-7][0-9]{2})
31
+ (?<check>[0-9])
32
+ \z
33
+ }x
34
+ private_constant :MATCHER
35
+
36
+ def birth_century = birth_date.strftime("%Y")[..1]
37
+ def birth_year = birth_date.year
38
+
39
+ def date(year, month, day)
40
+ Date.new(year.to_i, month.to_i, day.to_i)
41
+ rescue Date::Error
42
+ nil
43
+ end
44
+
45
+ def checksum
46
+ # 1. Multiply the values of each position by the corresponding weight:
47
+ # 2. Add up the results of the above multiplications;
48
+ sum = sum_weights([1, 2, 3, 4, 5, 6, 7, 8, 9, 1])
49
+
50
+ # 3. Get modulo 11 of the result of the previous addition.
51
+ remainder = sum % 11
52
+
53
+ # 4. Check digit
54
+ # a. If remainder is less than 10, the remainder is the check digit;
55
+ return remainder if remainder < 10
56
+
57
+ # b. If remainder is 10, use the following table instead of the previous
58
+ # one:
59
+ sum = sum_weights([3, 4, 5, 6, 7, 8, 9, 1, 2, 3])
60
+
61
+ # If remainder is less than 10, the remainder is the check digit;
62
+ remainder = sum % 11
63
+ return remainder if remainder < 10
64
+
65
+ # If remainder is 10, the check digit is 0.
66
+ 0
67
+ end
68
+
69
+ def sum_weights(weights)
70
+ weights.each_with_index.sum do |char, index|
71
+ char.to_i * tin[index].to_i
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinValid
4
+ class SwedenTin < Data.define(:tin, :birth_date)
5
+ def initialize(tin:, birth_date: nil)
6
+ super
7
+ end
8
+
9
+ def valid?
10
+ VERSIONS.any? do |matcher|
11
+ match = matcher.match(tin)
12
+ next false unless match
13
+
14
+ year = match[:year]
15
+ month = match[:month]
16
+ day = match[:day]
17
+ next false unless date_valid?(year, month, day)
18
+
19
+ id = match[:id]
20
+ match[:check].to_i == checksum("#{year[..2]}#{month}#{day}#{id}")
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ VERSIONS = [
27
+ %r{
28
+ \A
29
+ (?<year>[0-9]{2})
30
+ (?<month>[0-1][0-9])
31
+ (?<day>[0-3][0-9])
32
+ -
33
+ (?<id>[0-9]{3})
34
+ (?<check>[0-9])
35
+ \z
36
+ }x,
37
+ %r{
38
+ \A
39
+ (?<year>[0-9]{2})
40
+ (?<month>[0-1][0-9])
41
+ (?<day>[6-9][0-9])
42
+ -
43
+ (?<id>[0-9]{3})
44
+ (?<check>[0-9])
45
+ \z
46
+ }x,
47
+ %r{
48
+ \A
49
+ (?<year>1[89][0-9]{2})
50
+ (?<month>[0-1][0-9])
51
+ (?<day>[0-3][0-9])
52
+ (?<id>[0-9]{3})
53
+ (?<check>[0-9])
54
+ \z
55
+ }x,
56
+ %r{
57
+ \A
58
+ (?<year>(?:18|19|20)[0-9]{2})
59
+ (?<month>[0-1][0-9])
60
+ (?<day>[6-9][0-9])
61
+ (?<id>[0-9]{3})
62
+ (?<check>[0-9])
63
+ \z
64
+ }x,
65
+ ].freeze
66
+ private_constant :VERSIONS
67
+
68
+ def checksum(numbers)
69
+ # 1. Multiply the values of each position by the corresponding weight:
70
+ # - C1: 2
71
+ # - C2: 1
72
+ # - C3: 2
73
+ # - C4: 1
74
+ # - C5: 2
75
+ # - C6: 1
76
+ # - C7: 2
77
+ # - C8: 1
78
+ # - C9: 2
79
+ values_by_weight =
80
+ numbers
81
+ .chars
82
+ .each_with_index
83
+ .map { |n, i| n.to_i * (i.even? ? 2 : 1) }
84
+
85
+ # 2. Add up the results of the above multiplications. NB: 12 is regarded
86
+ # as 1 + 2;
87
+ sum = values_by_weight.join.chars.sum(&:to_i)
88
+
89
+ # 3. The unit digit in the sum of the digits is subtracted from 10 and the
90
+ # result is the check digit. If the resulting number is 10, the check
91
+ # digit is 0.
92
+ last_digit = sum % 10
93
+ last_digit == 0 ? 0 : 10 - last_digit
94
+ end
95
+
96
+ def date_valid?(year, month, day)
97
+ day = day.to_i
98
+ day -= 60 if day > 60
99
+
100
+ if birth_date
101
+ year = "#{birth_date.strftime('%Y')[..1]}#{year}" if year.size == 2
102
+ birth_date == date(year, month, day)
103
+ else
104
+ date(year, month, day) ||
105
+ date("19#{year}", month, day) ||
106
+ date("20#{year}", month, day)
107
+ end
108
+ end
109
+
110
+ def date(year, month, day)
111
+ found_date = Date.new(year.to_i, month.to_i, day.to_i)
112
+ found_date if found_date < Date.today
113
+ rescue Date::Error
114
+ nil
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinValid
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tin_valid.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "tin_valid/version"
5
+ require_relative "tin_valid/austria_tin"
6
+ require_relative "tin_valid/belgium_tin"
7
+ require_relative "tin_valid/bulgaria_tin"
8
+ require_relative "tin_valid/croatia_tin"
9
+ require_relative "tin_valid/cyprus_tin"
10
+ require_relative "tin_valid/czechia_tin"
11
+ require_relative "tin_valid/denmark_tin"
12
+ require_relative "tin_valid/estonia_tin"
13
+ require_relative "tin_valid/sweden_tin"
14
+
15
+ module TinValid
16
+ class Error < StandardError; end
17
+ # Your code goes here...
18
+ end
data/sig/tin_valid.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module TinValid
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tin_valid
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sunny Ripert
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-04-14 00:00:00.000000000 Z
11
+ dependencies: []
12
+ email:
13
+ - sunny@sunfox.org
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - ".rspec"
19
+ - ".rubocop.yml"
20
+ - CHANGELOG.md
21
+ - CODE_OF_CONDUCT.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - Rakefile
25
+ - lib/tin_valid.rb
26
+ - lib/tin_valid/austria_tin.rb
27
+ - lib/tin_valid/belgium_tin.rb
28
+ - lib/tin_valid/bulgaria_tin.rb
29
+ - lib/tin_valid/croatia_tin.rb
30
+ - lib/tin_valid/cyprus_tin.rb
31
+ - lib/tin_valid/czechia_tin.rb
32
+ - lib/tin_valid/denmark_tin.rb
33
+ - lib/tin_valid/estonia_tin.rb
34
+ - lib/tin_valid/sweden_tin.rb
35
+ - lib/tin_valid/version.rb
36
+ - sig/tin_valid.rbs
37
+ homepage: https://github.com/cults/tin_valid
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ allowed_push_host: https://rubygems.org
42
+ homepage_uri: https://github.com/cults/tin_valid
43
+ source_code_uri: https://github.com/cults/tin_valid
44
+ changelog_uri: https://github.com/cults/tin_valid/blob/main/CHANGELOG.md
45
+ rubygems_mfa_required: 'true'
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 3.1.0
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.6.5
61
+ specification_version: 4
62
+ summary: Validate Tax Identification Numbers (TINs) in Europe
63
+ test_files: []