Files
discourse-hledger/lib/hledger/report_builder.rb
T

307 lines
8.9 KiB
Ruby

# frozen_string_literal: true
require "bigdecimal"
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 transactions].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?
report =
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)
when "transactions"
build_transactions(journal, begin_date, end_date)
else
raise Error, "unsupported report type: #{report_type}"
end
Mentions.annotate!(report)
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" => account_rows(Array(rows)),
"total" => amounts(Array(total), parenthesize: true),
},
]
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", "json", *flags], journal)
report = parse_json(result.stdout)
sections =
Array(report["cbrSubreports"]).map do |entry|
title, subreport = entry
{
"title" => title,
"rows" => compound_section_rows(Array(subreport["prRows"]), title),
"total" => amounts(Array(subreport.dig("prTotals", "prrTotal"))),
}
end
net = amounts(Array(report.dig("cbrTotals", "prrTotal")))
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)
entries =
Array(rows).map do |row|
parts =
Array(row[3]).map do |amount|
part = raw_amount(amount)
part.merge("quantity" => part["quantity"].abs)
end
[row[1].to_s, parts]
end
groups =
Equity
.groups(entries)
.map do |group|
group.merge(
"total" => normalized_amount(group["total"]),
"rows" =>
group["rows"].map do |row|
row.merge("amounts" => row["amounts"].map { |part| normalized_amount(part) })
end,
)
end
envelope("equity", begin_date, end_date, "groups" => groups)
end
def build_transactions(journal, begin_date, end_date)
result = run(["print", "-O", "json", *period_flags(begin_date, end_date)], journal)
transactions = parse_json(result.stdout)
rows =
Array(transactions).map do |transaction|
{
"date" => transaction["tdate"],
"date2" => transaction["tdate2"],
"description" => transaction["tdescription"].to_s,
"code" => transaction["tcode"].to_s,
"postings" =>
Array(transaction["tpostings"]).map do |posting|
{
"account" => posting["paccount"].to_s,
"amounts" => amounts(Array(posting["pamount"])),
}
end,
}
end
envelope("transactions", begin_date, end_date, "transactions" => rows)
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 OutputLimitError if result.output_limit_exceeded
raise ExecutionError, "hledger timed out: #{result.stderr}" if result.timed_out
unless result.success?
raise ExecutionError, "hledger failed (exit #{result.exit_status}): #{result.stderr}"
end
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_rows(rows)
entries =
rows.map do |row|
display, full, _depth, row_amounts = row
[(full || display).to_s, Array(row_amounts).map { |amount| raw_amount(amount) }]
end
tree_rows(entries, parenthesize: true)
end
def compound_rows(rows)
entries =
rows.map do |row|
[row["prrName"].to_s, Array(row["prrTotal"]).map { |amount| raw_amount(amount) }]
end
tree_rows(entries)
end
# The section heading stands in for the matching top-level account, so its
# subtree starts flush with the heading (one level shallower).
def compound_section_rows(rows, title)
nodes = compound_rows(rows)
root = nodes.find { |node| node["root"] && title_matches_root?(title, node["label"]) }
return nodes unless root
prefix = "#{root["account"]}:"
nodes
.reject { |node| node.equal?(root) }
.map do |node|
node["account"].start_with?(prefix) ? node.merge("depth" => node["depth"] - 1) : node
end
end
def tree_rows(entries, parenthesize: false)
AccountTree
.expand(entries)
.map do |node|
node.merge(
"amounts" => node["amounts"].map { |part| normalized_amount(part, parenthesize:) },
)
end
end
def title_matches_root?(title, label)
label.to_s.casecmp?(title.to_s)
end
def amounts(list, parenthesize: false)
Array(list).map { |amount| normalized_amount(raw_amount(amount), parenthesize:) }
end
def raw_amount(amount)
{
"commodity" => amount["acommodity"].to_s,
"quantity" => quantity_of(amount["aquantity"]),
"style" => amount["astyle"] || {},
}
end
def normalized_amount(part, parenthesize: false)
commodity = part["commodity"].to_s
quantity = part["quantity"]
style = part["style"] || {}
{
"commodity" => commodity,
"quantity" => quantity.to_s("F"),
"display" => display_amount(quantity, commodity, style, parenthesize:),
"side" => style["ascommodityside"] || "R",
"spaced" => style["ascommodityspaced"] ? true : false,
"precision" => style["asprecision"],
}
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, parenthesize: false)
precision = style["asprecision"]
if precision.nil?
rendered = quantity.abs.to_s("F")
precision = rendered.include?(".") ? rendered.split(".").last.length : 0
end
mark = style["asdecimalmark"].presence || "."
magnitude = parenthesize ? quantity.abs : quantity
number = format("%.#{precision}f", magnitude)
number = number.tr(".", mark) if mark != "."
text =
if commodity.blank?
number
else
spaced = style["ascommodityspaced"] ? " " : ""
if style["ascommodityside"] == "L"
"#{commodity}#{spaced}#{number}"
else
"#{number}#{spaced}#{commodity}"
end
end
parenthesize && quantity.negative? ? "(#{text})" : text
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