steuer 1.0.4 → 1.2.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.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Steuer
6
+ # Lookup of Bundesfinanzamtsnummer (BUFA-Nr, the four-digit prefix of the
7
+ # federal-13 form) to the office's name, backed by the BZSt BUFA-Tabelle
8
+ # bundled in data/finanzaemter.yml.
9
+ #
10
+ # Unlike the structural data in StateMapping, these names go stale: offices
11
+ # merge and get renamed several times a year. Lookups therefore return nil
12
+ # rather than raising, so a number issued after the bundled table's revision
13
+ # still converts -- it just has no name yet.
14
+ module FinanzamtRegistry
15
+ DATA_PATH = File.expand_path('data/finanzaemter.yml', __dir__)
16
+
17
+ class << self
18
+ def name_for(code)
19
+ entries[code.to_s]&.fetch(:name, nil)
20
+ end
21
+
22
+ def state_for(code)
23
+ entries[code.to_s]&.fetch(:state, nil)
24
+ end
25
+
26
+ def known?(code)
27
+ entries.key?(code.to_s)
28
+ end
29
+
30
+ def revision
31
+ data[:revision]
32
+ end
33
+
34
+ def entries
35
+ @entries ||= data[:finanzaemter].to_h do |code, attrs|
36
+ [code.to_s, { name: attrs['name'], state: attrs['state'] }.freeze]
37
+ end.freeze
38
+ end
39
+
40
+ private
41
+
42
+ def data
43
+ @data ||= begin
44
+ loaded = YAML.safe_load_file(DATA_PATH)
45
+ { revision: loaded['revision'], finanzaemter: loaded['finanzaemter'] || {} }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -119,6 +119,66 @@ module Steuer
119
119
  },
120
120
  }.freeze
121
121
  class << self
122
+ def standard_group_sizes(state_code)
123
+ config = STATES[state_code]
124
+ return unless config
125
+
126
+ sizes = config[:standard_pattern].source.delete_prefix('^').delete_suffix('$')
127
+ .split('/')
128
+ .map { |group| standard_group_size(group) }
129
+
130
+ return if sizes.any?(&:nil?) || sizes.empty?
131
+
132
+ sizes
133
+ end
134
+
135
+ # Ranges such as Hessen's 0(1[3-9]) and Saarland's (01[0-2]) stand in
136
+ # for single digits, so groups are measured rather than read off \d{n}.
137
+ def standard_group_size(group)
138
+ size = 0
139
+ scanner = group.dup
140
+ until scanner.empty?
141
+ case scanner
142
+ when /\A\\d\{(\d+)\}/
143
+ size += Regexp.last_match(1).to_i
144
+ scanner = scanner.sub(/\A\\d\{\d+\}/, '')
145
+ when /\A\[[^\]]*\]/
146
+ size += 1
147
+ scanner = scanner.sub(/\A\[[^\]]*\]/, '')
148
+ when /\A\\d/
149
+ size += 1
150
+ scanner = scanner.delete_prefix('\\d')
151
+ when /\A\d/
152
+ size += 1
153
+ scanner = scanner[1..]
154
+ when /\A[()]/
155
+ scanner = scanner[1..]
156
+ else
157
+ return
158
+ end
159
+ end
160
+ size
161
+ end
162
+
163
+ # Separators are presentation only: Baden-Württemberg prints 93815/08152
164
+ # for the ten digits the BZSt Standardschema groups as 93/815/08152.
165
+ def canonicalize_standard_format(tax_number, state_code)
166
+ sizes = standard_group_sizes(state_code)
167
+ return unless sizes
168
+
169
+ digits = tax_number.gsub(/\D/, '')
170
+ return unless digits.length == sizes.sum
171
+
172
+ offset = 0
173
+ groups = sizes.map do |size|
174
+ part = digits[offset, size]
175
+ offset += size
176
+ part
177
+ end
178
+
179
+ groups.join('/')
180
+ end
181
+
122
182
  def find_state_by_standard_format(tax_number)
123
183
  # Try more specific patterns first to avoid conflicts with general patterns
124
184
  # Priority: character classes with ranges > specific digit patterns > general digit patterns
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'state_mapping'
4
+ require_relative 'finanzamt_registry'
4
5
 
5
6
  module Steuer
6
7
  class Steuernummer
@@ -8,7 +9,8 @@ module Steuer
8
9
 
9
10
  def initialize(tax_number, state: nil)
10
11
  @original_input = tax_number.to_s.strip
11
- @state_code = normalize_state_code(state) || auto_detect_state
12
+ @explicit_state_code = normalize_state_code(state)
13
+ @state_code = @explicit_state_code || auto_detect_state
12
14
 
13
15
  validate_tax_number!
14
16
  end
@@ -61,7 +63,7 @@ module Steuer
61
63
 
62
64
  case format_type
63
65
  when :standard
64
- @original_input
66
+ canonical_standard_input || @original_input
65
67
  when :federal_12
66
68
  convert_federal_12_to_standard
67
69
  when :federal_13
@@ -75,6 +77,19 @@ module Steuer
75
77
  StateMapping::STATES[@state_code][:name]
76
78
  end
77
79
 
80
+ # The BUFA-Nr: the leading four digits of the federal-13 form, identifying
81
+ # the issuing Finanzamt (the Empfaenger in an ELSTER transmission).
82
+ def finanzamt_code
83
+ to_federal_13&.slice(0, 4)
84
+ end
85
+
86
+ def finanzamt_name
87
+ code = finanzamt_code
88
+ return unless code
89
+
90
+ FinanzamtRegistry.name_for(code)
91
+ end
92
+
78
93
  def format_type
79
94
  @format_type ||= if normalized_input.include?('/')
80
95
  :standard
@@ -82,6 +97,8 @@ module Steuer
82
97
  :federal_12
83
98
  elsif normalized_input.length == 13 && normalized_input.match?(/^\d{13}$/)
84
99
  :federal_13
100
+ elsif unseparated_standard?
101
+ :standard
85
102
  end
86
103
 
87
104
  @format_type
@@ -178,7 +195,24 @@ module Steuer
178
195
 
179
196
  def validate_standard_format
180
197
  config = StateMapping::STATES[@state_code]
181
- normalized_input.match?(config[:standard_pattern])
198
+ return true if normalized_input.match?(config[:standard_pattern])
199
+
200
+ canonical = canonical_standard_input
201
+ !canonical.nil? && canonical.match?(config[:standard_pattern])
202
+ end
203
+
204
+ def unseparated_standard?
205
+ return false unless @explicit_state_code
206
+ return false unless normalized_input.match?(/\A\d+\z/)
207
+
208
+ sizes = StateMapping.standard_group_sizes(@explicit_state_code)
209
+ !sizes.nil? && normalized_input.length == sizes.sum
210
+ end
211
+
212
+ def canonical_standard_input
213
+ return unless @state_code
214
+
215
+ StateMapping.canonicalize_standard_format(normalized_input, @state_code)
182
216
  end
183
217
 
184
218
  def validate_federal_12_format
@@ -200,7 +234,8 @@ module Steuer
200
234
  def convert_standard_to_federal_12
201
235
  config = StateMapping::STATES[@state_code]
202
236
 
203
- match = normalized_input.match(config[:standard_pattern])
237
+ match = normalized_input.match(config[:standard_pattern]) ||
238
+ canonical_standard_input&.match(config[:standard_pattern])
204
239
  return unless match
205
240
 
206
241
  parts = match.captures
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Steuer
4
- VERSION = '1.0.4'
4
+ VERSION = '1.2.0'
5
5
  end
data/lib/steuer.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'steuer/version'
4
4
  require_relative 'steuer/steuernummer'
5
+ require_relative 'steuer/finanzamt_registry'
5
6
  require_relative 'steuer/errors'
6
7
 
7
8
  module Steuer
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: steuer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.4
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Olumuyiwa Osiname
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-23 00:00:00.000000000 Z
11
+ date: 2026-09-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pry
@@ -133,7 +133,9 @@ files:
133
133
  - LICENSE
134
134
  - README.md
135
135
  - lib/steuer.rb
136
+ - lib/steuer/data/finanzaemter.yml
136
137
  - lib/steuer/errors.rb
138
+ - lib/steuer/finanzamt_registry.rb
137
139
  - lib/steuer/state_mapping.rb
138
140
  - lib/steuer/steuernummer.rb
139
141
  - lib/steuer/version.rb