sat_rfc 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: d546b4ca5245b4d86c98325bf2872a14268e945b8b51c0f6bee2c26e7dc9b1af
4
+ data.tar.gz: 6e129b4c04bfc6bb2ec397609bc408c8368094a49bc4a292fb561ff0f9bc9088
5
+ SHA512:
6
+ metadata.gz: 69a47948fcfa654cacfe91d7abd919704b44be4e3fa1690a078ca48a90dec715e5635b9781103b4e3f5e8944b891063361746567b9368d14ad2267e64411fcb9
7
+ data.tar.gz: 928596bf9a5c0fcfd3426b839e8db8254e90c76b892ed785159a688e3d721ccc0771308f057fd04ea06daacf76e52fbd8be5cdf04a9b65c667be073e695180d6
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 RFC Contributors
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,92 @@
1
+ # sat_rfc
2
+
3
+ Ruby gem to generate Mexican **RFC** tax identifiers for **natural persons** (personas físicas), following the [SAT](https://www.sat.gob.mx/) algorithm.
4
+
5
+ Legal entity support (personas morales) is planned for a future release.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem "sat_rfc"
13
+ ```
14
+
15
+ Or install from the repository:
16
+
17
+ ```ruby
18
+ gem "sat_rfc", git: "https://github.com/elosnaya/rfc.git"
19
+ ```
20
+
21
+ Local development install:
22
+
23
+ ```bash
24
+ bundle exec rake install
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```ruby
30
+ require "sat_rfc"
31
+
32
+ rfc = Rfc::Generator.new.for_natural_person(
33
+ name: "Juan Carlos",
34
+ first_last_name: "Perez",
35
+ second_last_name: "Garcia",
36
+ date_of_birth: "15/03/1990"
37
+ )
38
+
39
+ puts rfc # => "PEGJ900315PE9"
40
+ ```
41
+
42
+ You can also pass the birth date as separate values:
43
+
44
+ ```ruby
45
+ Rfc::Generator.new.for_natural_person(
46
+ name: "Juan Carlos",
47
+ first_last_name: "Perez",
48
+ second_last_name: "Garcia",
49
+ day: 15,
50
+ month: 3,
51
+ year: 1990
52
+ )
53
+ ```
54
+
55
+ `date_of_birth` accepts `DD/MM/YYYY` format or any string parseable by Ruby's `Date`.
56
+
57
+ ### Errors
58
+
59
+ ```ruby
60
+ begin
61
+ Rfc::Generator.new.for_natural_person(name: "Ana", first_last_name: "Lopez", second_last_name: "Garcia")
62
+ rescue Rfc::Generator::InvalidDateError => e
63
+ puts e.message # => "Date information missing"
64
+ end
65
+ ```
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ bin/setup # install dependencies
71
+ bundle exec rspec # run tests
72
+ bin/console # interactive console
73
+ ```
74
+
75
+ Example in the console:
76
+
77
+ ```ruby
78
+ Rfc::Generator.new.for_natural_person(
79
+ name: "Juan Carlos",
80
+ first_last_name: "Perez",
81
+ second_last_name: "Garcia",
82
+ date_of_birth: "15/03/1990"
83
+ )
84
+ ```
85
+
86
+ ## Contributing
87
+
88
+ Bug reports and pull requests are welcome on GitHub at [elosnaya/rfc](https://github.com/elosnaya/rfc).
89
+
90
+ ## License
91
+
92
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
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
+ task default: :spec
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rfc
4
+ module DiscardableTerms
5
+ # Dictionary of discardable terms and forbidden words for natural persons (personas físicas).
6
+ # Based on SAT specification.
7
+ module NaturalPerson
8
+ ARTICLES_AND_PREPOSITIONS = %w[
9
+ DE LA LAS DEL LOS Y
10
+ EN CON SUS PARA POR AL E
11
+ OF THE AND
12
+ ].freeze
13
+
14
+ COMPANY_TYPE_ABBREVIATIONS = %w[
15
+ SA SC SRL SCS CIA COOP SOC CO COMPANY
16
+ CV RL S A C V
17
+ COMPAÑIA
18
+ ].freeze
19
+
20
+ DISCARDABLE_TERMS = %w[MC VON MAC VAN MI].freeze
21
+
22
+ ALL = (
23
+ ARTICLES_AND_PREPOSITIONS +
24
+ COMPANY_TYPE_ABBREVIATIONS +
25
+ DISCARDABLE_TERMS
26
+ ).freeze
27
+
28
+ FORBIDDEN_WORDS = %w[
29
+ BUEI BUEY CACA CACO CAGA KOGE KAKA MAME KOJO KULO
30
+ CAGO COGE COJE COJO FETO JOTO KACO KAGO MAMO MEAR MEON
31
+ MION MOCO MULA PEDA PEDO PENE PUTA PUTO QULO RATA RUIN
32
+ ].freeze
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Rfc
6
+ class Generator
7
+ class InvalidDateError < Error; end
8
+
9
+ def initialize(
10
+ natural_ten_digits_calculator: NaturalTenDigitsCodeCalculator,
11
+ homoclave_calculator: HomoclaveCalculator,
12
+ verification_digit_calculator: VerificationDigitCalculator
13
+ )
14
+ @natural_ten_digits_calculator = natural_ten_digits_calculator
15
+ @homoclave_calculator = homoclave_calculator
16
+ @verification_digit_calculator = verification_digit_calculator
17
+ end
18
+
19
+ def for_natural_person(
20
+ name:,
21
+ first_last_name:,
22
+ second_last_name:,
23
+ day: nil,
24
+ month: nil,
25
+ year: nil,
26
+ date_of_birth: nil
27
+ )
28
+ normalized_day, normalized_month, normalized_year = normalize_date(day, month, year, date_of_birth)
29
+
30
+ ten_digits_code = @natural_ten_digits_calculator.new(
31
+ name: name,
32
+ first_last_name: first_last_name,
33
+ second_last_name: second_last_name,
34
+ day: normalized_day,
35
+ month: normalized_month,
36
+ year: normalized_year
37
+ ).calculate
38
+
39
+ homoclave = @homoclave_calculator.new(
40
+ name: name,
41
+ first_last_name: first_last_name,
42
+ second_last_name: second_last_name
43
+ ).calculate
44
+
45
+ build_rfc(ten_digits_code, homoclave)
46
+ rescue ArgumentError => e
47
+ raise InvalidDateError, "Invalid date parameters: #{e.message}"
48
+ end
49
+
50
+ private
51
+
52
+ def build_rfc(ten_digits_code, homoclave)
53
+ rfc12_digits = "#{ten_digits_code.gsub("-", "")}#{homoclave}"
54
+ verification_digit = @verification_digit_calculator.new(rfc12_digits).calculate
55
+
56
+ "#{ten_digits_code}#{homoclave}#{verification_digit}"
57
+ end
58
+
59
+ def normalize_date(day, month, year, date_string)
60
+ if !day.nil? && !month.nil? && !year.nil?
61
+ [day, month, year]
62
+ elsif present?(date_string)
63
+ parse_date_string(date_string)
64
+ else
65
+ raise InvalidDateError, "Date information missing"
66
+ end
67
+ end
68
+
69
+ def parse_date_string(date_string)
70
+ parsed_date = try_parse_date(date_string)
71
+ raise InvalidDateError, "Invalid date format: #{date_string}" unless parsed_date
72
+
73
+ [parsed_date.day, parsed_date.month, parsed_date.year]
74
+ end
75
+
76
+ def try_parse_date(date_string)
77
+ return nil unless present?(date_string)
78
+
79
+ Date.strptime(date_string, "%d/%m/%Y")
80
+ rescue ArgumentError
81
+ begin
82
+ Date.parse(date_string.to_s)
83
+ rescue ArgumentError
84
+ nil
85
+ end
86
+ end
87
+
88
+ def present?(value)
89
+ !value.nil? && !value.to_s.strip.empty?
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+
5
+ module Rfc
6
+ class HomoclaveCalculator
7
+ HOMOCLAVE_DIGITS = "123456789ABCDEFGHIJKLMNPQRSTUVWXYZ".freeze
8
+ FULL_NAME_MAPPING = {
9
+ " " => "00", "0" => "00", "1" => "01", "2" => "02", "3" => "03", "4" => "04",
10
+ "5" => "05", "6" => "06", "7" => "07", "8" => "08", "9" => "09", "&" => "10",
11
+ "A" => "11", "B" => "12", "C" => "13", "D" => "14", "E" => "15", "F" => "16",
12
+ "G" => "17", "H" => "18", "I" => "19", "J" => "21", "K" => "22", "L" => "23",
13
+ "M" => "24", "N" => "25", "O" => "26", "P" => "27", "Q" => "28", "R" => "29",
14
+ "S" => "32", "T" => "33", "U" => "34", "V" => "35", "W" => "36", "X" => "37",
15
+ "Y" => "38", "Z" => "39", "Ñ" => "40"
16
+ }.freeze
17
+
18
+ def initialize(name:, first_last_name:, second_last_name:)
19
+ @name = name
20
+ @first_last_name = first_last_name
21
+ @second_last_name = second_last_name
22
+ end
23
+
24
+ def calculate
25
+ normalize_full_name
26
+ map_full_name_to_digits_code
27
+ sum_pairs_of_digits
28
+ build_homoclave
29
+
30
+ @homoclave
31
+ end
32
+
33
+ private
34
+
35
+ def build_homoclave
36
+ last_three_digits = (@pairs_of_digits_sum % 1000)
37
+ quo = (last_three_digits / 34)
38
+ reminder = (last_three_digits % 34)
39
+ @homoclave = "#{HOMOCLAVE_DIGITS[quo]}#{HOMOCLAVE_DIGITS[reminder]}"
40
+ end
41
+
42
+ def sum_pairs_of_digits
43
+ @pairs_of_digits_sum = 0
44
+ (0..@mapped_full_name.length - 2).each do |i|
45
+ num1 = @mapped_full_name[i..i + 1].to_i
46
+ num2 = @mapped_full_name[i + 1..i + 1].to_i
47
+
48
+ @pairs_of_digits_sum += num1 * num2
49
+ end
50
+ end
51
+
52
+ def map_full_name_to_digits_code
53
+ @mapped_full_name = +"0"
54
+ @full_name.each_char do |character|
55
+ @mapped_full_name << map_character_to_two_digit_code(character)
56
+ end
57
+ end
58
+
59
+ def map_character_to_two_digit_code(character)
60
+ return FULL_NAME_MAPPING[character] if FULL_NAME_MAPPING.key?(character)
61
+
62
+ raise ArgumentError, "No two-digit-code mapping for char: #{character}"
63
+ end
64
+
65
+ def normalize_full_name
66
+ raw_full_name = full_name_string.upcase
67
+ @full_name = I18n.transliterate(raw_full_name).dup
68
+ @full_name.gsub!(/[-.']/, "")
69
+ @full_name.gsub!(/[^A-Z0-9\s]/, " ")
70
+ @full_name.gsub!(/\s+/, " ")
71
+ @full_name.strip!
72
+ add_missing_char_to_full_name(raw_full_name, "Ñ")
73
+ end
74
+
75
+ def full_name_string
76
+ "#{@first_last_name} #{@second_last_name} #{@name}"
77
+ end
78
+
79
+ def add_missing_char_to_full_name(raw_full_name, missing_char)
80
+ index = raw_full_name.index(missing_char)
81
+ until index.nil?
82
+ @full_name[index] = missing_char
83
+ index = raw_full_name.index(missing_char, index + 1)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rfc
4
+ class NaturalTenDigitsCodeCalculator
5
+ VOWEL_PATTERN = /[AEIOU]+/
6
+ DISCARDABLE_TERMS = DiscardableTerms::NaturalPerson::ALL
7
+ FORBIDDEN_WORDS = DiscardableTerms::NaturalPerson::FORBIDDEN_WORDS
8
+
9
+ def initialize(name:, first_last_name:, second_last_name:, day:, month:, year:)
10
+ @name = name
11
+ @first_last_name = first_last_name
12
+ @second_last_name = second_last_name
13
+ @day = day
14
+ @month = month
15
+ @year = year
16
+ end
17
+
18
+ def calculate
19
+ obfuscate_forbidden_words(name_code) + birthday_code
20
+ end
21
+
22
+ private
23
+
24
+ def obfuscate_forbidden_words(name_code)
25
+ FORBIDDEN_WORDS.each do |forbidden|
26
+ return "#{name_code[0..2]}X" if forbidden == name_code
27
+ end
28
+ name_code
29
+ end
30
+
31
+ def name_code
32
+ return first_last_name_empty_form if first_last_name_empty?
33
+ return second_last_name_empty_form if second_last_name_empty?
34
+ return first_last_name_too_short_form if first_last_name_is_too_short?
35
+
36
+ normal_form
37
+ end
38
+
39
+ def second_last_name_empty_form
40
+ first_two_letters_of(@first_last_name) + first_two_letters_of(filter_name(@name))
41
+ end
42
+
43
+ def birthday_code
44
+ "#{@year.to_s[-2, 2]}#{format('%02d', @month)}#{format('%02d', @day)}"
45
+ end
46
+
47
+ def second_last_name_empty?
48
+ normalized = normalize(@second_last_name)
49
+ normalized.nil? || normalized.empty?
50
+ end
51
+
52
+ def first_last_name_empty_form
53
+ first_two_letters_of(@second_last_name) + first_two_letters_of(filter_name(@name))
54
+ end
55
+
56
+ def first_last_name_empty?
57
+ normalized = normalize(@first_last_name)
58
+ normalized.nil? || normalized.empty?
59
+ end
60
+
61
+ def first_last_name_too_short_form
62
+ first_letter_of(@first_last_name) +
63
+ first_letter_of(@second_last_name) +
64
+ first_two_letters_of(filter_name(@name))
65
+ end
66
+
67
+ def first_two_letters_of(word)
68
+ normalized_word = normalize(word)
69
+ normalized_word[0..1]
70
+ end
71
+
72
+ def first_last_name_is_too_short?
73
+ normalize(@first_last_name).length <= 2
74
+ end
75
+
76
+ def normal_form
77
+ first_letter_of(@first_last_name) +
78
+ first_vowel_excluding_first_character_of(@first_last_name) +
79
+ first_letter_of(@second_last_name) +
80
+ first_letter_of(filter_name(@name))
81
+ end
82
+
83
+ def filter_name(name)
84
+ normalize(name).strip.sub(/^(MA|MA\.|MARIA|JOSE)\s+/, "")
85
+ end
86
+
87
+ def first_letter_of(word)
88
+ normalized_word = normalize(word)
89
+ normalized_word[0]
90
+ end
91
+
92
+ def normalize(word)
93
+ return word if word.nil? || word.empty?
94
+
95
+ normalized_word = I18n.transliterate(word).upcase
96
+ remove_discardable_terms(normalized_word, DISCARDABLE_TERMS)
97
+ end
98
+
99
+ def remove_discardable_terms(word, discardable_terms)
100
+ new_word = word.dup
101
+ discardable_terms.each do |term|
102
+ new_word.gsub!("#{term} ", "")
103
+ end
104
+ new_word
105
+ end
106
+
107
+ def first_vowel_excluding_first_character_of(word)
108
+ normalized_word = normalize(word)[1..]
109
+ match = VOWEL_PATTERN.match(normalized_word)
110
+ raise ArgumentError, "Word doesn't contain a vowel: #{normalized_word}" if match.nil?
111
+
112
+ match.to_s[0]
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rfc
4
+ class VerificationDigitCalculator
5
+ MAPPING = {
6
+ "0" => 0, "1" => 1, "2" => 2, "3" => 3, "4" => 4, "5" => 5, "6" => 6,
7
+ "7" => 7, "8" => 8, "9" => 9, "A" => 10, "B" => 11, "C" => 12, "D" => 13,
8
+ "E" => 14, "F" => 15, "G" => 16, "H" => 17, "I" => 18, "J" => 19, "K" => 20,
9
+ "L" => 21, "M" => 22, "N" => 23, "&" => 24, "O" => 25, "P" => 26, "Q" => 27,
10
+ "R" => 28, "S" => 29, "T" => 30, "U" => 31, "V" => 32, "W" => 33, "X" => 34,
11
+ "Y" => 35, "Z" => 36, " " => 37, "Ñ" => 38
12
+ }.freeze
13
+
14
+ def initialize(rfc12_digits)
15
+ @rfc12_digits = rfc12_digits
16
+ end
17
+
18
+ def calculate
19
+ rfc12_padded = @rfc12_digits.ljust(12, " ")
20
+ sum = 0
21
+ (0..11).each do |i|
22
+ sum += map_digit(rfc12_padded[i]) * (13 - i)
23
+ end
24
+ reminder = sum % 11
25
+
26
+ return "0" if reminder.zero?
27
+
28
+ (11 - reminder).to_s(16).upcase
29
+ end
30
+
31
+ private
32
+
33
+ def map_digit(character)
34
+ return MAPPING[character] if MAPPING.key?(character)
35
+
36
+ 0
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rfc
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rfc.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sat_rfc"
data/lib/sat_rfc.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+
5
+ I18n.available_locales = [:en]
6
+ I18n.default_locale = :en
7
+
8
+ require_relative "rfc/version"
9
+ require_relative "rfc/discardable_terms/natural_person"
10
+ require_relative "rfc/verification_digit_calculator"
11
+ require_relative "rfc/homoclave_calculator"
12
+ require_relative "rfc/natural_ten_digits_code_calculator"
13
+
14
+ module Rfc
15
+ class Error < StandardError; end
16
+ end
17
+
18
+ require_relative "rfc/generator"
data/sig/sat_rfc.rbs ADDED
@@ -0,0 +1,3 @@
1
+ module Rfc
2
+ VERSION: String
3
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sat_rfc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - elOsnaya
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: i18n
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ description: Ruby library that generates RFC tax identifiers for Mexican natural persons
27
+ using the SAT algorithm. Supports name normalization, homoclave, and verification
28
+ digit calculation.
29
+ email:
30
+ - luisosnet@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - LICENSE.txt
36
+ - README.md
37
+ - Rakefile
38
+ - lib/rfc.rb
39
+ - lib/rfc/discardable_terms/natural_person.rb
40
+ - lib/rfc/generator.rb
41
+ - lib/rfc/homoclave_calculator.rb
42
+ - lib/rfc/natural_ten_digits_code_calculator.rb
43
+ - lib/rfc/verification_digit_calculator.rb
44
+ - lib/rfc/version.rb
45
+ - lib/sat_rfc.rb
46
+ - sig/sat_rfc.rbs
47
+ homepage: https://github.com/elosnaya/rfc
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/elosnaya/rfc
52
+ source_code_uri: https://github.com/elosnaya/rfc
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 3.2.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 4.0.3
68
+ specification_version: 4
69
+ summary: Generate Mexican RFC codes for natural persons (personas físicas).
70
+ test_files: []