steuer 0.2.0.pre.alpha

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: 359109ea7cce6dd33e9fed32d083742ad20b27f97e84b3d89c4bdf73aa281dd4
4
+ data.tar.gz: 4fa8e2834415fc5e041a7f00ea13eac12d4340f62aed2a6168e1fac2aa6b80f2
5
+ SHA512:
6
+ metadata.gz: 7c4805f2ff71eed6e1bcf2b85335226dab136107b82718fa9d223d8c83b8884b13817ba9a4dce30403bb60cac163f227f1527413b911b6f0245bc1c0748b2739
7
+ data.tar.gz: 126840ae3f0888c8dfc1d1eb0f0e717686b6f1b0a841fb9b18e54f3c9f408259b1576b1d2416e1428fd34529dde3460cd2a907a3d52f2143e22e3b266a461a90
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Steuer 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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # Steuer
2
+
3
+ A Ruby gem for German tax system utilities, including Steuernummer (tax number) conversion between different formats and validation.
4
+
5
+ Based on the official specifications from the [German Wikipedia page on Steuernummer](https://de.wikipedia.org/wiki/Steuernummer#Deutschland).
6
+
7
+ ## Features
8
+
9
+ - **Format Detection**: Automatically detects the format of German tax numbers
10
+ - **Format Conversion**: Convert between three different formats:
11
+ - Standard scheme (state-specific format, e.g., `93/815/08152`)
12
+ - Unified federal scheme (12-digit, e.g., `289381508152`)
13
+ - Unified federal scheme for electronic transmission (13-digit, e.g., `2893081508152`)
14
+ - **State Detection**: Automatically detects the German state (Bundesland) from tax numbers
15
+ - **Validation**: Validates tax numbers according to official patterns
16
+ - **Object-Oriented**: Clean, object-oriented API design
17
+ - **Extensible**: Designed to be extended with additional German tax utilities (VAT validation, etc.)
18
+
19
+ ## Installation
20
+
21
+ Add this line to your application's Gemfile:
22
+
23
+ ```ruby
24
+ gem 'steuer'
25
+ ```
26
+
27
+ And then execute:
28
+
29
+ ```bash
30
+ $ bundle install
31
+ ```
32
+
33
+ Or install it yourself as:
34
+
35
+ ```bash
36
+ $ gem install steuer
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ### Basic Usage
42
+
43
+ ```ruby
44
+ require 'steuer'
45
+
46
+ # Create a tax number object (auto-detects state from unambiguous formats)
47
+ tax_number = Steuer.steuernummer('93/815/08152') # Auto-detects Baden-Württemberg
48
+
49
+ # Check if valid
50
+ puts tax_number.valid? # => true
51
+
52
+ # Get information
53
+ puts tax_number.state_code # => "BW"
54
+ puts tax_number.state_name # => "Baden-Württemberg"
55
+ puts tax_number.format_type # => :standard
56
+
57
+ # Convert between formats
58
+ puts tax_number.to_federal_12 # => "289381508152"
59
+ puts tax_number.to_federal_13 # => "2893081508152"
60
+ puts tax_number.to_standard # => "93/815/08152"
61
+ ```
62
+
63
+ ### Auto-Detection vs Explicit State
64
+
65
+ ```ruby
66
+ # ✅ Auto-detects from standard format (each state has unique pattern)
67
+ standard = Steuer.steuernummer('181/815/08155') # Auto-detects Bayern
68
+ puts standard.state_code # => "BY"
69
+
70
+ # ✅ Auto-detects from unambiguous federal prefixes
71
+ federal_12 = Steuer.steuernummer('289381508152') # Prefix '28' is unique to BW
72
+ puts federal_12.state_name # => "Baden-Württemberg"
73
+
74
+ # ❌ Requires explicit state for ambiguous prefixes
75
+ begin
76
+ Steuer.steuernummer('304881508155') # Prefix '3' shared by BB, SN, ST
77
+ rescue Steuer::UnsupportedStateError => e
78
+ puts e.message # => "Cannot determine state from tax number..."
79
+ end
80
+
81
+ # ✅ Works with explicit state for ambiguous cases
82
+ ambiguous = Steuer.steuernummer('304881508155', state: 'BB')
83
+ puts ambiguous.state_code # => "BB"
84
+ ```
85
+
86
+ ### Ambiguous vs Unambiguous Prefixes
87
+
88
+ **✅ Unambiguous prefixes (auto-detected):**
89
+
90
+ - `28` → Baden-Württemberg
91
+ - `9` → Bayern
92
+ - `11` → Berlin
93
+ - `24` → Bremen
94
+ - `22` → Hamburg
95
+ - `26` → Hessen
96
+ - `23` → Niedersachsen
97
+ - `5` → Nordrhein-Westfalen
98
+ - `27` → Rheinland-Pfalz
99
+ - `1` → Saarland
100
+ - `21` → Schleswig-Holstein
101
+
102
+ **❌ Ambiguous prefixes (require explicit state):**
103
+
104
+ - `3` → Brandenburg, Sachsen, or Sachsen-Anhalt
105
+ - `4` → Mecklenburg-Vorpommern or Thüringen
106
+
107
+ ### Error Handling
108
+
109
+ ```ruby
110
+ begin
111
+ # Invalid format
112
+ Steuer.steuernummer('invalid')
113
+ rescue Steuer::InvalidTaxNumberError => e
114
+ puts "Invalid format: #{e.message}"
115
+ end
116
+
117
+ begin
118
+ # Unsupported state or undetectable
119
+ Steuer.steuernummer('99/999/99999')
120
+ rescue Steuer::UnsupportedStateError => e
121
+ puts "Unsupported state: #{e.message}"
122
+ end
123
+ ```
124
+
125
+ ## Supported German States
126
+
127
+ The gem supports all 16 German states (Bundesländer):
128
+
129
+ | State Code | State Name | Standard Format Example | 12-digit Example | 13-digit Example |
130
+ | ---------- | ---------------------- | ----------------------- | ---------------- | ---------------- |
131
+ | BW | Baden-Württemberg | `93/815/08152` | `289381508152` | `2893081508152` |
132
+ | BY | Bayern | `181/815/08155` | `918181508155` | `9181081508155` |
133
+ | BE | Berlin | `21/815/08150` | `112181508150` | `1121081508150` |
134
+ | BB | Brandenburg | `048/815/08155` | `304881508155` | `3048081508155` |
135
+ | HB | Bremen | `75/815/08152` | `247581508152` | `2475081508152` |
136
+ | HH | Hamburg | `02/815/08156` | `220281508156` | `2202081508156` |
137
+ | HE | Hessen | `013/815/08153` | `261381508153` | `2613081508153` |
138
+ | MV | Mecklenburg-Vorpommern | `79/815/08151` | `407981508151` | `4079081508151` |
139
+ | NI | Niedersachsen | `24/815/08151` | `232481508151` | `2324081508151` |
140
+ | NW | Nordrhein-Westfalen | `133/8150/8159` | `513381508159` | `5133081508159` |
141
+ | RP | Rheinland-Pfalz | `22/815/08154` | `272281508154` | `2722081508154` |
142
+ | SL | Saarland | `010/815/08182` | `101081508182` | `1010081508182` |
143
+ | SN | Sachsen | `201/815/08156` | `320181508156` | `3201081508156` |
144
+ | ST | Sachsen-Anhalt | `101/815/08153` | `310181508153` | `3101081508153` |
145
+ | SH | Schleswig-Holstein | `01/815/08155` | `210181508155` | `2101081508155` |
146
+ | TH | Thüringen | `151/815/08154` | `415181508154` | `4151081508154` |
147
+
148
+ ## API Reference
149
+
150
+ ### `Steuer.steuernummer(tax_number, state_code = nil)`
151
+
152
+ Creates a new `Steuer::Steuernummer` object.
153
+
154
+ **Parameters:**
155
+
156
+ - `tax_number` (String): The tax number in any supported format
157
+ - `state_code` (String, optional): The German state code (auto-detected if not provided)
158
+
159
+ **Returns:** `Steuer::Steuernummer` instance
160
+
161
+ ### `Steuer::Steuernummer` Methods
162
+
163
+ #### Instance Methods
164
+
165
+ - `valid?` - Returns `true` if the tax number is valid
166
+ - `to_federal_12` - Converts to 12-digit unified federal scheme
167
+ - `to_federal_13` - Converts to 13-digit electronic transmission format
168
+ - `to_standard` - Converts to standard state-specific format
169
+ - `state_code` - Returns the German state code (e.g., "BW")
170
+ - `state_name` - Returns the full state name (e.g., "Baden-Württemberg")
171
+ - `format_type` - Returns the detected format (`:standard`, `:federal_12`, or `:federal_13`)
172
+ - `original_input` - Returns the original input string
173
+
174
+ ## Development
175
+
176
+ After checking out the repo, run:
177
+
178
+ ```bash
179
+ bundle install
180
+ ```
181
+
182
+ To run the tests:
183
+
184
+ ```bash
185
+ bundle exec rspec
186
+ ```
187
+
188
+ ## Contributing
189
+
190
+ 1. Fork it
191
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
192
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
193
+ 4. Push to the branch (`git push origin my-new-feature`)
194
+ 5. Create new Pull Request
195
+
196
+ ## Future Features
197
+
198
+ This gem is designed to be extensible. Planned features include:
199
+
200
+ - German VAT number validation
201
+ - Steuer-ID (tax identification number) handling
202
+ - Wirtschafts-Identifikationsnummer support
203
+ - Additional German tax system utilities
204
+
205
+ ## References
206
+
207
+ - [German Steuernummer Wikipedia Page](https://de.wikipedia.org/wiki/Steuernummer#Deutschland)
208
+ - [Bundeszentralamt für Steuern](https://www.bzst.de/)
209
+
210
+ ## License
211
+
212
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Steuer
4
+ class Error < StandardError; end
5
+ class InvalidTaxNumberError < Error; end
6
+ class UnsupportedStateError < Error; end
7
+ class ValidationError < Error; end
8
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Steuer
4
+ module StateMapping
5
+ # Mapping from state codes to their configuration
6
+ # Based on https://de.wikipedia.org/wiki/Steuernummer#Deutschland
7
+ STATES = {
8
+ 'BW' => {
9
+ name: 'Baden-Württemberg',
10
+ standard_pattern: %r{^(\d{2})/(\d{3})/(\d{5})$},
11
+ federal_12_prefix: '28',
12
+ federal_13_prefix: '28',
13
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FF)
14
+ },
15
+ 'BY' => {
16
+ name: 'Bayern',
17
+ standard_pattern: %r{^(\d{3})/(\d{3})/(\d{5})$},
18
+ federal_12_prefix: '9',
19
+ federal_13_prefix: '9',
20
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FFF)
21
+ },
22
+ 'BE' => {
23
+ name: 'Berlin',
24
+ standard_pattern: %r{^(\d{2})/(\d{3})/(\d{5})$},
25
+ federal_12_prefix: '11',
26
+ federal_13_prefix: '11',
27
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FF)
28
+ },
29
+ 'BB' => {
30
+ name: 'Brandenburg',
31
+ standard_pattern: %r{^(\d{3})/(\d{3})/(\d{5})$},
32
+ federal_12_prefix: '3',
33
+ federal_13_prefix: '3',
34
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FFF)
35
+ },
36
+ 'HB' => {
37
+ name: 'Bremen',
38
+ standard_pattern: %r{^(\d{2})/(\d{3})/(\d{5})$},
39
+ federal_12_prefix: '24',
40
+ federal_13_prefix: '24',
41
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FF)
42
+ },
43
+ 'HH' => {
44
+ name: 'Hamburg',
45
+ standard_pattern: %r{^(\d{2})/(\d{3})/(\d{5})$},
46
+ federal_12_prefix: '22',
47
+ federal_13_prefix: '22',
48
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FF)
49
+ },
50
+ 'HE' => {
51
+ name: 'Hessen',
52
+ standard_pattern: %r{^0(1[3-9])/(\d{3})/(\d{5})$}, # More specific: 013-019 range, capture 13-19
53
+ federal_12_prefix: '26',
54
+ federal_13_prefix: '26',
55
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FF)
56
+ },
57
+ 'MV' => {
58
+ name: 'Mecklenburg-Vorpommern',
59
+ standard_pattern: %r{^(\d{3})/(\d{3})/(\d{5})$},
60
+ federal_12_prefix: '4',
61
+ federal_13_prefix: '4',
62
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FFF)
63
+ },
64
+ 'NI' => {
65
+ name: 'Niedersachsen',
66
+ standard_pattern: %r{^(\d{2})/(\d{3})/(\d{5})$},
67
+ federal_12_prefix: '23',
68
+ federal_13_prefix: '23',
69
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FF)
70
+ },
71
+ 'NW' => {
72
+ name: 'Nordrhein-Westfalen',
73
+ standard_pattern: %r{^(\d{3})/(\d{4})/(\d{4})$},
74
+ federal_12_prefix: '5',
75
+ federal_13_prefix: '5',
76
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FFF)
77
+ },
78
+ 'RP' => {
79
+ name: 'Rheinland-Pfalz',
80
+ standard_pattern: %r{^(\d{2})/(\d{3})/(\d{5})$},
81
+ federal_12_prefix: '27',
82
+ federal_13_prefix: '27',
83
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FF)
84
+ },
85
+ 'SL' => {
86
+ name: 'Saarland',
87
+ standard_pattern: %r{^(01[0-2])/(\d{3})/(\d{5})$}, # Specific to 010-012 for Saarland
88
+ federal_12_prefix: '1',
89
+ federal_13_prefix: '1',
90
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FFF)
91
+ },
92
+ 'SN' => {
93
+ name: 'Sachsen',
94
+ standard_pattern: %r{^(\d{3})/(\d{3})/(\d{5})$},
95
+ federal_12_prefix: '3',
96
+ federal_13_prefix: '3',
97
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FFF)
98
+ },
99
+ 'ST' => {
100
+ name: 'Sachsen-Anhalt',
101
+ standard_pattern: %r{^(\d{3})/(\d{3})/(\d{5})$},
102
+ federal_12_prefix: '3',
103
+ federal_13_prefix: '3',
104
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FFF)
105
+ },
106
+ 'SH' => {
107
+ name: 'Schleswig-Holstein',
108
+ standard_pattern: %r{^(\d{2})/(\d{3})/(\d{5})$},
109
+ federal_12_prefix: '21',
110
+ federal_13_prefix: '21',
111
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FF)
112
+ },
113
+ 'TH' => {
114
+ name: 'Thüringen',
115
+ standard_pattern: %r{^(\d{3})/(\d{3})/(\d{5})$},
116
+ federal_12_prefix: '4',
117
+ federal_13_prefix: '4',
118
+ federal_13_zero_position: 4, # Insert 0 at position 4 (after FFF)
119
+ },
120
+ }.freeze
121
+ class << self
122
+ def find_state_by_standard_format(tax_number)
123
+ # Try more specific patterns first to avoid conflicts with general patterns
124
+ # Priority: character classes with ranges > specific digit patterns > general digit patterns
125
+ sorted_states = STATES.sort_by do |_, config|
126
+ pattern_str = config[:standard_pattern].source
127
+ if pattern_str.include?('[') # Character classes like [0-2] or [3-9]
128
+ 0
129
+ elsif pattern_str.include?('0(') || pattern_str.start_with?('^0') # Patterns starting with specific digit
130
+ 1
131
+ else # General patterns like (\d{3})
132
+ 2
133
+ end
134
+ end
135
+
136
+ sorted_states.each do |code, config|
137
+ return code if tax_number.match?(config[:standard_pattern])
138
+ end
139
+ nil
140
+ end
141
+
142
+ def find_state_by_federal_12(tax_number)
143
+ return unless tax_number.length == 12
144
+
145
+ # Try exact prefix matches, longest first to avoid conflicts
146
+ # e.g., '28' should match before '2', '11' before '1'
147
+ sorted_states = STATES.sort_by { |_, config| -config[:federal_12_prefix].length }
148
+
149
+ sorted_states.each do |code, config|
150
+ prefix = config[:federal_12_prefix]
151
+
152
+ # Check if tax number starts with this exact prefix
153
+ next unless tax_number.start_with?(prefix)
154
+
155
+ # For single digit prefixes, ensure we don't match longer numbers
156
+ if prefix.length == 1
157
+ # For single digit, check that the next digit doesn't make it a longer prefix
158
+ next_char = tax_number[prefix.length]
159
+ return nil if next_char.nil? # Invalid tax number
160
+
161
+ # Skip if this would create a longer prefix that exists
162
+ longer_prefix = prefix + next_char
163
+ has_longer_prefix = STATES.any? { |_, c| c[:federal_12_prefix] == longer_prefix }
164
+ next if has_longer_prefix
165
+
166
+ # Additional validation: reject obviously invalid combinations
167
+ # For prefix '9' (Bayern), '99' is not a valid federal tax number start
168
+ if prefix == '9' && next_char == '9'
169
+ return nil
170
+ end
171
+ end
172
+
173
+ return code
174
+ end
175
+ nil
176
+ end
177
+
178
+ def find_state_by_federal_13(tax_number)
179
+ return unless tax_number.length == 13
180
+
181
+ # Try exact prefix matches, longest first to avoid conflicts
182
+ sorted_states = STATES.sort_by { |_, config| -config[:federal_13_prefix].length }
183
+
184
+ sorted_states.each do |code, config|
185
+ prefix = config[:federal_13_prefix]
186
+
187
+ # Check if tax number starts with this exact prefix
188
+ next unless tax_number.start_with?(prefix)
189
+
190
+ # For single digit prefixes, ensure we don't match longer numbers
191
+ if prefix.length == 1
192
+ # For single digit, check that the next digit doesn't make it a longer prefix
193
+ next_char = tax_number[prefix.length]
194
+ return nil if next_char.nil? # Invalid tax number
195
+
196
+ # Skip if this would create a longer prefix that exists
197
+ longer_prefix = prefix + next_char
198
+ has_longer_prefix = STATES.any? { |_, c| c[:federal_13_prefix] == longer_prefix }
199
+ next if has_longer_prefix
200
+ end
201
+
202
+ return code
203
+ end
204
+ nil
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'state_mapping'
4
+
5
+ module Steuer
6
+ class Steuernummer
7
+ attr_reader :original_input, :state_code
8
+
9
+ def initialize(tax_number, state: nil)
10
+ @original_input = tax_number.to_s.strip
11
+ @state_code = normalize_state_code(state) || auto_detect_state
12
+
13
+ validate_tax_number!
14
+ end
15
+
16
+ def valid?
17
+ return false if @original_input.empty?
18
+ return false unless format_type
19
+ return false unless @state_code
20
+
21
+ case format_type
22
+ when :standard
23
+ validate_standard_format
24
+ when :federal_12
25
+ validate_federal_12_format
26
+ when :federal_13
27
+ validate_federal_13_format
28
+ else
29
+ false
30
+ end
31
+ end
32
+
33
+ def to_federal_12
34
+ return unless valid?
35
+
36
+ case format_type
37
+ when :standard
38
+ convert_standard_to_federal_12
39
+ when :federal_12
40
+ normalized_input
41
+ when :federal_13
42
+ convert_federal_13_to_federal_12
43
+ end
44
+ end
45
+
46
+ def to_federal_13
47
+ return unless valid?
48
+
49
+ case format_type
50
+ when :standard
51
+ convert_standard_to_federal_13
52
+ when :federal_12
53
+ convert_federal_12_to_federal_13
54
+ when :federal_13
55
+ normalized_input
56
+ end
57
+ end
58
+
59
+ def to_standard
60
+ return unless valid?
61
+
62
+ case format_type
63
+ when :standard
64
+ @original_input
65
+ when :federal_12
66
+ convert_federal_12_to_standard
67
+ when :federal_13
68
+ convert_federal_13_to_standard
69
+ end
70
+ end
71
+
72
+ def state_name
73
+ return unless @state_code
74
+
75
+ StateMapping::STATES[@state_code][:name]
76
+ end
77
+
78
+ def format_type
79
+ @format_type ||= if normalized_input.include?('/')
80
+ :standard
81
+ elsif normalized_input.length == 12 && normalized_input.match?(/^\d{12}$/)
82
+ :federal_12
83
+ elsif normalized_input.length == 13 && normalized_input.match?(/^\d{13}$/)
84
+ :federal_13
85
+ end
86
+
87
+ @format_type
88
+ end
89
+
90
+ private
91
+
92
+ def normalized_input
93
+ @normalized_input_input ||= if @original_input.include?('/') || @original_input.include?('-')
94
+ @original_input.gsub(%r{[^0-9/\-]}, '').tr('-', '/')
95
+ else
96
+ @original_input.gsub(/[^0-9]/, '')
97
+ end
98
+ @normalized_input_input
99
+ end
100
+
101
+ def validate_tax_number!
102
+ unless format_type
103
+ raise InvalidTaxNumberError, "Invalid tax number format: #{@original_input}"
104
+ end
105
+
106
+ # Auto-detect state if not provided
107
+ if @state_code.nil?
108
+ raise UnsupportedStateError,
109
+ "Cannot determine state from tax number #{@original_input}. Please provide state parameter."
110
+ end
111
+
112
+ unless StateMapping::STATES.key?(@state_code)
113
+ raise UnsupportedStateError, "Unsupported state: #{@state_code}"
114
+ end
115
+
116
+ # Validate that the tax number matches the state's expected pattern
117
+ unless valid_for_state?
118
+ raise InvalidTaxNumberError, "Tax number #{@original_input} is not valid for state #{@state_code}"
119
+ end
120
+ end
121
+
122
+ def normalize_state_code(state)
123
+ return if state.nil? || state.to_s.strip.empty?
124
+
125
+ state_str = state.to_s.strip.upcase
126
+
127
+ return state_str if StateMapping::STATES.key?(state_str)
128
+
129
+ StateMapping::STATES.find { |_code, config| config[:name].upcase == state_str }&.first || state_str
130
+ end
131
+
132
+ def auto_detect_state
133
+ case format_type
134
+ when :standard
135
+ StateMapping.find_state_by_standard_format(normalized_input)
136
+ when :federal_12
137
+ # Only auto-detect if prefix is unambiguous
138
+ detected = StateMapping.find_state_by_federal_12(normalized_input)
139
+ return detected if detected && unambiguous_federal_prefix?(detected, normalized_input, :federal_12)
140
+
141
+ nil
142
+ when :federal_13
143
+ # Only auto-detect if prefix is unambiguous
144
+ detected = StateMapping.find_state_by_federal_13(normalized_input)
145
+ return detected if detected && unambiguous_federal_prefix?(detected, normalized_input, :federal_13)
146
+
147
+ nil
148
+ end
149
+ end
150
+
151
+ def unambiguous_federal_prefix?(detected_state, tax_number, format_type)
152
+ prefix_key = format_type == :federal_12 ? :federal_12_prefix : :federal_13_prefix
153
+ detected_prefix = StateMapping::STATES[detected_state][prefix_key]
154
+
155
+ # Count how many states share this prefix
156
+ matching_states = StateMapping::STATES.count do |_, config|
157
+ config[prefix_key] == detected_prefix
158
+ end
159
+
160
+ # Only unambiguous if exactly one state has this prefix
161
+ matching_states == 1
162
+ end
163
+
164
+ def valid_for_state?
165
+ return false unless @state_code && StateMapping::STATES.key?(@state_code)
166
+
167
+ case format_type
168
+ when :standard
169
+ validate_standard_format
170
+ when :federal_12
171
+ validate_federal_12_format
172
+ when :federal_13
173
+ validate_federal_13_format
174
+ else
175
+ false
176
+ end
177
+ end
178
+
179
+ def validate_standard_format
180
+ config = StateMapping::STATES[@state_code]
181
+ normalized_input.match?(config[:standard_pattern])
182
+ end
183
+
184
+ def validate_federal_12_format
185
+ return false unless normalized_input.length == 12
186
+ return false unless normalized_input.match?(/^\d{12}$/)
187
+
188
+ config = StateMapping::STATES[@state_code]
189
+ normalized_input.start_with?(config[:federal_12_prefix])
190
+ end
191
+
192
+ def validate_federal_13_format
193
+ return false unless normalized_input.length == 13
194
+ return false unless normalized_input.match?(/^\d{13}$/)
195
+
196
+ config = StateMapping::STATES[@state_code]
197
+ normalized_input.start_with?(config[:federal_13_prefix])
198
+ end
199
+
200
+ def convert_standard_to_federal_12
201
+ config = StateMapping::STATES[@state_code]
202
+
203
+ match = normalized_input.match(config[:standard_pattern])
204
+ return unless match
205
+
206
+ parts = match.captures
207
+
208
+ case @state_code
209
+ when 'NW' # Nordrhein-Westfalen has different structure (FFF/BBBB/UUUP)
210
+ fff, bbbb, uuup = parts
211
+ "#{config[:federal_12_prefix]}#{fff}#{bbbb}#{uuup}"
212
+ when 'HE' # Hessen (has leading zero in standard format)
213
+ if parts.length == 3
214
+ ff_or_fff, bbb, uuuup = parts
215
+ # Remove leading zero from Finanzamt code for federal format
216
+ finanzamt = ff_or_fff.sub(/^0/, '')
217
+ "#{config[:federal_12_prefix]}#{finanzamt}#{bbb}#{uuuup}"
218
+ end
219
+ else
220
+ if parts.length == 3
221
+ ff_or_fff, bbb, uuuup = parts
222
+ "#{config[:federal_12_prefix]}#{ff_or_fff}#{bbb}#{uuuup}"
223
+ end
224
+ end
225
+ end
226
+
227
+ def convert_standard_to_federal_13
228
+ federal_12 = convert_standard_to_federal_12
229
+ return unless federal_12
230
+
231
+ config = StateMapping::STATES[@state_code]
232
+ insert_position = config[:federal_13_zero_position]
233
+
234
+ result = federal_12.dup
235
+ result.insert(insert_position, '0')
236
+ result
237
+ end
238
+
239
+ def convert_federal_12_to_federal_13
240
+ config = StateMapping::STATES[@state_code]
241
+ insert_position = config[:federal_13_zero_position]
242
+
243
+ result = normalized_input.dup
244
+ result.insert(insert_position, '0')
245
+ result
246
+ end
247
+
248
+ def convert_federal_13_to_federal_12
249
+ config = StateMapping::STATES[@state_code]
250
+ remove_position = config[:federal_13_zero_position]
251
+
252
+ result = normalized_input.dup
253
+ result.slice!(remove_position)
254
+ result
255
+ end
256
+
257
+ def convert_federal_12_to_standard
258
+ federal_12_to_standard_format(normalized_input)
259
+ end
260
+
261
+ def convert_federal_13_to_standard
262
+ federal_12 = convert_federal_13_to_federal_12
263
+ return unless federal_12
264
+
265
+ federal_12_to_standard_format(federal_12)
266
+ end
267
+
268
+ def federal_12_to_standard_format(federal_12_string)
269
+ config = StateMapping::STATES[@state_code]
270
+ prefix = config[:federal_12_prefix]
271
+
272
+ # Remove the prefix
273
+ without_prefix = federal_12_string[prefix.length..-1]
274
+
275
+ case @state_code
276
+ when 'NW' # Nordrhein-Westfalen (FFF/BBBB/UUUP)
277
+ fff = without_prefix[0, 3]
278
+ bbbb = without_prefix[3, 4]
279
+ uuup = without_prefix[7, 4]
280
+ "#{fff}/#{bbbb}/#{uuup}"
281
+ when 'HE' # Hessen (has leading 0 in standard format)
282
+ ff = without_prefix[0, 2]
283
+ bbb = without_prefix[2, 3]
284
+ uuuup = without_prefix[5, 5]
285
+ "0#{ff}/#{bbb}/#{uuuup}"
286
+ else
287
+ if prefix.length == 1 # Single digit prefix states
288
+ fff = without_prefix[0, 3]
289
+ bbb = without_prefix[3, 3]
290
+ uuuup = without_prefix[6, 5]
291
+ "#{fff}/#{bbb}/#{uuuup}"
292
+ else # Two digit prefix states
293
+ ff = without_prefix[0, 2]
294
+ bbb = without_prefix[2, 3]
295
+ uuuup = without_prefix[5, 5]
296
+ "#{ff}/#{bbb}/#{uuuup}"
297
+ end
298
+ end
299
+ end
300
+
301
+ alias_method :to_elster, :to_federal_13
302
+ end
303
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Steuer
4
+ VERSION = '1.0.0'
5
+ end
data/lib/steuer.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'steuer/version'
4
+ require_relative 'steuer/steuernummer'
5
+ require_relative 'steuer/errors'
6
+
7
+ module Steuer
8
+ class << self
9
+ def steuernummer(tax_number, state: nil)
10
+ Steuernummer.new(tax_number, state: state)
11
+ end
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,163 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: steuer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0.pre.alpha
5
+ platform: ruby
6
+ authors:
7
+ - Olumuyiwa Osiname
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-08-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: pry
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.15.1
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.15.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.56.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.56.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop-factory_bot
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 2.24.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 2.24.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop-performance
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 1.19.0
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 1.19.0
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop-rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 2.24.0
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 2.24.0
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-shopify
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '2.14'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '2.14'
125
+ description: A Ruby gem for German tax system utilities including Steuernummer conversion
126
+ between formats, VAT validation, and other tax-related functionality
127
+ email:
128
+ - oluosiname@gmail.com
129
+ executables: []
130
+ extensions: []
131
+ extra_rdoc_files: []
132
+ files:
133
+ - LICENSE
134
+ - README.md
135
+ - lib/steuer.rb
136
+ - lib/steuer/errors.rb
137
+ - lib/steuer/state_mapping.rb
138
+ - lib/steuer/steuernummer.rb
139
+ - lib/steuer/version.rb
140
+ homepage: https://github.com/oluosiname/steuer
141
+ licenses:
142
+ - MIT
143
+ metadata: {}
144
+ post_install_message:
145
+ rdoc_options: []
146
+ require_paths:
147
+ - lib
148
+ required_ruby_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: 3.3.0
153
+ required_rubygems_version: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: '0'
158
+ requirements: []
159
+ rubygems_version: 3.5.3
160
+ signing_key:
161
+ specification_version: 4
162
+ summary: German tax system utilities - tax numbers, VAT validation, and more
163
+ test_files: []