Add Transactions view with detailed tx log

This commit is contained in:
2026-09-27 13:12:44 +02:00
parent 7c1b67c000
commit 9f87fd66ac
11 changed files with 526 additions and 5 deletions
+2 -1
View File
@@ -3,7 +3,7 @@
Render [hledger](https://hledger.org) journal reports inside Discourse topics.
Put an hledger journal in a fenced `hledger` code block in a topic's first
post. The block is replaced by a dashboard with four reports, generated live
post. The block is replaced by a dashboard with five reports, generated live
by the `hledger` command line tool:
- Accounts and balances (a full account tree, each name capitalized)
@@ -11,6 +11,7 @@ by the `hledger` command line tool:
- Income statement (indented account tree under each section)
- Equity distribution (account tree with per-commodity shares; contributed
capital, not legal ownership)
- Transactions (a detailed transaction log: date, description and postings)
## Installation
@@ -11,7 +11,13 @@ import { i18n } from "discourse-i18n";
import { amountText } from "../lib/hledger-report";
import HledgerAccountCell from "./hledger-account-cell";
const REPORT_IDS = ["accounts", "balance_sheet", "income_statement", "equity"];
const REPORT_IDS = [
"accounts",
"balance_sheet",
"income_statement",
"equity",
"transactions",
];
export default class HledgerDashboard extends Component {
@tracked selectedReport = "accounts";
@@ -35,6 +41,7 @@ export default class HledgerDashboard extends Component {
balance_sheet: "scale-balanced",
income_statement: "chart-line",
equity: "users",
transactions: "receipt",
}[id],
}));
}
@@ -47,6 +54,10 @@ export default class HledgerDashboard extends Component {
return this.selectedReport === "equity";
}
get isTransactions() {
return this.selectedReport === "transactions";
}
get labels() {
return {
total: i18n("hledger.total"),
@@ -191,6 +202,37 @@ export default class HledgerDashboard extends Component {
</div>
{{/each}}
<p class="hledger-dashboard__note">{{this.labels.note}}</p>
{{else if this.isTransactions}}
{{#if this.report.transactions.length}}
{{#each this.report.transactions as |transaction|}}
<div class="hledger-dashboard__transaction">
<div class="hledger-dashboard__transaction-header">
<span
class="hledger-dashboard__transaction-date"
>{{transaction.date}}</span>
<span
class="hledger-dashboard__transaction-description"
>{{transaction.description}}</span>
</div>
<table class="hledger-dashboard__table">
<tbody>
{{#each transaction.postings as |posting|}}
<tr>
<td
class="hledger-dashboard__account"
>{{posting.account}}</td>
<td class="hledger-dashboard__amount">{{amountText
posting.amounts
}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/each}}
{{else}}
<p class="hledger-dashboard__empty">{{this.labels.noData}}</p>
{{/if}}
{{else}}
{{#each this.report.sections as |section|}}
<div class="hledger-dashboard__section">
+15
View File
@@ -114,6 +114,21 @@
color: var(--primary-medium);
}
&__transaction + &__transaction {
margin-top: 1em;
}
&__transaction-header {
display: flex;
gap: 0.5em;
margin: 0 0 0.25em;
font-size: var(--font-down-1);
}
&__transaction-date {
color: var(--primary-medium);
}
&__error {
margin: 0;
}
+1
View File
@@ -11,6 +11,7 @@ en:
balance_sheet: "Balance sheet"
income_statement: "Income statement"
equity: "Equity"
transactions: "Transactions"
total: "Total"
net: "Net"
no_data: "No data"
+27 -1
View File
@@ -7,7 +7,7 @@ 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].freeze
REPORT_TYPES = %w[accounts balance_sheet income_statement equity transactions].freeze
COMPOUND_COMMANDS = { "balance_sheet" => "bs", "income_statement" => "is" }.freeze
def self.supported?(type)
@@ -28,6 +28,8 @@ module Hledger
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
@@ -104,6 +106,30 @@ module Hledger
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,
+1
View File
@@ -12,6 +12,7 @@ register_asset "stylesheets/hledger.scss"
register_svg_icon "scale-balanced"
register_svg_icon "chart-line"
register_svg_icon "receipt"
enabled_site_setting :hledger_enabled
+294
View File
@@ -0,0 +1,294 @@
[
{
"tcode": "",
"tcomment": "",
"tdate": "2024-01-01",
"tdate2": null,
"tdescription": "Opening balances",
"tindex": 1,
"tpostings": [
{
"paccount": "assets:bank:checking",
"pamount": [
{
"acommodity": "EUR",
"aprice": null,
"aquantity": {
"decimalMantissa": 100000,
"decimalPlaces": 2,
"floatingPoint": 1000
},
"astyle": {
"ascommodityside": "R",
"ascommodityspaced": true,
"asdecimalmark": ".",
"asdigitgroups": null,
"asprecision": 2,
"asrounding": "NoRounding"
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "1",
"ptype": "RegularPosting"
},
{
"paccount": "equity:alice",
"pamount": [
{
"acommodity": "EUR",
"aprice": null,
"aquantity": {
"decimalMantissa": -60000,
"decimalPlaces": 2,
"floatingPoint": -600
},
"astyle": {
"ascommodityside": "R",
"ascommodityspaced": true,
"asdecimalmark": ".",
"asdigitgroups": null,
"asprecision": 2,
"asrounding": "NoRounding"
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "1",
"ptype": "RegularPosting"
},
{
"paccount": "equity:bob",
"pamount": [
{
"acommodity": "EUR",
"aprice": null,
"aquantity": {
"decimalMantissa": -40000,
"decimalPlaces": 2,
"floatingPoint": -400
},
"astyle": {
"ascommodityside": "R",
"ascommodityspaced": true,
"asdecimalmark": ".",
"asdigitgroups": null,
"asprecision": 2,
"asrounding": "NoRounding"
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "1",
"ptype": "RegularPosting"
}
],
"tprecedingcomment": "",
"tsourcepos": [
{
"sourceColumn": 1,
"sourceLine": 1,
"sourceName": "-"
},
{
"sourceColumn": 1,
"sourceLine": 5,
"sourceName": "-"
}
],
"tstatus": "Unmarked",
"ttags": []
},
{
"tcode": "",
"tcomment": "",
"tdate": "2024-02-10",
"tdate2": null,
"tdescription": "Hosting",
"tindex": 2,
"tpostings": [
{
"paccount": "expenses:hosting:server",
"pamount": [
{
"acommodity": "EUR",
"aprice": null,
"aquantity": {
"decimalMantissa": 2050,
"decimalPlaces": 2,
"floatingPoint": 20.5
},
"astyle": {
"ascommodityside": "R",
"ascommodityspaced": true,
"asdecimalmark": ".",
"asdigitgroups": null,
"asprecision": 2,
"asrounding": "NoRounding"
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "2",
"ptype": "RegularPosting"
},
{
"paccount": "assets:bank:checking",
"pamount": [
{
"acommodity": "EUR",
"aprice": null,
"aquantity": {
"decimalMantissa": -2050,
"decimalPlaces": 2,
"floatingPoint": -20.5
},
"astyle": {
"ascommodityside": "R",
"ascommodityspaced": true,
"asdecimalmark": ".",
"asdigitgroups": null,
"asprecision": 2,
"asrounding": "NoRounding"
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "2",
"ptype": "RegularPosting"
}
],
"tprecedingcomment": "",
"tsourcepos": [
{
"sourceColumn": 1,
"sourceLine": 6,
"sourceName": "-"
},
{
"sourceColumn": 1,
"sourceLine": 9,
"sourceName": "-"
}
],
"tstatus": "Unmarked",
"ttags": []
},
{
"tcode": "",
"tcomment": "",
"tdate": "2024-03-15",
"tdate2": null,
"tdescription": "Donation",
"tindex": 3,
"tpostings": [
{
"paccount": "assets:bank:checking",
"pamount": [
{
"acommodity": "EUR",
"aprice": null,
"aquantity": {
"decimalMantissa": 5000,
"decimalPlaces": 2,
"floatingPoint": 50
},
"astyle": {
"ascommodityside": "R",
"ascommodityspaced": true,
"asdecimalmark": ".",
"asdigitgroups": null,
"asprecision": 2,
"asrounding": "NoRounding"
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "3",
"ptype": "RegularPosting"
},
{
"paccount": "revenues:donations",
"pamount": [
{
"acommodity": "EUR",
"aprice": null,
"aquantity": {
"decimalMantissa": -5000,
"decimalPlaces": 2,
"floatingPoint": -50
},
"astyle": {
"ascommodityside": "R",
"ascommodityspaced": true,
"asdecimalmark": ".",
"asdigitgroups": null,
"asprecision": 2,
"asrounding": "NoRounding"
}
}
],
"pbalanceassertion": null,
"pcomment": "",
"pdate": null,
"pdate2": null,
"poriginal": null,
"pstatus": "Unmarked",
"ptags": [],
"ptransaction_": "3",
"ptype": "RegularPosting"
}
],
"tprecedingcomment": "",
"tsourcepos": [
{
"sourceColumn": 1,
"sourceLine": 10,
"sourceName": "-"
},
{
"sourceColumn": 1,
"sourceLine": 13,
"sourceName": "-"
}
],
"tstatus": "Unmarked",
"ttags": []
}
]
+24 -1
View File
@@ -8,7 +8,7 @@ RSpec.describe "Hledger integration" do
let(:builder) { Hledger::ReportBuilder.new }
it "builds every supported report" do
%w[accounts balance_sheet income_statement equity].each do |type|
%w[accounts balance_sheet income_statement equity transactions].each do |type|
expect(builder.build(type, journal: journal)["report_type"]).to eq(type)
end
end
@@ -54,6 +54,29 @@ RSpec.describe "Hledger integration" do
expect(expenses["rows"].map { |row| row["depth"] }).to eq([0, 1])
end
it "returns transactions with their postings" do
transactions = builder.build("transactions", journal: journal)["transactions"]
expect(transactions.map { |txn| txn["description"] }).to eq(
["Opening balances", "Hosting", "Donation"],
)
expect(transactions.first["postings"].map { |posting| posting["account"] }).to eq(
%w[assets:bank:checking equity:alice equity:bob],
)
end
it "includes hledger's inferred amount for elided postings" do
report = builder.build("transactions", journal: <<~JOURNAL)
2024-01-01 Test
expenses:food 10.00 EUR
assets:bank
JOURNAL
postings = report["transactions"].first["postings"]
expect(postings.last["account"]).to eq("assets:bank")
expect(postings.last["amounts"].first["display"]).to eq("-10.00 EUR")
end
it "rejects journals with include directives" do
expect(Hledger::Journal.extract("```hledger\ninclude /etc/hostname\n```").error).to eq(
:include_not_allowed,
+20
View File
@@ -7,6 +7,7 @@ class FakeHledgerRunner
"balance_sheet" => "balance_sheet.json",
"income_statement" => "income_statement.json",
"equity" => "equity.json",
"transactions" => "transactions.json",
}.freeze
attr_reader :version
@@ -33,6 +34,7 @@ class FakeHledgerRunner
return "equity" if argv.include?("^equity:")
return "balance_sheet" if argv.include?("bs")
return "income_statement" if argv.include?("is")
return "transactions" if argv.include?("print")
"accounts"
end
@@ -112,6 +114,24 @@ RSpec.describe Hledger::ReportBuilder do
end
end
describe "transactions" do
it "returns each transaction with its postings" do
report = builder.build("transactions", journal: "journal")
transactions = report["transactions"]
expect(transactions.map { |txn| txn["date"] }).to eq(%w[2024-01-01 2024-02-10 2024-03-15])
expect(transactions.map { |txn| txn["description"] }).to eq(
["Opening balances", "Hosting", "Donation"],
)
opening = transactions.first
expect(opening["postings"].map { |posting| posting["account"] }).to eq(
%w[assets:bank:checking equity:alice equity:bob],
)
expect(opening["postings"].first["amounts"].first["display"]).to eq("1000.00 EUR")
end
end
describe "period" do
it "serializes the requested period" do
report =
+14
View File
@@ -17,6 +17,8 @@ class StubHledgerRunner
"balance_sheet.json"
elsif argv.include?("is")
"income_statement.json"
elsif argv.include?("print")
"transactions.json"
else
"accounts.json"
end
@@ -95,6 +97,18 @@ RSpec.describe Hledger::ReportsController do
)
end
it "returns the transactions log" do
get_report("transactions")
expect(response.status).to eq(200)
expect(response.parsed_body["transactions"].map { |txn| txn["description"] }).to eq(
["Opening balances", "Hosting", "Donation"],
)
expect(response.parsed_body["transactions"].first["postings"].first["account"]).to eq(
"assets:bank:checking",
)
end
it "rejects unsupported report types" do
get_report("nope")
+85 -1
View File
@@ -166,6 +166,60 @@ const incomeStatementReport = {
meta: { hledger_version: "1.52.1" },
};
const transactionsReport = {
report_type: "transactions",
period: { begin: null, end: null },
transactions: [
{
date: "2024-01-01",
date2: null,
description: "Opening balances",
code: "",
postings: [
{
account: "assets:bank:checking",
amounts: [
{ commodity: "EUR", quantity: "1000.0", display: "1000.00 EUR" },
],
},
{
account: "equity:alice",
amounts: [
{ commodity: "EUR", quantity: "-600.0", display: "-600.00 EUR" },
],
},
{
account: "equity:bob",
amounts: [
{ commodity: "EUR", quantity: "-400.0", display: "-400.00 EUR" },
],
},
],
},
{
date: "2024-02-10",
date2: null,
description: "Hosting",
code: "",
postings: [
{
account: "expenses:hosting:server",
amounts: [
{ commodity: "EUR", quantity: "20.5", display: "20.50 EUR" },
],
},
{
account: "assets:bank:checking",
amounts: [
{ commodity: "EUR", quantity: "-20.5", display: "-20.50 EUR" },
],
},
],
},
],
meta: { hledger_version: "1.52.1" },
};
acceptance("Hledger plugin", function (needs) {
needs.settings({ hledger_enabled: true });
@@ -188,12 +242,15 @@ acceptance("Hledger plugin", function (needs) {
server.get(`/hledger/topics/${TOPIC_ID}/reports/income_statement`, () =>
helper.response(incomeStatementReport)
);
server.get(`/hledger/topics/${TOPIC_ID}/reports/transactions`, () =>
helper.response(transactionsReport)
);
});
test("renders the dashboard in place of the journal", async function (assert) {
await visit("/t/-/45");
assert.dom(".hledger-dashboard__toolbar .btn").exists({ count: 4 });
assert.dom(".hledger-dashboard__toolbar .btn").exists({ count: 5 });
assert
.dom(".hledger-dashboard__toolbar .btn:nth-of-type(1)")
.hasText("Accounts");
@@ -206,6 +263,9 @@ acceptance("Hledger plugin", function (needs) {
assert
.dom(".hledger-dashboard__toolbar .btn:nth-of-type(4)")
.hasText("Equity");
assert
.dom(".hledger-dashboard__toolbar .btn:nth-of-type(5)")
.hasText("Transactions");
assert
.dom(
".hledger-dashboard__toolbar .btn:nth-of-type(2) .d-icon-scale-balanced"
@@ -342,4 +402,28 @@ acceptance("Hledger plugin", function (needs) {
.dom('.hledger-dashboard__account[title="expenses:hosting:server"]')
.hasText("Server");
});
test("shows the transaction log", async function (assert) {
await visit("/t/-/45");
await click(".hledger-dashboard__toolbar .btn:nth-of-type(5)");
assert
.dom(".hledger-dashboard__transaction")
.exists({ count: 2 }, "renders one block per transaction");
assert
.dom(
".hledger-dashboard__transaction:nth-of-type(1) .hledger-dashboard__transaction-date"
)
.hasText("2024-01-01", "shows the transaction date");
assert
.dom(
".hledger-dashboard__transaction:nth-of-type(1) .hledger-dashboard__transaction-description"
)
.hasText("Opening balances", "shows the transaction description");
assert
.dom(".hledger-dashboard__table")
.includesText("assets:bank:checking");
assert.dom(".hledger-dashboard__table").includesText("1000.00 EUR");
});
});