Files
discourse-hledger/lib/hledger/report_builder.rb
T
2026-09-27 10:31:47 +02:00

256 lines
7.0 KiB
Ruby

# frozen_string_literal: true
require "bigdecimal"
require "csv"
require "json"
module Hledger
# Runs an hledger report and normalizes its output into the JSON shape the
# client renders.
class ReportBuilder
REPORT_TYPES = %w[accounts balance_sheet income_statement equity].freeze
COMPOUND_COMMANDS = { "balance_sheet" => "bs", "income_statement" => "is" }.freeze
def self.supported?(type)
REPORT_TYPES.include?(type.to_s)
end
def initialize(runner: Runner.new)
@runner = runner
end
def build(report_type, journal:, begin_date: nil, end_date: nil)
raise UnavailableError if @runner.version.blank?
case report_type.to_s
when "accounts"
build_accounts(journal, begin_date, end_date)
when "balance_sheet", "income_statement"
build_compound(report_type.to_s, journal, begin_date, end_date)
when "equity"
build_equity(journal, begin_date, end_date)
else
raise Error, "unsupported report type: #{report_type}"
end
end
private
def build_accounts(journal, begin_date, end_date)
result = run(["bal", "-O", "json", *balance_flags(begin_date, end_date)], journal)
rows, total = parse_json(result.stdout)
sections = [
{
"title" => nil,
"rows" => Array(rows).map { |row| account_row(row) },
"total" => amounts(Array(total)),
},
]
envelope("accounts", begin_date, end_date, "sections" => sections, "net" => nil)
end
def build_compound(report_type, journal, begin_date, end_date)
flags =
(
if report_type == "balance_sheet"
balance_flags(begin_date, end_date)
else
period_flags(begin_date, end_date)
end
)
result = run([COMPOUND_COMMANDS.fetch(report_type), "-O", "csv", *flags], journal)
sections, net = parse_compound_csv(result.stdout)
envelope(report_type, begin_date, end_date, "sections" => sections, "net" => net)
end
def build_equity(journal, begin_date, end_date)
result = run(["bal", "-O", "json", *balance_flags(begin_date, end_date), "^equity:"], journal)
rows, = parse_json(result.stdout)
holdings = Array(rows).map { |row| [row[1].to_s, amounts(Array(row[3]))] }
envelope("equity", begin_date, end_date, "groups" => Equity.distribute(holdings))
end
def envelope(report_type, begin_date, end_date, **extra)
{
"report_type" => report_type,
"period" => {
"begin" => begin_date&.iso8601,
"end" => end_date&.iso8601,
},
"meta" => {
"hledger_version" => @runner.version,
},
}.merge(extra)
end
def run(argv, journal)
result = @runner.execute([*base_flags, *argv], stdin_data: journal)
raise ExecutionError, result.stderr if result.timed_out
raise ExecutionError, result.stderr unless result.success?
result
end
def base_flags
flags = ["-f-"]
flags << "-n" if no_conf_supported?
flags
end
def no_conf_supported?
Gem::Version.new(@runner.version) >= Gem::Version.new("1.40")
rescue ArgumentError
false
end
def parse_json(text)
JSON.parse(text)
rescue JSON::ParserError => e
raise ParseError, e.message
end
def account_row(row)
display, full, _depth, row_amounts = row
account = (full || display).to_s
{
"account" => account,
"depth" => depth_of(account),
"amounts" => amounts(Array(row_amounts)),
}
end
def parse_compound_csv(text)
rows = CSV.parse(text)
sections = []
current = nil
net = nil
rows.each_with_index do |row, index|
next if index < 2
label = row[0].to_s
raw_amount = row[1].to_s
next if label.blank?
case label
when "Total:"
current["total"] = [parse_amount_string(raw_amount)] if current
when "Net:"
net = [parse_amount_string(raw_amount)]
else
if raw_amount.blank?
current = { "title" => label, "rows" => [], "total" => nil }
sections << current
elsif current
current["rows"] << {
"account" => label,
"depth" => depth_of(label),
"amounts" => [parse_amount_string(raw_amount)],
}
end
end
end
[sections, net]
end
def amounts(list)
Array(list).map { |amount| amount_to_h(amount) }
end
def amount_to_h(amount)
quantity = quantity_of(amount["aquantity"])
style = amount["astyle"] || {}
commodity = amount["acommodity"].to_s
{
"commodity" => commodity,
"quantity" => quantity.to_s("F"),
"display" => display_amount(quantity, commodity, style),
"side" => style["ascommodityside"] || "R",
"spaced" => style["ascommodityspaced"] ? true : false,
"precision" => style["asprecision"],
}
end
def parse_amount_string(raw)
text = raw.to_s.strip
return nil if text.empty?
if (match = text.match(/\A(-?\d+(?:[.,]\d+)?)\s*(.*)\z/))
number = match[1]
commodity = match[2].strip
side = "R"
elsif (match = text.match(/\A(.*?)\s*(-?\d+(?:[.,]\d+)?)\z/))
commodity = match[1].strip
number = match[2]
side = "L"
else
return nil
end
quantity = BigDecimal(number.tr(",", "."))
{
"commodity" => commodity,
"quantity" => quantity.to_s("F"),
"display" => text,
"side" => side,
"spaced" => commodity.present? && text.include?(" "),
"precision" => number.match?(/[.,]/) ? number.split(/[.,]/).last.length : 0,
}
end
def quantity_of(aquantity)
return BigDecimal(0) if aquantity.blank?
mantissa = aquantity["decimalMantissa"].to_i
places = aquantity["decimalPlaces"].to_i
BigDecimal(mantissa) / (10**places)
end
def display_amount(quantity, commodity, style)
precision = style["asprecision"]
if precision.nil?
rendered = quantity.to_s("F")
precision = rendered.include?(".") ? rendered.split(".").last.length : 0
end
mark = style["asdecimalmark"].presence || "."
number = format("%.#{precision}f", quantity)
number = number.tr(".", mark) if mark != "."
return number if commodity.blank?
spaced = style["ascommodityspaced"] ? " " : ""
if style["ascommodityside"] == "L"
"#{commodity}#{spaced}#{number}"
else
"#{number}#{spaced}#{commodity}"
end
end
def depth_of(account)
account.to_s.count(":")
end
def balance_flags(begin_date, end_date)
flags = []
flags += ["-e", (end_date + 1).iso8601] if end_date
flags
end
def period_flags(begin_date, end_date)
flags = []
flags += ["-b", begin_date.iso8601] if begin_date
flags += ["-e", (end_date + 1).iso8601] if end_date
flags
end
end
end