From 9f87fd66acea2e1551b6c850d6014c90eaa2cf7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 27 Sep 2026 13:12:31 +0200 Subject: [PATCH] Add Transactions view with detailed tx log --- README.md | 3 +- .../components/hledger-dashboard.gjs | 44 ++- assets/stylesheets/hledger.scss | 15 + config/locales/client.en.yml | 1 + lib/hledger/report_builder.rb | 28 +- plugin.rb | 1 + spec/fixtures/transactions.json | 294 ++++++++++++++++++ spec/lib/hledger/integration_spec.rb | 25 +- spec/lib/hledger/report_builder_spec.rb | 20 ++ spec/requests/hledger/reports_spec.rb | 14 + test/javascripts/acceptance/hledger-test.js | 86 ++++- 11 files changed, 526 insertions(+), 5 deletions(-) create mode 100644 spec/fixtures/transactions.json diff --git a/README.md b/README.md index e8d2e2a..5c0f540 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/assets/javascripts/discourse/components/hledger-dashboard.gjs b/assets/javascripts/discourse/components/hledger-dashboard.gjs index 6c36037..65e9bbe 100644 --- a/assets/javascripts/discourse/components/hledger-dashboard.gjs +++ b/assets/javascripts/discourse/components/hledger-dashboard.gjs @@ -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 { {{/each}}

{{this.labels.note}}

+ {{else if this.isTransactions}} + {{#if this.report.transactions.length}} + {{#each this.report.transactions as |transaction|}} +
+
+ {{transaction.date}} + {{transaction.description}} +
+ + + {{#each transaction.postings as |posting|}} + + + + + {{/each}} + +
{{amountText + posting.amounts + }}
+
+ {{/each}} + {{else}} +

{{this.labels.noData}}

+ {{/if}} {{else}} {{#each this.report.sections as |section|}}
diff --git a/assets/stylesheets/hledger.scss b/assets/stylesheets/hledger.scss index 7ce7889..90b7486 100644 --- a/assets/stylesheets/hledger.scss +++ b/assets/stylesheets/hledger.scss @@ -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; } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ec4876b..b348f42 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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" diff --git a/lib/hledger/report_builder.rb b/lib/hledger/report_builder.rb index 8349090..ce9cefc 100644 --- a/lib/hledger/report_builder.rb +++ b/lib/hledger/report_builder.rb @@ -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, diff --git a/plugin.rb b/plugin.rb index 41cbd66..0252a40 100644 --- a/plugin.rb +++ b/plugin.rb @@ -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 diff --git a/spec/fixtures/transactions.json b/spec/fixtures/transactions.json new file mode 100644 index 0000000..f5e9ae3 --- /dev/null +++ b/spec/fixtures/transactions.json @@ -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": [] + } +] diff --git a/spec/lib/hledger/integration_spec.rb b/spec/lib/hledger/integration_spec.rb index bde1041..5294f6c 100644 --- a/spec/lib/hledger/integration_spec.rb +++ b/spec/lib/hledger/integration_spec.rb @@ -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, diff --git a/spec/lib/hledger/report_builder_spec.rb b/spec/lib/hledger/report_builder_spec.rb index b1b1430..aa572b2 100644 --- a/spec/lib/hledger/report_builder_spec.rb +++ b/spec/lib/hledger/report_builder_spec.rb @@ -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 = diff --git a/spec/requests/hledger/reports_spec.rb b/spec/requests/hledger/reports_spec.rb index cd12b32..25a2f60 100644 --- a/spec/requests/hledger/reports_spec.rb +++ b/spec/requests/hledger/reports_spec.rb @@ -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") diff --git a/test/javascripts/acceptance/hledger-test.js b/test/javascripts/acceptance/hledger-test.js index 8d3b650..5d39ed6 100644 --- a/test/javascripts/acceptance/hledger-test.js +++ b/test/javascripts/acceptance/hledger-test.js @@ -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"); + }); });