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|}}
+
+
+
+
+ {{#each transaction.postings as |posting|}}
+
+ | {{posting.account}} |
+ {{amountText
+ posting.amounts
+ }} |
+
+ {{/each}}
+
+
+
+ {{/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");
+ });
});