256 lines
7.0 KiB
Ruby
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
|