No notifications are sent for appearing in a journal, but profile popovers etc. all work like mentions anywhere else in a topic.
304 lines
8.8 KiB
Ruby
304 lines
8.8 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 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_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
|