Support trees in the Equity view

This commit is contained in:
2026-09-27 12:32:30 +02:00
parent 4a1b029863
commit 817b121ca4
9 changed files with 153 additions and 91 deletions
+2 -1
View File
@@ -9,7 +9,8 @@ by the `hledger` command line tool:
- Accounts and balances (a full account tree, each name capitalized)
- Balance sheet (indented account tree under each section)
- Income statement (indented account tree under each section)
- Equity distribution (contributed capital, not legal ownership)
- Equity distribution (account tree with per-commodity shares; contributed
capital, not legal ownership)
## Installation
@@ -174,15 +174,23 @@ export default class HledgerDashboard extends Component {
</tr>
</thead>
<tbody>
{{#each group.holders as |holder|}}
{{#each group.rows as |row|}}
<tr>
<td>{{holder.holder}}</td>
<td
class={{dConcatClass
"hledger-dashboard__account"
(this.depthClass row.depth)
}}
title={{row.account}}
>
{{row.label}}
</td>
<td class="hledger-dashboard__amount">{{this.amountText
row.amounts
}}</td>
<td
class="hledger-dashboard__amount"
>{{holder.amount.display}}</td>
<td
class="hledger-dashboard__amount"
>{{holder.percentage}}%</td>
>{{row.percentage}}%</td>
</tr>
{{/each}}
<tr class="hledger-dashboard__total">
+37 -47
View File
@@ -3,52 +3,50 @@
require "bigdecimal"
module Hledger
# Turns contributed-capital balances into a per-commodity tree, where every
# row shows its share of that commodity's total.
#
# quantities are absolute, so a node equals the sum of its descendants and no
# negative values are shown.
module Equity
module_function
# rows: array of [account_name, amounts] where amounts are normalized hashes
# with "commodity" and "quantity". Returns groups per commodity.
def distribute(rows)
buckets = Hash.new { |h, k| h[k] = Hash.new { |hh, hk| hh[hk] = BigDecimal(0) } }
# entries: [[full_account_name, [{ "commodity" =>, "quantity" => BigDecimal, "style" => }]], ...]
def groups(entries)
equity_entries = entries.select { |name, _amounts| name.to_s.start_with?("equity:") }
rows =
AccountTree
.expand(equity_entries)
.reject { |node| node["root"] }
.map { |node| node.merge("depth" => node["depth"] - 1) }
rows.each do |account, amounts|
holder = holder_name(account)
next if holder.blank?
Array(amounts).each do |amount|
next if amount.blank?
commodity = amount["commodity"].to_s
buckets[commodity][holder] += BigDecimal(amount["quantity"]).abs
end
end
buckets
.map do |commodity, holders|
total = holders.values.reduce(BigDecimal(0), :+)
{
"commodity" => commodity,
"total" => amount_hash(commodity, total),
"holders" =>
holders
.map do |holder, quantity|
{
"holder" => holder,
"amount" => amount_hash(commodity, quantity),
"percentage" => percentage(quantity, total),
}
end
.sort_by { |holder| -BigDecimal(holder["amount"]["quantity"]) },
}
end
.sort_by { |group| group["commodity"] }
commodities(rows).map { |commodity| build_group(rows, commodity) }
end
def holder_name(account)
parts = account.to_s.split(":")
return nil unless parts.first == "equity"
def commodities(rows)
rows.flat_map { |node| node["amounts"].map { |amount| amount["commodity"].to_s } }.uniq
end
parts[1]
def build_group(rows, commodity)
group_rows =
rows.filter_map do |node|
amount = node["amounts"].find { |candidate| candidate["commodity"].to_s == commodity }
node.merge("amounts" => [amount]) if amount
end
template = group_rows.first["amounts"].first
total =
group_rows
.select { |row| row["depth"].zero? }
.sum { |row| row["amounts"].first["quantity"] }
{
"commodity" => commodity,
"total" => template.merge("quantity" => total),
"rows" =>
group_rows.map do |row|
row.merge("percentage" => percentage(row["amounts"].first["quantity"], total))
end,
}
end
def percentage(quantity, total)
@@ -56,13 +54,5 @@ module Hledger
format("%.2f", (quantity / total) * 100)
end
def amount_hash(commodity, quantity)
{
"commodity" => commodity,
"quantity" => quantity.to_s("F"),
"display" => [quantity.to_s("F"), commodity].reject(&:blank?).join(" "),
}
end
end
end
+25 -2
View File
@@ -76,9 +76,32 @@ module Hledger
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))
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 envelope(report_type, begin_date, end_date, **extra)
+44 -21
View File
@@ -1,37 +1,60 @@
# frozen_string_literal: true
require "bigdecimal"
RSpec.describe Hledger::Equity do
it "aggregates holders and computes percentages" do
rows = [
["equity:alice", [{ "commodity" => "EUR", "quantity" => "-600.0" }]],
["equity:bob", [{ "commodity" => "EUR", "quantity" => "-400.0" }]],
]
def part(commodity, quantity)
{ "commodity" => commodity, "quantity" => BigDecimal(quantity), "style" => {} }
end
group = described_class.distribute(rows).first
it "builds a per-commodity tree with each row's share" do
groups =
described_class.groups(
[["equity:alice", [part("EUR", "600")]], ["equity:bob", [part("EUR", "400")]]],
)
group = groups.first
expect(group["commodity"]).to eq("EUR")
expect(group["total"]["quantity"]).to eq("1000.0")
expect(group["holders"].map { |h| h["holder"] }).to eq(%w[alice bob])
expect(group["holders"].map { |h| h["percentage"] }).to eq(%w[60.00 40.00])
expect(group["total"]["quantity"].to_s("F")).to eq("1000.0")
expect(group["rows"].map { |row| row["label"] }).to eq(%w[Alice Bob])
expect(group["rows"].map { |row| row["depth"] }).to eq([0, 0])
expect(group["rows"].map { |row| row["percentage"] }).to eq(%w[60.00 40.00])
end
it "nests subaccounts and shares the commodity total" do
groups =
described_class.groups(
[
["equity:alice:initial", [part("EUR", "600")]],
["equity:alice:extra", [part("EUR", "100")]],
["equity:bob", [part("EUR", "300")]],
],
)
rows = groups.first["rows"]
expect(rows.map { |row| [row["label"], row["depth"]] }).to eq(
[["Alice", 0], ["Initial", 1], ["Extra", 1], ["Bob", 0]],
)
expect(rows.map { |row| row["percentage"] }).to eq(%w[70.00 60.00 10.00 30.00])
end
it "keeps commodities separate" do
rows = [
["equity:alice", [{ "commodity" => "EUR", "quantity" => "100" }]],
["equity:alice", [{ "commodity" => "USD", "quantity" => "50" }]],
["equity:bob", [{ "commodity" => "USD", "quantity" => "50" }]],
]
groups =
described_class.groups(
[
["equity:alice", [part("EUR", "100"), part("USD", "50")]],
["equity:bob", [part("USD", "50")]],
],
)
groups = described_class.distribute(rows)
expect(groups.map { |g| g["commodity"] }).to eq(%w[EUR USD])
usd = groups.find { |g| g["commodity"] == "USD" }
expect(usd["holders"].map { |h| h["percentage"] }).to eq(%w[50.00 50.00])
expect(groups.map { |group| group["commodity"] }).to eq(%w[EUR USD])
usd = groups.find { |group| group["commodity"] == "USD" }
expect(usd["rows"].map { |row| row["percentage"] }).to eq(%w[50.00 50.00])
end
it "ignores accounts outside of equity" do
rows = [["assets:bank", [{ "commodity" => "EUR", "quantity" => "100" }]]]
groups = described_class.groups([["assets:bank", [part("EUR", "100")]]])
expect(described_class.distribute(rows)).to eq([])
expect(groups).to eq([])
end
end
+3 -3
View File
@@ -26,10 +26,10 @@ RSpec.describe "Hledger integration" do
end
it "computes the equity distribution" do
holders = builder.build("equity", journal: journal)["groups"].first["holders"]
rows = builder.build("equity", journal: journal)["groups"].first["rows"]
expect(holders.map { |holder| [holder["holder"], holder["percentage"]] }).to eq(
[%w[alice 60.00], %w[bob 40.00]],
expect(rows.map { |row| [row["label"], row["percentage"]] }).to eq(
[%w[Alice 60.00], %w[Bob 40.00]],
)
end
+5 -3
View File
@@ -99,14 +99,16 @@ RSpec.describe Hledger::ReportBuilder do
end
describe "equity" do
it "distributes contributed capital per holder" do
it "shows the equity account tree with each row's share" do
report = builder.build("equity", journal: "journal")
group = report["groups"].first
expect(group["commodity"]).to eq("EUR")
expect(group["total"]["quantity"]).to eq("1000.0")
expect(group["holders"].map { |h| h["holder"] }).to eq(%w[alice bob])
expect(group["holders"].map { |h| h["percentage"] }).to eq(%w[60.00 40.00])
expect(group["rows"].map { |row| row["label"] }).to eq(%w[Alice Bob])
expect(group["rows"].map { |row| row["depth"] }).to eq([0, 0])
expect(group["rows"].map { |row| row["percentage"] }).to eq(%w[60.00 40.00])
expect(group["rows"].first["amounts"].first["display"]).to eq("600.00 EUR")
end
end
+2 -2
View File
@@ -90,8 +90,8 @@ RSpec.describe Hledger::ReportsController do
get_report("equity")
expect(response.status).to eq(200)
expect(response.parsed_body["groups"].first["holders"].map { |h| h["holder"] }).to eq(
%w[alice bob],
expect(response.parsed_body["groups"].first["rows"].map { |row| row["label"] }).to eq(
%w[Alice Bob],
)
end
+21 -6
View File
@@ -55,15 +55,23 @@ const equityReport = {
{
commodity: "EUR",
total: { commodity: "EUR", quantity: "1000.0", display: "1000.0 EUR" },
holders: [
rows: [
{
holder: "alice",
amount: { commodity: "EUR", quantity: "600.0", display: "600.0 EUR" },
account: "equity:alice",
label: "Alice",
depth: 0,
amounts: [
{ commodity: "EUR", quantity: "600.0", display: "600.0 EUR" },
],
percentage: "60.00",
},
{
holder: "bob",
amount: { commodity: "EUR", quantity: "400.0", display: "400.0 EUR" },
account: "equity:bob",
label: "Bob",
depth: 0,
amounts: [
{ commodity: "EUR", quantity: "400.0", display: "400.0 EUR" },
],
percentage: "40.00",
},
],
@@ -254,8 +262,15 @@ acceptance("Hledger plugin", function (needs) {
await click(".hledger-dashboard__toolbar .btn:nth-of-type(4)");
assert.dom(".hledger-dashboard__table").includesText("alice");
assert.dom(".hledger-dashboard__table").includesText("Alice");
assert.dom(".hledger-dashboard__table").includesText("60.00%");
assert
.dom('.hledger-dashboard__account[title="equity:alice"]')
.hasText("Alice");
assert.dom(".hledger-dashboard__account.--depth-0").exists({ count: 2 });
assert
.dom(".hledger-dashboard__account.--root")
.doesNotExist("equity rows are never bold");
});
test("renders the balance sheet and income statement as trees", async function (assert) {