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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SieAnalysis
4
+ # Shared ledger amount presentation helpers for Analysis.
5
+ module Amounts
6
+ private
7
+
8
+ # Period keys are either account or [account, dimension_no, object_code].
9
+ def dimension_parts(key)
10
+ case key
11
+ when Array
12
+ account, dim, code = key
13
+ [account, dim, code]
14
+ else
15
+ [key, nil, nil]
16
+ end
17
+ end
18
+
19
+ def income_group?(group)
20
+ group && Groups::INCOME.any? { |item| item.equal?(group) }
21
+ end
22
+
23
+ def present_amount(raw, kind)
24
+ kind == :revenue ? -raw : raw
25
+ end
26
+
27
+ def present_balance(raw, kind)
28
+ kind == :equity_liability ? -raw : raw
29
+ end
30
+
31
+ def round_money(value)
32
+ BigDecimal(value.to_s).round(2, BigDecimal::ROUND_HALF_UP)
33
+ end
34
+
35
+ def present_ledger_amount(raw, account_number)
36
+ group = Groups.for_account(account_number)
37
+ return round_money(raw) unless group
38
+
39
+ presented = if income_group?(group)
40
+ present_amount(raw, group.fetch(:kind))
41
+ else
42
+ present_balance(raw, group.fetch(:kind))
43
+ end
44
+ round_money(presented)
45
+ end
46
+
47
+ def raw_period_amount(account, month_hash)
48
+ month_hash.fetch(account, BigDecimal("0"))
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module SieAnalysis
6
+ # Reports for one parsed financial year. Monetary values are BigDecimal in SEK.
7
+ # Income and monthly summaries use period balances; daily summaries use vouchers.
8
+ class Analysis
9
+ include Amounts
10
+ include IncomeStatements
11
+ include DimensionStatements
12
+ include BalanceSheets
13
+ include Budgets
14
+
15
+ CURRENCY = "SEK"
16
+ DIMENSIONS = {
17
+ cost_center: 1,
18
+ project: 6
19
+ }.freeze
20
+
21
+ # @param document [Parser::Parsed] a decoded and parsed SIE export
22
+ def initialize(document)
23
+ @parsed = document
24
+ end
25
+
26
+ # Summarizes all calendar months touched by the inclusive date range.
27
+ # @param from [Date] first date
28
+ # @param to [Date] last date
29
+ # @param dimension [Symbol, nil] :project or :cost_center
30
+ # @return [Hash] income statement, balance sheet, budget, monthly totals and notes
31
+ def overview(from:, to:, dimension: nil)
32
+ start_date, end_date = from, to
33
+ validate_period!(start_date, end_date)
34
+ months = month_keys(start_date, end_date)
35
+ notes = overview_notes(start_date, end_date)
36
+ dim = require_dimension!(dimension, available_dimensions)
37
+ income = income_statement(months, dim)
38
+ balance = balance_sheet(end_date, months, dim)
39
+ soft_balance_note!(balance, income, notes)
40
+ notes << "Balance sheet totals are omitted when grouping by #{dim}." if dim
41
+
42
+ {
43
+ period: { from: start_date, to: end_date },
44
+ currency: CURRENCY,
45
+ granularity: "monthly",
46
+ available_dimensions: available_dimensions,
47
+ income_statement: income,
48
+ budget: budget_overview(months, dim),
49
+ balance_sheet: balance,
50
+ highlights: highlights(income, balance),
51
+ notes:,
52
+ monthly_totals: monthly_totals(months, dim)
53
+ }
54
+ end
55
+
56
+ # Lists non-zero monthly movements, optionally filtered by BAS group or object.
57
+ # @param month [String] YYYY-MM
58
+ # @param group_code [String, nil] report group, e.g. "revenue"
59
+ # @param account_number [Integer, nil] BAS account number
60
+ # @param dimension [Symbol, nil] :project or :cost_center
61
+ # @param dimension_code [String, nil] object code; requires dimension
62
+ # @return [Array<Hash>] account_number, account_name, movement and group/object fields
63
+ def account_movements(month:, group_code: nil, account_number: nil, dimension: nil, dimension_code: nil)
64
+ month_label = normalize_year_month(month)
65
+ dim = require_dimension!(dimension, available_dimensions)
66
+ raise ArgumentError, "dimension_code requires dimension" if dimension_code && !dim
67
+ accounts = account_rows_for_month(month_label.delete("-"), dim)
68
+ accounts.select! { |row| row.fetch(:group_code) == group_code } if group_code
69
+ accounts.select! { |row| row.fetch(:account_number) == Integer(account_number) } if account_number
70
+ accounts.select! { |row| row.fetch(:dimension_code) == dimension_code } if dimension_code
71
+ accounts
72
+ end
73
+
74
+ # Returns opening balance, movement and closing balance for exactly one period.
75
+ # @param account_number [Integer] BAS account number
76
+ # @param month [String, nil] YYYY-MM; mutually exclusive with on
77
+ # @param on [Date, nil] a single day; requires SIE 4
78
+ # @return [Hash] account fields, Date period boundaries and BigDecimal balances
79
+ def account_summary(account_number, month: nil, on: nil)
80
+ number = Integer(account_number)
81
+ raise ArgumentError, "Provide month or on, not both" if on && month
82
+ return day_account_summary(number, date_argument!(on)) if on
83
+ raise ArgumentError, "month or on is required" unless month
84
+
85
+ month_account_summary(number, month)
86
+ end
87
+
88
+ # Lists vouchers with a non-zero net movement on the account; requires SIE 4.
89
+ # @param account_number [Integer] BAS account number
90
+ # @param from [Date] inclusive first voucher date
91
+ # @param to [Date] inclusive last voucher date
92
+ # @return [Array<Hash>] series, number, Date, description and BigDecimal movement
93
+ def vouchers_on_account(account_number, from:, to:)
94
+ start_day, end_day = from, to
95
+ require_transactions!
96
+ validate_period!(start_day, end_day)
97
+ number = Integer(account_number)
98
+ @parsed.vouchers.filter_map do |voucher|
99
+ next unless voucher.date.between?(start_day, end_day)
100
+
101
+ raw = voucher.lines.sum { |line| line.account == number ? line.amount : BigDecimal("0") }
102
+ next if raw.zero?
103
+
104
+ {
105
+ voucher_series: voucher.series,
106
+ voucher_number: voucher.number,
107
+ date: voucher.date,
108
+ description: voucher.description,
109
+ movement: present_ledger_amount(raw, number)
110
+ }
111
+ end
112
+ end
113
+
114
+ # @return [Array<Symbol>] supported dimensions with objects declared in the export
115
+ def available_dimensions
116
+ dims = []
117
+ dims << :cost_center if @parsed.objects.keys.any? { |dim, _code| dim == 1 }
118
+ dims << :project if @parsed.objects.keys.any? { |dim, _code| dim == 6 }
119
+ dims
120
+ end
121
+
122
+ private
123
+
124
+ def date_argument!(date)
125
+ raise ArgumentError, "Expected a Date" unless date.instance_of?(Date)
126
+ date
127
+ end
128
+
129
+ def validate_period!(first, last)
130
+ date_argument!(first)
131
+ date_argument!(last)
132
+ raise ArgumentError, "Start date must not be after end date" if first > last
133
+ end
134
+
135
+ def require_transactions!
136
+ raise ArgumentError, "This analysis requires a SIE 4 export" unless @parsed.sie_type == 4
137
+ end
138
+
139
+ def overview_notes(start_date, end_date)
140
+ notes = ["Amounts use monthly period balances, not day-level bookkeeping."]
141
+ return notes if whole_months?(start_date, end_date)
142
+
143
+ notes << "The requested dates are not full calendar months; monthly balances still cover complete months."
144
+ notes
145
+ end
146
+
147
+ def month_account_summary(number, year_month)
148
+ month_label = normalize_year_month(year_month)
149
+ start_date = Date.strptime("#{month_label}-01", "%Y-%m-%d")
150
+ end_date = Date.new(start_date.year, start_date.month, -1)
151
+ month_key = month_label.delete("-")
152
+ raw_opening = raw_balance_before_month(number, month_key)
153
+ raw_period = raw_period_amount(number, @parsed.period_changes[month_key] || {})
154
+ build_account_summary(number, start_date, end_date, raw_opening, raw_period)
155
+ end
156
+
157
+ def day_account_summary(number, day)
158
+ require_transactions!
159
+ month_start = Date.new(day.year, day.month, 1)
160
+ raw_opening = raw_balance_before_month(number, day.strftime("%Y%m"))
161
+ raw_opening += raw_voucher_amount(number, month_start, day.prev_day) if day.day > 1
162
+ raw_period = raw_voucher_amount(number, day, day)
163
+ build_account_summary(number, day, day, raw_opening, raw_period)
164
+ end
165
+
166
+ def build_account_summary(number, start_date, end_date, raw_opening, raw_period)
167
+ {
168
+ account_number: number,
169
+ account_name: @parsed.accounts[number],
170
+ period: { from: start_date, to: end_date },
171
+ movement: present_ledger_amount(raw_period, number),
172
+ opening_balance: present_ledger_amount(raw_opening, number),
173
+ closing_balance: present_ledger_amount(raw_opening + raw_period, number)
174
+ }
175
+ end
176
+
177
+ def raw_balance_before_month(account, month_key)
178
+ opening = @parsed.opening_balances[account] || BigDecimal("0")
179
+ prior_months = @parsed.period_changes.keys.select { |key| key < month_key }
180
+ opening + prior_months.sum { |key| raw_period_amount(account, @parsed.period_changes[key] || {}) }
181
+ end
182
+
183
+ def raw_voucher_amount(account, start_day, end_day)
184
+ return BigDecimal("0") if end_day < start_day
185
+
186
+ @parsed.vouchers.sum do |voucher|
187
+ next BigDecimal("0") unless voucher.date.between?(start_day, end_day)
188
+
189
+ voucher.lines.sum { |line| line.account == account ? line.amount : BigDecimal("0") }
190
+ end
191
+ end
192
+
193
+ def require_dimension!(dimension, available)
194
+ dim = normalize_dimension(dimension)
195
+ return dim unless dim
196
+ return dim if available.include?(dim)
197
+
198
+ raise ArgumentError, "The financial export has no #{dim.to_s.tr('_', ' ')} objects"
199
+ end
200
+
201
+ def normalize_dimension(dimension)
202
+ return if dimension.nil?
203
+ raise ArgumentError, "Unsupported dimension" unless DIMENSIONS.key?(dimension)
204
+
205
+ dimension
206
+ end
207
+
208
+ def normalize_year_month(value)
209
+ text = value.to_s
210
+ raise ArgumentError, "month must be YYYY-MM" unless text.match?(/\A\d{4}-\d{2}\z/)
211
+
212
+ Date.strptime("#{text}-01", "%Y-%m-%d")
213
+ text
214
+ rescue Date::Error
215
+ raise ArgumentError, "month must be YYYY-MM"
216
+ end
217
+
218
+ def month_keys(start_date, end_date)
219
+ cursor = Date.new(start_date.year, start_date.month, 1)
220
+ last = Date.new(end_date.year, end_date.month, 1)
221
+ keys = []
222
+ while cursor <= last
223
+ keys << cursor.strftime("%Y%m")
224
+ cursor = cursor.next_month
225
+ end
226
+ keys
227
+ end
228
+
229
+ def whole_months?(start_date, end_date)
230
+ start_date.day == 1 && end_date == Date.new(end_date.year, end_date.month, -1)
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SieAnalysis
4
+ # Balance-sheet builders and closing-balance helpers for Analysis.
5
+ module BalanceSheets
6
+ private
7
+
8
+ def balance_sheet(end_date, months, dimension)
9
+ return dimension_balance_placeholder if dimension
10
+
11
+ groups = Groups::BALANCE.map { |group| balance_group_row(group, months) }
12
+ assets = group_amount(groups, "assets")
13
+ equity = group_amount(groups, "equity_and_liabilities")
14
+ {
15
+ as_of: end_date,
16
+ assets:,
17
+ equity_and_liabilities: equity,
18
+ cash_and_bank: round_money(cash_total(months)),
19
+ groups: groups.reject { |group| group.fetch(:amount).zero? && group.fetch(:accounts).empty? }
20
+ }
21
+ end
22
+
23
+ def group_amount(groups, code)
24
+ groups.find { |row| row[:code] == code }&.fetch(:amount) || BigDecimal("0")
25
+ end
26
+
27
+ def cash_total(months)
28
+ balance_accounts_in(Groups::CASH_RANGE).sum do |number|
29
+ present_balance(closing_balance_for(number, months), :asset)
30
+ end
31
+ end
32
+
33
+ def balance_group_row(group, months)
34
+ rows = balance_accounts_in(group.fetch(:range)).filter_map do |number|
35
+ presented = present_balance(closing_balance_for(number, months), group.fetch(:kind))
36
+ next if presented.zero?
37
+
38
+ { account_number: number, account_name: @parsed.accounts[number], amount: round_money(presented) }
39
+ end
40
+ {
41
+ code: group.fetch(:code),
42
+ label: group.fetch(:label),
43
+ amount: round_money(rows.sum { |row| row.fetch(:amount) }),
44
+ accounts: rows.sort_by { |row| row.fetch(:account_number) }
45
+ }
46
+ end
47
+
48
+ def dimension_balance_placeholder
49
+ { as_of: nil, assets: nil, equity_and_liabilities: nil, cash_and_bank: nil, groups: [] }
50
+ end
51
+
52
+ def soft_balance_note!(balance, income, notes)
53
+ return if balance[:assets].nil? || balance[:equity_and_liabilities].nil?
54
+
55
+ gap = (balance.fetch(:assets) - balance.fetch(:equity_and_liabilities) - income.fetch(:result)).abs
56
+ return if gap < BigDecimal("0.05")
57
+
58
+ notes << "Balance sheet assets and equity/liabilities differ after including period result; common mid-year."
59
+ end
60
+
61
+ def closing_balance_for(account, months)
62
+ if @parsed.closing_balances.key?(account) && months_cover_year_end?(months)
63
+ return @parsed.closing_balances[account]
64
+ end
65
+
66
+ opening = @parsed.opening_balances[account] || BigDecimal("0")
67
+ through_end = @parsed.period_changes.keys.select { |month| month <= months.last }
68
+ opening + through_end.sum { |month| raw_period_amount(account, @parsed.period_changes[month]) }
69
+ end
70
+
71
+ def months_cover_year_end?(months)
72
+ rar = @parsed.financial_years[0]
73
+ return false unless rar && rar[:to]
74
+
75
+ year_end = Date.strptime(rar[:to].to_s, "%Y%m%d")
76
+ months.include?(year_end.strftime("%Y%m"))
77
+ rescue Date::Error, TypeError
78
+ false
79
+ end
80
+
81
+ def balance_accounts_in(range)
82
+ (
83
+ @parsed.accounts.keys + @parsed.opening_balances.keys + @parsed.closing_balances.keys +
84
+ @parsed.period_changes.values.flat_map { |hash| hash.keys.map { |key| dimension_parts(key).first } }
85
+ ).uniq.select { |number| range.cover?(number) }.sort
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SieAnalysis
4
+ # Compare only explicitly exported monthly income budgets with matching ledger rows.
5
+ module Budgets
6
+ private
7
+
8
+ def budget_overview(months, dimension)
9
+ rows = months.flat_map do |month|
10
+ (@parsed.budgets[month] || {}).filter_map do |key, raw|
11
+ budget_row(month, key, raw, dimension)
12
+ end
13
+ end
14
+ rows.sort_by! { |row| [row[:month], row[:dimension_code].to_s, row[:account_number]] }
15
+ {
16
+ status: rows.empty? ? "unavailable" : "available",
17
+ rows:
18
+ }
19
+ end
20
+
21
+ def budget_row(month, key, raw, dimension)
22
+ account, dim, code = dimension_parts(key)
23
+ return unless dim == (dimension && Analysis::DIMENSIONS.fetch(dimension))
24
+
25
+ group = Groups.for_account(account)
26
+ return unless income_group?(group)
27
+
28
+ budget = round_money(present_amount(raw, group.fetch(:kind)))
29
+ actual_raw = (@parsed.period_changes[month] || {}).fetch(key, BigDecimal("0"))
30
+ actual = round_money(present_amount(actual_raw, group.fetch(:kind)))
31
+ {
32
+ month: "#{month[0, 4]}-#{month[4, 2]}",
33
+ account_number: account, account_name: @parsed.accounts[account],
34
+ dimension_code: code, dimension_label: code && (@parsed.objects[[dim, code]] || code),
35
+ budget:, actual:, variance: round_money(actual - budget)
36
+ }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SieAnalysis
4
+ # Cost-center / project income and account-row builders for Analysis.
5
+ module DimensionStatements
6
+ private
7
+
8
+ def dimension_income_groups(months, dimension)
9
+ dim_no = Analysis::DIMENSIONS.fetch(dimension)
10
+ totals = accumulate_dimension_totals(months, dim_no)
11
+ groups = dimension_group_rows(totals, dim_no)
12
+ revenue = totals.values.sum { |values| values[:revenue] }
13
+ expenses = totals.values.sum { |values| values[:expenses] }
14
+ {
15
+ revenue: round_money(revenue),
16
+ expenses: round_money(expenses),
17
+ result: round_money(revenue - expenses),
18
+ groups:
19
+ }
20
+ end
21
+
22
+ def accumulate_dimension_totals(months, dim_no)
23
+ totals = Hash.new { |hash, key| hash[key] = { revenue: BigDecimal("0"), expenses: BigDecimal("0"), accounts: Hash.new(BigDecimal("0")) } }
24
+ months.each do |month|
25
+ (@parsed.period_changes[month] || {}).each do |key, raw|
26
+ account, dim, code = dimension_parts(key)
27
+ next unless dim == dim_no && code
28
+
29
+ group = Groups.for_account(account)
30
+ next unless income_group?(group)
31
+
32
+ presented = present_amount(raw, group.fetch(:kind))
33
+ bucket = totals[code]
34
+ bucket[group.fetch(:kind) == :revenue ? :revenue : :expenses] += presented
35
+ bucket[:accounts][account] += presented
36
+ end
37
+ end
38
+ totals
39
+ end
40
+
41
+ def dimension_group_rows(totals, dim_no)
42
+ rows = totals.map do |code, values|
43
+ accounts = values[:accounts].filter_map do |number, amount|
44
+ next if amount.zero?
45
+
46
+ { account_number: number, account_name: @parsed.accounts[number], amount: round_money(amount) }
47
+ end
48
+ {
49
+ code:,
50
+ label: @parsed.objects[[dim_no, code]] || code,
51
+ amount: round_money(values[:revenue] - values[:expenses]),
52
+ accounts: accounts.sort_by { |row| row.fetch(:account_number) }
53
+ }
54
+ end
55
+ rows.sort_by { |group| group.fetch(:code) }
56
+ end
57
+
58
+ def dimension_account_rows(month, dimension)
59
+ dim_no = Analysis::DIMENSIONS.fetch(dimension)
60
+ rows = []
61
+ (@parsed.period_changes[month] || {}).each do |key, raw|
62
+ account, dim, code = dimension_parts(key)
63
+ next unless dim == dim_no && code
64
+
65
+ group = Groups.for_account(account)
66
+ next unless group
67
+
68
+ presented = presented_period(raw, group)
69
+ next if presented.zero?
70
+
71
+ rows << {
72
+ account_number: account,
73
+ account_name: @parsed.accounts[account],
74
+ group_code: group.fetch(:code),
75
+ dimension_code: code,
76
+ movement: round_money(presented),
77
+ dimension_label: @parsed.objects[[dim_no, code]] || code
78
+ }
79
+ end
80
+ rows.sort_by { |row| [row.fetch(:dimension_code), row.fetch(:account_number)] }
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SieAnalysis
4
+ module Groups
5
+ INCOME = [
6
+ { code: "revenue", label: "Revenue", range: 3000..3999, kind: :revenue },
7
+ { code: "cost_of_goods", label: "Cost of goods sold", range: 4000..4999, kind: :expense },
8
+ { code: "external_costs", label: "External costs", range: 5000..6999, kind: :expense },
9
+ { code: "personnel_costs", label: "Personnel costs", range: 7000..7699, kind: :expense },
10
+ { code: "depreciation", label: "Depreciation", range: 7700..7899, kind: :expense },
11
+ { code: "other_operating", label: "Other operating items", range: 7900..7999, kind: :expense },
12
+ { code: "financial_income", label: "Financial income", range: 8000..8399, kind: :revenue },
13
+ { code: "financial_expenses", label: "Financial expenses", range: 8400..8799, kind: :expense },
14
+ { code: "appropriations_tax", label: "Appropriations and tax", range: 8800..8999, kind: :expense }
15
+ ].freeze
16
+
17
+ BALANCE = [
18
+ { code: "assets", label: "Assets", range: 1000..1999, kind: :asset },
19
+ { code: "equity_and_liabilities", label: "Equity and liabilities", range: 2000..2999, kind: :equity_liability }
20
+ ].freeze
21
+
22
+ CASH_RANGE = 1910..1999
23
+
24
+ def self.for_account(number)
25
+ (INCOME + BALANCE).find { |group| group.fetch(:range).cover?(number) }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SieAnalysis
4
+ # BAS income-statement and undimensioned account-row builders for Analysis.
5
+ module IncomeStatements
6
+ private
7
+
8
+ def income_statement(months, dimension)
9
+ dimension ? dimension_income_groups(months, dimension) : bas_income_groups(months)
10
+ end
11
+
12
+ def bas_income_groups(months)
13
+ groups = Groups::INCOME.map { |group| bas_group_row(months, group) }
14
+ revenue = sum_kind(groups, :revenue)
15
+ expenses = sum_kind(groups, :expense)
16
+ {
17
+ revenue: round_money(revenue),
18
+ expenses: round_money(expenses),
19
+ result: round_money(revenue - expenses),
20
+ groups: groups.reject { |group| group.fetch(:amount).zero? && group.fetch(:accounts).empty? }
21
+ }
22
+ end
23
+
24
+ def bas_group_row(months, group)
25
+ rows = account_amounts_for_range(months, group.fetch(:range), group.fetch(:kind))
26
+ {
27
+ code: group.fetch(:code),
28
+ label: group.fetch(:label),
29
+ amount: round_money(rows.sum { |row| row.fetch(:amount) }),
30
+ accounts: rows.reject { |row| row.fetch(:amount).zero? }
31
+ }
32
+ end
33
+
34
+ def sum_kind(groups, kind)
35
+ selected = groups.select do |row|
36
+ Groups::INCOME.find { |definition| definition[:code] == row[:code] }[:kind] == kind
37
+ end
38
+ selected.sum { |row| row.fetch(:amount) }
39
+ end
40
+
41
+ def account_rows_for_month(month, dimension)
42
+ return dimension_account_rows(month, dimension) if dimension
43
+
44
+ amounts = Hash.new(BigDecimal("0"))
45
+ (@parsed.period_changes[month] || {}).each do |key, raw|
46
+ account, dim, code = dimension_parts(key)
47
+ next if dim || code
48
+
49
+ group = Groups.for_account(account)
50
+ next unless group
51
+
52
+ amounts[account] += presented_period(raw, group)
53
+ end
54
+ amounts.filter_map { |number, amount| account_row(number, amount) unless amount.zero? }
55
+ .sort_by { |row| row.fetch(:account_number) }
56
+ end
57
+
58
+ def presented_period(raw, group)
59
+ income_group?(group) ? present_amount(raw, group.fetch(:kind)) : present_balance(raw, group.fetch(:kind))
60
+ end
61
+
62
+ def account_row(number, amount)
63
+ group = Groups.for_account(number)
64
+ {
65
+ account_number: number,
66
+ account_name: @parsed.accounts[number],
67
+ group_code: group&.fetch(:code),
68
+ movement: round_money(amount),
69
+ dimension_code: nil, dimension_label: nil
70
+ }
71
+ end
72
+
73
+ def account_amounts_for_range(months, range, kind)
74
+ totals = Hash.new(BigDecimal("0"))
75
+ months.each do |month|
76
+ (@parsed.period_changes[month] || {}).each do |key, raw|
77
+ account, dim, code = dimension_parts(key)
78
+ next if dim || code
79
+ next unless range.cover?(account)
80
+
81
+ totals[account] += present_amount(raw, kind)
82
+ end
83
+ end
84
+ totals.map { |number, amount| { account_number: number, account_name: @parsed.accounts[number], amount: round_money(amount) } }
85
+ .sort_by { |row| row.fetch(:account_number) }
86
+ end
87
+
88
+ def monthly_totals(months, dimension)
89
+ return if months.length <= 1
90
+
91
+ months.map do |month|
92
+ income = income_statement([month], dimension)
93
+ {
94
+ month: "#{month[0, 4]}-#{month[4, 2]}",
95
+ revenue: income.fetch(:revenue),
96
+ expenses: income.fetch(:expenses),
97
+ result: income.fetch(:result)
98
+ }
99
+ end
100
+ end
101
+
102
+ def highlights(income, balance)
103
+ revenue = income.fetch(:revenue)
104
+ result = income.fetch(:result)
105
+ margin = revenue.zero? ? nil : round_money((result / revenue) * 100)
106
+ { result:, profit_margin_percent: margin, cash_and_bank: balance[:cash_and_bank] }.compact
107
+ end
108
+ end
109
+ end