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) {