From 817b121ca4bc3f6c38da8da503d12928600effe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 27 Sep 2026 12:32:30 +0200 Subject: [PATCH] Support trees in the Equity view --- README.md | 3 +- .../components/hledger-dashboard.gjs | 20 +++-- lib/hledger/equity.rb | 84 ++++++++----------- lib/hledger/report_builder.rb | 27 +++++- spec/lib/hledger/equity_spec.rb | 65 +++++++++----- spec/lib/hledger/integration_spec.rb | 6 +- spec/lib/hledger/report_builder_spec.rb | 8 +- spec/requests/hledger/reports_spec.rb | 4 +- test/javascripts/acceptance/hledger-test.js | 27 ++++-- 9 files changed, 153 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 8566536..e8d2e2a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/assets/javascripts/discourse/components/hledger-dashboard.gjs b/assets/javascripts/discourse/components/hledger-dashboard.gjs index 2cd1847..c0c48d0 100644 --- a/assets/javascripts/discourse/components/hledger-dashboard.gjs +++ b/assets/javascripts/discourse/components/hledger-dashboard.gjs @@ -174,15 +174,23 @@ export default class HledgerDashboard extends Component { - {{#each group.holders as |holder|}} + {{#each group.rows as |row|}} - {{holder.holder}} + + {{row.label}} + + {{this.amountText + row.amounts + }} {{holder.amount.display}} - {{holder.percentage}}% + >{{row.percentage}}% {{/each}} diff --git a/lib/hledger/equity.rb b/lib/hledger/equity.rb index 2c8e640..0c2d384 100644 --- a/lib/hledger/equity.rb +++ b/lib/hledger/equity.rb @@ -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 diff --git a/lib/hledger/report_builder.rb b/lib/hledger/report_builder.rb index 8af307b..8349090 100644 --- a/lib/hledger/report_builder.rb +++ b/lib/hledger/report_builder.rb @@ -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) diff --git a/spec/lib/hledger/equity_spec.rb b/spec/lib/hledger/equity_spec.rb index 605f0b7..ee9fcd8 100644 --- a/spec/lib/hledger/equity_spec.rb +++ b/spec/lib/hledger/equity_spec.rb @@ -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 diff --git a/spec/lib/hledger/integration_spec.rb b/spec/lib/hledger/integration_spec.rb index cc83c39..bde1041 100644 --- a/spec/lib/hledger/integration_spec.rb +++ b/spec/lib/hledger/integration_spec.rb @@ -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 diff --git a/spec/lib/hledger/report_builder_spec.rb b/spec/lib/hledger/report_builder_spec.rb index 16a64f8..b1b1430 100644 --- a/spec/lib/hledger/report_builder_spec.rb +++ b/spec/lib/hledger/report_builder_spec.rb @@ -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 diff --git a/spec/requests/hledger/reports_spec.rb b/spec/requests/hledger/reports_spec.rb index 277d522..cd12b32 100644 --- a/spec/requests/hledger/reports_spec.rb +++ b/spec/requests/hledger/reports_spec.rb @@ -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 diff --git a/test/javascripts/acceptance/hledger-test.js b/test/javascripts/acceptance/hledger-test.js index 7b69dd6..f755f17 100644 --- a/test/javascripts/acceptance/hledger-test.js +++ b/test/javascripts/acceptance/hledger-test.js @@ -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) {