sie-analysis 0.1.1

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,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module SieAnalysis
6
+ # Parses financial export types 2–4 into balances and (for type 4) voucher lines.
7
+ class Parser
8
+ Record = Data.define(:tag, :fields)
9
+ Voucher = Data.define(:series, :number, :date, :description, :lines)
10
+ Line = Data.define(:account, :amount)
11
+
12
+ # Parsed ledger: monetary values are BigDecimal; voucher dates are Date.
13
+ # Period keys are YYYYMM strings; object keys are [dimension number, code].
14
+ Parsed = Data.define(
15
+ :accounts, :objects, :opening_balances, :closing_balances, :results, :period_changes,
16
+ :financial_years, :vouchers, :budgets, :sie_type
17
+ )
18
+
19
+ # @param text [String] export text already decoded to UTF-8
20
+ # @return [Parsed] recognized balances, objects, budgets and vouchers
21
+ # @raise [ParseError] invalid export type, financial year or recognized record
22
+ def self.parse(text)
23
+ new(text).parse
24
+ end
25
+
26
+ # @param text [String] decoded SIE 2, 3 or 4 text
27
+ def initialize(text)
28
+ @text = text.to_s
29
+ @accounts = {}
30
+ @objects = {}
31
+ @opening = Hash.new(BigDecimal("0"))
32
+ @closing = Hash.new(BigDecimal("0"))
33
+ @results = Hash.new(BigDecimal("0"))
34
+ @periods = Hash.new { |hash, key| hash[key] = Hash.new(BigDecimal("0")) }
35
+ @budgets = Hash.new { |hash, key| hash[key] = Hash.new(BigDecimal("0")) }
36
+ @years = {}
37
+ @vouchers = []
38
+ @current_voucher = nil
39
+ end
40
+
41
+ # Consumes this parser once. Use .parse for each separate export.
42
+ # @return [Parsed] parsed ledger
43
+ # @raise [ParseError] unreadable or invalid recognized records
44
+ def parse
45
+ each_record { |record| apply_record(record) }
46
+ finish_voucher!
47
+ raise ArgumentError unless [2, 3, 4].include?(@sie_type) && @years.key?(0)
48
+ @periods.default_proc = nil
49
+ @budgets.default_proc = nil
50
+ Parsed.new(
51
+ accounts: @accounts, objects: @objects, opening_balances: @opening, closing_balances: @closing,
52
+ results: @results, period_changes: @periods, financial_years: @years, vouchers: @vouchers,
53
+ budgets: @budgets, sie_type: @sie_type
54
+ )
55
+ rescue ParseError
56
+ raise
57
+ rescue StandardError
58
+ raise ParseError, "Unreadable financial export"
59
+ end
60
+
61
+ private
62
+
63
+ def each_record
64
+ @text.each_line do |line|
65
+ stripped = line.strip
66
+ next if stripped.empty? || !stripped.start_with?("#")
67
+
68
+ tokens = SieTokenizer.tokenize(stripped[1..])
69
+ next if tokens.empty?
70
+
71
+ yield Record.new(tag: tokens[0], fields: tokens[1..])
72
+ end
73
+ end
74
+
75
+ def apply_record(record)
76
+ case record.tag
77
+ when "SIETYP"
78
+ raise ArgumentError if @sie_type
79
+ @sie_type = Integer(record.fields.fetch(0))
80
+ when "KONTO" then apply_account(record.fields)
81
+ when "OBJEKT" then apply_object(record.fields)
82
+ when "RAR" then apply_year(record.fields)
83
+ when "IB" then apply_balance!(@opening, record.fields)
84
+ when "UB" then apply_balance!(@closing, record.fields)
85
+ when "RES" then apply_balance!(@results, record.fields)
86
+ when "PSALDO" then apply_period!(record.fields)
87
+ when "PBUDGET" then apply_budget!(record.fields)
88
+ when "VER" then start_voucher!(record.fields)
89
+ when "TRANS" then apply_trans!(record.fields)
90
+ end
91
+ end
92
+
93
+ def apply_account(fields)
94
+ number = integer_field(fields[0])
95
+ @accounts[number] = unquote(fields[1]).to_s if number
96
+ end
97
+
98
+ def apply_object(fields)
99
+ dim = integer_field(fields[0])
100
+ code = unquote(fields[1]).to_s
101
+ name = unquote(fields[2]).to_s
102
+ @objects[[dim, code]] = name if dim && !code.empty?
103
+ end
104
+
105
+ def apply_year(fields)
106
+ year_no = integer_field(fields[0])
107
+ first, last = sie_date(fields[1]), sie_date(fields[2])
108
+ raise ArgumentError unless year_no && first && last && first <= last
109
+ raise ArgumentError if @years.key?(year_no)
110
+ @years[year_no] = { from: unquote(fields[1]), to: unquote(fields[2]) }
111
+ end
112
+
113
+ def apply_balance!(target, fields)
114
+ year = integer_field(fields[0])
115
+ raise ArgumentError unless year
116
+ return unless year.zero?
117
+
118
+ account = integer_field(fields[1])
119
+ amount = decimal_field(fields[2])
120
+ raise ArgumentError unless account&.positive? && amount&.finite?
121
+
122
+ target[account] += amount
123
+ end
124
+
125
+ def apply_period!(fields, target = @periods)
126
+ year = integer_field(fields[0])
127
+ raise ArgumentError unless year
128
+ return unless year.zero?
129
+
130
+ period = fields[1].to_s
131
+ account = integer_field(fields[2])
132
+ object_field = fields[3].to_s
133
+ amount = object_field.start_with?("{") ? decimal_field(fields[4]) : decimal_field(fields[3])
134
+ raise ArgumentError unless period.match?(/\A\d{4}(0[1-9]|1[0-2])\z/) && account&.positive? && amount&.finite?
135
+ raise ArgumentError if object_field.start_with?("{") && !valid_budget_objects?(object_field)
136
+
137
+ refs = object_refs_from(object_field)
138
+ if refs.empty?
139
+ target[period][account] += amount
140
+ else
141
+ refs.each { |dim, code| target[period][[account, dim, code]] += amount }
142
+ end
143
+ end
144
+
145
+ def apply_budget!(fields)
146
+ year = integer_field(fields[0])
147
+ raise ArgumentError unless year
148
+ return unless year.zero?
149
+
150
+ period, account, objects, amount = fields.values_at(1, 2, 3, 4)
151
+ raise ArgumentError unless period.to_s.match?(/\A\d{4}(0[1-9]|1[0-2])\z/)
152
+ raise ArgumentError unless integer_field(account)&.positive? && decimal_field(amount)&.finite?
153
+ raise ArgumentError unless valid_budget_objects?(objects)
154
+
155
+ apply_period!(fields, @budgets)
156
+ end
157
+
158
+ def valid_budget_objects?(objects)
159
+ return false unless objects.to_s.start_with?("{") && objects.end_with?("}")
160
+
161
+ parts = SieTokenizer.tokenize(objects[1...-1])
162
+ parts.length.even? && parts.each_slice(2).all? do |dim, code|
163
+ integer_field(dim)&.positive? && !unquote(code).empty?
164
+ end
165
+ end
166
+
167
+ def start_voucher!(fields)
168
+ finish_voucher!
169
+ date = sie_date(fields[2])
170
+ number = integer_field(fields[1])
171
+ raise ArgumentError unless date && number
172
+
173
+ @current_voucher = {
174
+ series: unquote(fields[0]).to_s,
175
+ number:,
176
+ date:,
177
+ description: unquote(fields[3]).to_s,
178
+ lines: []
179
+ }
180
+ end
181
+
182
+ def apply_trans!(fields)
183
+ raise ArgumentError unless @current_voucher
184
+
185
+ account = integer_field(fields[0])
186
+ object_field = fields[1].to_s
187
+ amount = object_field.start_with?("{") ? decimal_field(fields[2]) : decimal_field(fields[1])
188
+ raise ArgumentError unless account&.positive? && amount&.finite?
189
+
190
+ raise ArgumentError if object_field.start_with?("{") && !valid_budget_objects?(object_field)
191
+
192
+ @current_voucher[:lines] << Line.new(account:, amount:)
193
+ end
194
+
195
+ def finish_voucher!
196
+ voucher = @current_voucher
197
+ @current_voucher = nil
198
+ return unless voucher && voucher[:lines].any?
199
+
200
+ @vouchers << Voucher.new(
201
+ series: voucher[:series], number: voucher[:number], date: voucher[:date],
202
+ description: voucher[:description], lines: voucher[:lines]
203
+ )
204
+ end
205
+
206
+ def object_refs_from(field)
207
+ text = field.to_s
208
+ return [] unless text.start_with?("{") && text.end_with?("}")
209
+
210
+ inner = text[1...-1].strip
211
+ return [] if inner.empty?
212
+
213
+ parts = SieTokenizer.tokenize(inner)
214
+ parts.each_slice(2).filter_map do |dim_token, code_token|
215
+ dim = integer_field(dim_token)
216
+ code = unquote(code_token).to_s
217
+ [dim, code] if dim && !code.empty?
218
+ end
219
+ end
220
+
221
+ def sie_date(value)
222
+ text = unquote(value).to_s
223
+ return unless text.match?(/\A\d{8}\z/)
224
+
225
+ Date.strptime(text, "%Y%m%d")
226
+ rescue Date::Error
227
+ nil
228
+ end
229
+
230
+ def integer_field(value)
231
+ Integer(unquote(value))
232
+ rescue TypeError, ArgumentError
233
+ nil
234
+ end
235
+
236
+ def decimal_field(value)
237
+ text = unquote(value).tr(",", ".")
238
+ return unless text.match?(/\A[+-]?\d+(?:\.\d+)?\z/)
239
+ BigDecimal(text)
240
+ rescue TypeError, ArgumentError
241
+ nil
242
+ end
243
+
244
+ def unquote(value)
245
+ text = value.to_s
246
+ return text[1...-1] if text.start_with?('"') && text.end_with?('"') && text.length >= 2
247
+
248
+ text
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SieAnalysis
4
+ # Tokenizes one SIE record line, keeping quoted strings and {object lists} intact.
5
+ class SieTokenizer
6
+ def self.tokenize(line)
7
+ new(line).tokens
8
+ end
9
+
10
+ def initialize(line)
11
+ @line = line
12
+ @tokens = []
13
+ @buffer = +""
14
+ @in_quotes = false
15
+ @brace_depth = 0
16
+ @index = 0
17
+ end
18
+
19
+ def tokens
20
+ consume while @index < @line.length
21
+ flush_buffer
22
+ @tokens
23
+ end
24
+
25
+ private
26
+
27
+ def consume
28
+ return append_escaped if escaped?
29
+ return toggle_quote if current == '"'
30
+ return handle_structure if !@in_quotes && structural?
31
+
32
+ @buffer << current
33
+ @index += 1
34
+ end
35
+
36
+ def current
37
+ @line[@index]
38
+ end
39
+
40
+ def escaped?
41
+ @in_quotes && current == "\\" && @index + 1 < @line.length
42
+ end
43
+
44
+ def structural?
45
+ current == "{" || (current == "}" && @brace_depth.positive?) ||
46
+ (@brace_depth.zero? && current.match?(/\s/))
47
+ end
48
+
49
+ def handle_structure
50
+ return open_brace if current == "{"
51
+ return close_brace if current == "}"
52
+
53
+ split_token
54
+ end
55
+
56
+ def append_escaped
57
+ @buffer << @line[@index + 1]
58
+ @index += 2
59
+ end
60
+
61
+ def toggle_quote
62
+ if @in_quotes
63
+ close_quote
64
+ else
65
+ @in_quotes = true
66
+ @buffer << '"' if @brace_depth.positive?
67
+ end
68
+ @index += 1
69
+ end
70
+
71
+ def close_quote
72
+ @in_quotes = false
73
+ if @brace_depth.positive?
74
+ @buffer << '"'
75
+ elsif @buffer.empty?
76
+ @tokens << ""
77
+ end
78
+ end
79
+
80
+ def open_brace
81
+ @brace_depth += 1
82
+ @buffer << "{"
83
+ @index += 1
84
+ end
85
+
86
+ def close_brace
87
+ @brace_depth -= 1
88
+ @buffer << "}"
89
+ flush_buffer if @brace_depth.zero?
90
+ @index += 1
91
+ end
92
+
93
+ def split_token
94
+ flush_buffer
95
+ @index += 1
96
+ end
97
+
98
+ def flush_buffer
99
+ return if @buffer.empty?
100
+
101
+ @tokens << @buffer
102
+ @buffer = +""
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SieAnalysis
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require_relative "sie_analysis/version"
5
+
6
+ # Analysis of Swedish SIE financial exports.
7
+ module SieAnalysis
8
+ # A malformed or unsupported SIE export.
9
+ class ParseError < StandardError; end
10
+ end
11
+
12
+ require_relative "sie_analysis/groups"
13
+ require_relative "sie_analysis/sie_tokenizer"
14
+ require_relative "sie_analysis/parser"
15
+ require_relative "sie_analysis/amounts"
16
+ require_relative "sie_analysis/budgets"
17
+ require_relative "sie_analysis/income_statements"
18
+ require_relative "sie_analysis/dimension_statements"
19
+ require_relative "sie_analysis/balance_sheets"
20
+ require_relative "sie_analysis/analysis"
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sie-analysis
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Standout AB
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bigdecimal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5'
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - README.md
37
+ - assets/header-vector.svg
38
+ - lib/sie_analysis.rb
39
+ - lib/sie_analysis/amounts.rb
40
+ - lib/sie_analysis/analysis.rb
41
+ - lib/sie_analysis/balance_sheets.rb
42
+ - lib/sie_analysis/budgets.rb
43
+ - lib/sie_analysis/dimension_statements.rb
44
+ - lib/sie_analysis/groups.rb
45
+ - lib/sie_analysis/income_statements.rb
46
+ - lib/sie_analysis/parser.rb
47
+ - lib/sie_analysis/sie_tokenizer.rb
48
+ - lib/sie_analysis/version.rb
49
+ homepage: https://github.com/standout/sie-analysis
50
+ licenses: []
51
+ metadata:
52
+ source_code_uri: https://github.com/standout/sie-analysis
53
+ allowed_push_host: https://rubygems.org
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '3.2'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 4.0.20
69
+ specification_version: 4
70
+ summary: Income statements, account movements and budget analysis for SIE exports
71
+ test_files: []