Support trees in the Equity view
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user