diff --git a/README.md b/README.md
index cf677ab..4d58ab4 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,71 @@
-# **Hledger** Plugin
+# **hledger** Plugin
-**Plugin Summary**
+Render [hledger](https://hledger.org) journal reports inside Discourse topics.
-For more information, please see: **url to meta topic**
+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
+by the `hledger` command line tool:
+
+- Accounts and balances
+- Balance sheet
+- Income statement
+- Equity distribution (contributed capital, not legal ownership)
+
+## Installation
+
+Follow the [plugin installation guide](https://meta.discourse.org/t/install-a-plugin/19157).
+
+The plugin requires the `hledger` executable on the server. Install it with
+your package manager (for example `apt-get install hledger`) and point the
+`hledger path` site setting at it if it is not on the default `PATH`.
+
+### Docker development environment
+
+Inside the Discourse dev container, install the plugin's system dependencies
+(hledger, Chromium and the Playwright browser used by the test suite) with:
+
+```sh
+docker exec -u root discourse_dev bash /src/plugins/hledger/bin/setup-dev
+```
+
+## How to use
+
+1. Enable the plugin under `Admin > Settings > Plugins` (`hledger enabled`).
+2. Add an hledger journal to the first post of a topic:
+
+ ````
+ ```hledger
+ 2024-01-01 Opening balances
+ assets:bank:checking 1000.00 EUR
+ equity:alice -600.00 EUR
+ equity:bob -400.00 EUR
+ ```
+ ````
+
+3. The code block is replaced by the report dashboard. Reports can be filtered
+ by a start and end date; the end date is inclusive.
+
+Reports are generated on demand and cached per post revision, so editing the
+journal refreshes them.
+
+## Security
+
+Journals are untrusted input. The plugin runs `hledger` with a scrubbed
+environment, a private working directory, a hard timeout, resource limits and
+an output cap, and it rejects `include` directives so a journal cannot read
+arbitrary server files.
+
+## Development
+
+```sh
+# Ruby specs (run `bin/setup-dev` first for the integration specs)
+bin/rspec plugins/hledger/spec/lib plugins/hledger/spec/requests
+bin/rspec plugins/hledger/spec/system
+
+# Frontend tests
+DISCOURSE_DISABLE_BROWSER_SANDBOX=1 bin/qunit plugins/hledger/test/javascripts
+```
+
+## License
+
+MIT
diff --git a/app/controllers/hledger/examples_controller.rb b/app/controllers/hledger/examples_controller.rb
deleted file mode 100644
index 4a00d88..0000000
--- a/app/controllers/hledger/examples_controller.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module ::Hledger
- class ExamplesController < ::ApplicationController
- requires_plugin PLUGIN_NAME
-
- def index
- render json: { hello: "world" }
- end
- end
-end
diff --git a/app/controllers/hledger/reports_controller.rb b/app/controllers/hledger/reports_controller.rb
new file mode 100644
index 0000000..bdea383
--- /dev/null
+++ b/app/controllers/hledger/reports_controller.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+module ::Hledger
+ class ReportsController < ::ApplicationController
+ requires_plugin PLUGIN_NAME
+
+ RATE_LIMIT_MAX = 30
+ RATE_LIMIT_SECONDS = 60
+ CACHE_TTL = 5.minutes
+
+ before_action :ensure_enabled
+ before_action :find_topic
+ before_action :enforce_rate_limit
+
+ def show
+ report_type = params[:report_type].to_s
+
+ if !Hledger::ReportBuilder.supported?(report_type)
+ return render_json_error(I18n.t("hledger.errors.unsupported_report"), status: 422)
+ end
+
+ begin_date = parse_date(params[:begin])
+ end_date = parse_date(params[:end])
+
+ if begin_date == :invalid || end_date == :invalid
+ return render_json_error(I18n.t("hledger.errors.invalid_date"), status: 422)
+ end
+
+ first_post = @topic.first_post
+ cache_key = cache_key_for(first_post, report_type, begin_date, end_date)
+
+ if (cached = Discourse.cache.read(cache_key))
+ return render(json: cached)
+ end
+
+ Hledger::BuildReport.call(
+ params: {
+ raw: first_post&.raw,
+ report_type: report_type,
+ begin_date: begin_date,
+ end_date: end_date,
+ },
+ runner: Hledger::Runner.new,
+ ) do
+ on_success do |report:|
+ Discourse.cache.write(cache_key, report, expires_in: CACHE_TTL)
+ render json: report
+ end
+ on_failed_step(:extract_journal) { |step| render_journal_error(step.error) }
+ on_failed_contract do |contract|
+ render_json_error(contract.errors.full_messages, status: 422)
+ end
+ on_exceptions(Hledger::UnavailableError) do
+ render_json_error(I18n.t("hledger.errors.unavailable"), status: 503)
+ end
+ on_exceptions(Hledger::Error) do
+ render_json_error(I18n.t("hledger.errors.execution"), status: 422)
+ end
+ on_failure { render_json_error(I18n.t("hledger.errors.generic"), status: 422) }
+ end
+ end
+
+ private
+
+ def ensure_enabled
+ raise Discourse::NotFound if !SiteSetting.hledger_enabled
+ end
+
+ def find_topic
+ @topic = Topic.find_by(id: params[:topic_id])
+ raise Discourse::NotFound if @topic.blank?
+ raise Discourse::NotFound if !guardian.can_see?(@topic)
+ end
+
+ def enforce_rate_limit
+ RateLimiter.new(
+ current_user,
+ "hledger-reports-#{request.remote_ip}",
+ RATE_LIMIT_MAX,
+ RATE_LIMIT_SECONDS,
+ ).performed!
+ end
+
+ def parse_date(value)
+ return nil if value.blank?
+
+ Date.iso8601(value.to_s)
+ rescue Date::Error
+ :invalid
+ end
+
+ def cache_key_for(first_post, report_type, begin_date, end_date)
+ [
+ "hledger",
+ @topic.id,
+ first_post&.version,
+ report_type,
+ begin_date,
+ end_date,
+ SiteSetting.hledger_path,
+ ].join(":")
+ end
+
+ def render_journal_error(error)
+ key = "hledger.errors.journal.#{error}"
+ key = "hledger.errors.journal.invalid" if !I18n.exists?(key)
+
+ render_json_error(I18n.t(key), status: error == :none ? 404 : 422)
+ end
+ end
+end
diff --git a/app/services/hledger/build_report.rb b/app/services/hledger/build_report.rb
new file mode 100644
index 0000000..a391786
--- /dev/null
+++ b/app/services/hledger/build_report.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Hledger
+ class BuildReport
+ include Service::Base
+
+ params do
+ attribute :raw, :string
+ attribute :report_type, :string
+ attribute :begin_date
+ attribute :end_date
+
+ validates :report_type,
+ presence: true,
+ inclusion: {
+ in: Hledger::ReportBuilder::REPORT_TYPES,
+ }
+ end
+
+ step :extract_journal
+
+ try { step :build }
+
+ private
+
+ def extract_journal(params:)
+ result = Hledger::Journal.extract(params.raw)
+ fail!(result.error) unless result.ok?
+ context[:journal_source] = result.source
+ end
+
+ def build(params:, journal_source:, runner:)
+ builder = Hledger::ReportBuilder.new(runner:)
+ context[:report] = builder.build(
+ params.report_type,
+ journal: journal_source,
+ begin_date: params.begin_date,
+ end_date: params.end_date,
+ )
+ end
+ end
+end
diff --git a/assets/javascripts/discourse-markdown/hledger.js b/assets/javascripts/discourse-markdown/hledger.js
new file mode 100644
index 0000000..48d8abe
--- /dev/null
+++ b/assets/javascripts/discourse-markdown/hledger.js
@@ -0,0 +1,62 @@
+export function setup(helper) {
+ if (!helper.markdownIt) {
+ return;
+ }
+
+ helper.allowList(["div.hledger-dashboard"]);
+
+ helper.registerOptions((opts, siteSettings) => {
+ opts.features.hledger = siteSettings.hledger_enabled;
+ });
+
+ helper.registerPlugin((md) => {
+ if (!md.options.discourse.features.hledger) {
+ return;
+ }
+
+ md.block.ruler.before(
+ "fence",
+ "hledger_dashboard",
+ (state, startLine, endLine, silent) => {
+ const start = state.bMarks[startLine] + state.tShift[startLine];
+ const max = state.eMarks[startLine];
+ const opening = state.src.slice(start, max);
+
+ if (!/^\s*`{3,}hledger\s*$/.test(opening)) {
+ return false;
+ }
+
+ let closingLine = startLine + 1;
+ let closed = false;
+
+ for (; closingLine < endLine; closingLine++) {
+ const pos = state.bMarks[closingLine] + state.tShift[closingLine];
+ const lineMax = state.eMarks[closingLine];
+ if (/^\s*`{3,}\s*$/.test(state.src.slice(pos, lineMax))) {
+ closed = true;
+ break;
+ }
+ }
+
+ if (!closed) {
+ return false;
+ }
+
+ if (silent) {
+ return true;
+ }
+
+ const token = state.push("hledger_dashboard", "div", 0);
+ token.block = true;
+ token.map = [startLine, closingLine + 1];
+ state.line = closingLine + 1;
+
+ return true;
+ },
+ { alt: ["paragraph", "reference", "blockquote", "list"] }
+ );
+
+ md.renderer.rules.hledger_dashboard = () =>
+ `
\n`;
+ });
+}
diff --git a/assets/javascripts/discourse/api-initializers/hledger.js b/assets/javascripts/discourse/api-initializers/hledger.js
new file mode 100644
index 0000000..bfb5655
--- /dev/null
+++ b/assets/javascripts/discourse/api-initializers/hledger.js
@@ -0,0 +1,23 @@
+import { apiInitializer } from "discourse/lib/api";
+import HledgerDashboard from "../components/hledger-dashboard";
+
+export default apiInitializer((api) => {
+ api.decorateCookedElement(
+ (element, helper) => {
+ const node = element.querySelector(".hledger-dashboard");
+
+ if (!node || !helper) {
+ return;
+ }
+
+ const post = helper.getModel();
+
+ if (!post || post.post_number !== 1) {
+ return;
+ }
+
+ helper.renderGlimmer(node, HledgerDashboard, { post });
+ },
+ { onlyStream: true, id: "hledger" }
+ );
+});
diff --git a/assets/javascripts/discourse/components/hledger-dashboard.gjs b/assets/javascripts/discourse/components/hledger-dashboard.gjs
new file mode 100644
index 0000000..619b505
--- /dev/null
+++ b/assets/javascripts/discourse/components/hledger-dashboard.gjs
@@ -0,0 +1,247 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { fn } from "@ember/helper";
+import { action } from "@ember/object";
+import { ajax } from "discourse/lib/ajax";
+import { eq } from "discourse/truth-helpers";
+import DButton from "discourse/ui-kit/d-button";
+import DConditionalLoadingSpinner from "discourse/ui-kit/d-conditional-loading-spinner";
+import DDatePicker from "discourse/ui-kit/d-date-picker";
+import { i18n } from "discourse-i18n";
+
+const REPORT_IDS = ["accounts", "balance_sheet", "income_statement", "equity"];
+
+export default class HledgerDashboard extends Component {
+ @tracked selectedReport = "accounts";
+ @tracked report = null;
+ @tracked loading = false;
+ @tracked error = null;
+ @tracked beginDate = null;
+ @tracked endDate = null;
+
+ constructor() {
+ super(...arguments);
+ this.loadReport();
+ }
+
+ get reports() {
+ return REPORT_IDS.map((id) => ({
+ id,
+ label: i18n(`hledger.reports.${id}`),
+ icon: {
+ accounts: "list",
+ balance_sheet: "scale-balanced",
+ income_statement: "chart-line",
+ equity: "users",
+ }[id],
+ }));
+ }
+
+ get topicId() {
+ return this.args.data?.post?.topic_id;
+ }
+
+ get isEquity() {
+ return this.selectedReport === "equity";
+ }
+
+ get labels() {
+ return {
+ total: i18n("hledger.total"),
+ net: i18n("hledger.net"),
+ holder: i18n("hledger.equity.holder"),
+ amount: i18n("hledger.equity.amount"),
+ share: i18n("hledger.equity.share"),
+ note: i18n("hledger.equity.note"),
+ beginDate: i18n("hledger.filters.begin"),
+ endDate: i18n("hledger.filters.end"),
+ };
+ }
+
+ @action
+ select(reportId) {
+ if (this.selectedReport === reportId) {
+ return;
+ }
+
+ this.selectedReport = reportId;
+ this.loadReport();
+ }
+
+ @action
+ setBeginDate(value) {
+ this.beginDate = value;
+ this.loadReport();
+ }
+
+ @action
+ setEndDate(value) {
+ this.endDate = value;
+ this.loadReport();
+ }
+
+ @action
+ async loadReport() {
+ const topicId = this.topicId;
+
+ if (!topicId) {
+ return;
+ }
+
+ this.loading = true;
+ this.error = null;
+
+ const data = {};
+ if (this.beginDate) {
+ data.begin = this.beginDate;
+ }
+ if (this.endDate) {
+ data.end = this.endDate;
+ }
+
+ try {
+ this.report = await ajax(
+ `/hledger/topics/${topicId}/reports/${this.selectedReport}`,
+ { data }
+ );
+ } catch (e) {
+ this.report = null;
+ this.error =
+ e?.jqXHR?.responseJSON?.errors?.[0] || i18n("hledger.errors.generic");
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ amountText(amounts) {
+ return (amounts || []).map((amount) => amount.display).join(", ");
+ }
+
+ depthClass(depth) {
+ return `hledger-dashboard__account--depth-${Math.min(depth || 0, 5)}`;
+ }
+
+
+
+
+ {{#each this.reports as |report|}}
+
+ {{/each}}
+
+
+
+
+
+
+
+
+ {{#if this.error}}
+ {{this.error}}
+ {{else if this.report}}
+ {{#if this.isEquity}}
+ {{#each this.report.groups as |group|}}
+
+
{{group.commodity}}
+
+
+
+ | {{this.labels.holder}} |
+ {{this.labels.amount}} |
+ {{this.labels.share}} |
+
+
+
+ {{#each group.holders as |holder|}}
+
+ | {{holder.holder}} |
+ {{holder.amount.display}} |
+ {{holder.percentage}}% |
+
+ {{/each}}
+
+ | {{this.labels.total}} |
+ {{group.total.display}} |
+ |
+
+
+
+
+ {{/each}}
+ {{this.labels.note}}
+ {{else}}
+ {{#each this.report.sections as |section|}}
+
+ {{#if section.title}}
+
{{section.title}}
+ {{/if}}
+
+
+ {{#each section.rows as |row|}}
+
+ |
+ {{row.account}}
+ |
+ {{this.amountText
+ row.amounts
+ }} |
+
+ {{/each}}
+ {{#if section.total}}
+
+ | {{this.labels.total}} |
+ {{this.amountText
+ section.total
+ }} |
+
+ {{/if}}
+
+
+
+ {{/each}}
+ {{#if this.report.net}}
+
+ {{this.labels.net}}
+ {{this.amountText
+ this.report.net
+ }}
+
+ {{/if}}
+ {{/if}}
+ {{/if}}
+
+
+
+}
diff --git a/assets/stylesheets/hledger.scss b/assets/stylesheets/hledger.scss
new file mode 100644
index 0000000..782dda0
--- /dev/null
+++ b/assets/stylesheets/hledger.scss
@@ -0,0 +1,106 @@
+.hledger-dashboard {
+ margin: 1em 0;
+
+ &__inner {
+ padding: 0.75em 1em;
+ border: 1px solid var(--primary-low);
+ border-radius: var(--d-border-radius);
+ background: var(--primary-very-low);
+ }
+
+ &__toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5em;
+ margin-bottom: 0.5em;
+
+ .btn.is-active {
+ color: var(--secondary);
+ background: var(--tertiary);
+ }
+ }
+
+ &__filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5em;
+ margin-bottom: 0.75em;
+ }
+
+ &__section + &__section {
+ margin-top: 1em;
+ }
+
+ &__section-title {
+ margin: 0 0 0.25em;
+ font-size: var(--font-down-1);
+ color: var(--primary-medium);
+ }
+
+ &__table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-down-1);
+
+ th,
+ td {
+ padding: 0.25em 0.5em;
+ text-align: start;
+ border-bottom: 1px solid var(--primary-low);
+ }
+ }
+
+ &__amount {
+ text-align: end;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ }
+
+ &__account {
+ padding-inline-start: 0.5em;
+
+ &--depth-1 {
+ padding-inline-start: 1.5em;
+ }
+
+ &--depth-2 {
+ padding-inline-start: 2.5em;
+ }
+
+ &--depth-3 {
+ padding-inline-start: 3.5em;
+ }
+
+ &--depth-4 {
+ padding-inline-start: 4.5em;
+ }
+
+ &--depth-5 {
+ padding-inline-start: 5.5em;
+ }
+ }
+
+ &__total {
+ font-weight: bold;
+ }
+
+ &__net {
+ display: flex;
+ justify-content: space-between;
+ gap: 1em;
+ margin-top: 0.75em;
+ padding-top: 0.5em;
+ border-top: 2px solid var(--primary-low-mid);
+ font-weight: bold;
+ }
+
+ &__note {
+ margin: 0.75em 0 0;
+ font-size: var(--font-down-2);
+ color: var(--primary-medium);
+ }
+
+ &__error {
+ margin: 0;
+ }
+}
diff --git a/bin/setup-dev b/bin/setup-dev
new file mode 100755
index 0000000..deb0f96
--- /dev/null
+++ b/bin/setup-dev
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# Install the system dependencies needed to develop and test the hledger plugin.
+#
+# Run inside the dev container as root, for example:
+# docker exec -u root discourse_dev bash /src/plugins/hledger/bin/setup-dev
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo "Run as root inside the dev container, eg: docker exec -u root discourse_dev bash /src/plugins/hledger/bin/setup-dev" >&2
+ exit 1
+fi
+
+apt-get update
+DEBIAN_FRONTEND=noninteractive apt-get install -y hledger chromium chromium-driver chromium-sandbox
+
+# System specs use Playwright, which needs its own Chromium build for the
+# discourse user.
+runuser -u discourse -- bash -lc "cd /src && pnpm playwright-install"
+
+echo "Installed:"
+hledger --version | head -1
+chromium --version
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index b70743a..50135b9 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -6,4 +6,20 @@ en:
hledger: "Hledger"
js:
hledger:
- placeholder: placeholder
+ reports:
+ accounts: "Accounts"
+ balance_sheet: "Balance sheet"
+ income_statement: "Income statement"
+ equity: "Equity"
+ total: "Total"
+ net: "Net"
+ filters:
+ begin: "From"
+ end: "To"
+ equity:
+ holder: "Holder"
+ amount: "Amount"
+ share: "Share"
+ note: "Contributed-capital distribution, not legal ownership."
+ errors:
+ generic: "Could not load the report."
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 63f1c3e..6332948 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1 +1,22 @@
en:
+ site_settings:
+ hledger_enabled: "Enable the hledger plugin"
+ hledger_path: "Path to the hledger executable"
+ hledger_max_journal_bytes: "Maximum size of an hledger journal in bytes"
+ hledger_timeout_seconds: "Maximum number of seconds an hledger command may run"
+ hledger_max_output_bytes: "Maximum number of bytes captured from hledger output"
+ hledger:
+ errors:
+ unsupported_report: "Unsupported report type."
+ invalid_date: "Invalid date; expected YYYY-MM-DD."
+ unavailable: "hledger is not available on this server."
+ execution: "hledger could not generate the report."
+ generic: "The report could not be generated."
+ journal:
+ none: "No hledger journal was found in this topic."
+ ambiguous: "This topic contains more than one hledger journal."
+ too_large: "The hledger journal is too large."
+ too_many_lines: "The hledger journal has too many lines."
+ include_not_allowed: "The hledger journal uses an unsupported include directive."
+ invalid_encoding: "The hledger journal is not valid UTF-8."
+ invalid: "The hledger journal is invalid."
diff --git a/config/routes.rb b/config/routes.rb
index 5209339..f22142f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,8 +1,5 @@
# frozen_string_literal: true
-Hledger::Engine.routes.draw do
- get "/examples" => "examples#index"
- # define routes here
-end
+Hledger::Engine.routes.draw { get "/topics/:topic_id/reports/:report_type" => "reports#show" }
Discourse::Application.routes.draw { mount ::Hledger::Engine, at: "hledger" }
diff --git a/config/settings.yml b/config/settings.yml
index 7e7d9dc..6d51543 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -2,3 +2,11 @@ hledger:
hledger_enabled:
default: false
client: true
+ hledger_path:
+ default: hledger
+ hledger_max_journal_bytes:
+ default: 262144
+ hledger_timeout_seconds:
+ default: 10
+ hledger_max_output_bytes:
+ default: 1048576
diff --git a/lib/hledger/equity.rb b/lib/hledger/equity.rb
new file mode 100644
index 0000000..2c8e640
--- /dev/null
+++ b/lib/hledger/equity.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require "bigdecimal"
+
+module Hledger
+ 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) } }
+
+ 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"] }
+ end
+
+ def holder_name(account)
+ parts = account.to_s.split(":")
+ return nil unless parts.first == "equity"
+
+ parts[1]
+ end
+
+ def percentage(quantity, total)
+ return "0.00" if total.zero?
+
+ 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/error.rb b/lib/hledger/error.rb
new file mode 100644
index 0000000..8a9075e
--- /dev/null
+++ b/lib/hledger/error.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Hledger
+ class Error < StandardError
+ end
+end
diff --git a/lib/hledger/execution_error.rb b/lib/hledger/execution_error.rb
new file mode 100644
index 0000000..73ca541
--- /dev/null
+++ b/lib/hledger/execution_error.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Hledger
+ class ExecutionError < Error
+ end
+end
diff --git a/lib/hledger/journal.rb b/lib/hledger/journal.rb
new file mode 100644
index 0000000..1d154bc
--- /dev/null
+++ b/lib/hledger/journal.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Hledger
+ # Extracts the single fenced `hledger` journal from a post's raw markdown and
+ # rejects input that could make hledger read files or resources we do not
+ # control.
+ class Journal
+ FENCE = /^[ \t]*`{3,}hledger[ \t]*\r?\n(?.*?)^[ \t]*`{3,}[ \t]*$/m
+ INCLUDE = /^[ \t]*!?include\b/i
+ MAX_LINES = 5_000
+
+ Result =
+ Struct.new(:source, :error, keyword_init: true) do
+ def ok?
+ error.nil?
+ end
+
+ def none?
+ error == :none
+ end
+ end
+
+ def self.extract(raw)
+ new(raw).extract
+ end
+
+ def initialize(raw)
+ @raw = raw.to_s
+ end
+
+ def extract
+ matches = @raw.scan(FENCE).flatten
+ return Result.new(source: nil, error: :none) if matches.empty?
+ return Result.new(source: nil, error: :ambiguous) if matches.length > 1
+
+ source = matches.first
+ validation_error = validate(source)
+ return Result.new(source: nil, error: validation_error) if validation_error
+
+ Result.new(source: source, error: nil)
+ end
+
+ private
+
+ def validate(source)
+ return :too_large if source.bytesize > SiteSetting.hledger_max_journal_bytes
+ return :too_many_lines if source.lines.size > MAX_LINES
+ return :invalid_encoding unless source.valid_encoding?
+ return :include_not_allowed if source.match?(INCLUDE)
+
+ nil
+ end
+ end
+end
diff --git a/lib/hledger/parse_error.rb b/lib/hledger/parse_error.rb
new file mode 100644
index 0000000..4839047
--- /dev/null
+++ b/lib/hledger/parse_error.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Hledger
+ class ParseError < Error
+ end
+end
diff --git a/lib/hledger/report_builder.rb b/lib/hledger/report_builder.rb
new file mode 100644
index 0000000..7e93a9b
--- /dev/null
+++ b/lib/hledger/report_builder.rb
@@ -0,0 +1,255 @@
+# frozen_string_literal: true
+
+require "bigdecimal"
+require "csv"
+require "json"
+
+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
+ COMPOUND_COMMANDS = { "balance_sheet" => "bs", "income_statement" => "is" }.freeze
+
+ def self.supported?(type)
+ REPORT_TYPES.include?(type.to_s)
+ end
+
+ def initialize(runner: Runner.new)
+ @runner = runner
+ end
+
+ def build(report_type, journal:, begin_date: nil, end_date: nil)
+ raise UnavailableError if @runner.version.blank?
+
+ case report_type.to_s
+ when "accounts"
+ build_accounts(journal, begin_date, end_date)
+ when "balance_sheet", "income_statement"
+ build_compound(report_type.to_s, journal, begin_date, end_date)
+ when "equity"
+ build_equity(journal, begin_date, end_date)
+ else
+ raise Error, "unsupported report type: #{report_type}"
+ end
+ end
+
+ private
+
+ def build_accounts(journal, begin_date, end_date)
+ result = run(["bal", "-O", "json", *balance_flags(begin_date, end_date)], journal)
+ rows, total = parse_json(result.stdout)
+
+ sections = [
+ {
+ "title" => nil,
+ "rows" => Array(rows).map { |row| account_row(row) },
+ "total" => amounts(Array(total)),
+ },
+ ]
+
+ envelope("accounts", begin_date, end_date, "sections" => sections, "net" => nil)
+ end
+
+ def build_compound(report_type, journal, begin_date, end_date)
+ flags =
+ (
+ if report_type == "balance_sheet"
+ balance_flags(begin_date, end_date)
+ else
+ period_flags(begin_date, end_date)
+ end
+ )
+ result = run([COMPOUND_COMMANDS.fetch(report_type), "-O", "csv", *flags], journal)
+ sections, net = parse_compound_csv(result.stdout)
+
+ envelope(report_type, begin_date, end_date, "sections" => sections, "net" => net)
+ end
+
+ 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))
+ end
+
+ def envelope(report_type, begin_date, end_date, **extra)
+ {
+ "report_type" => report_type,
+ "period" => {
+ "begin" => begin_date&.iso8601,
+ "end" => end_date&.iso8601,
+ },
+ "meta" => {
+ "hledger_version" => @runner.version,
+ },
+ }.merge(extra)
+ end
+
+ def run(argv, journal)
+ result = @runner.execute([*base_flags, *argv], stdin_data: journal)
+ raise ExecutionError, result.stderr if result.timed_out
+ raise ExecutionError, result.stderr unless result.success?
+
+ result
+ end
+
+ def base_flags
+ flags = ["-f-"]
+ flags << "-n" if no_conf_supported?
+ flags
+ end
+
+ def no_conf_supported?
+ Gem::Version.new(@runner.version) >= Gem::Version.new("1.40")
+ rescue ArgumentError
+ false
+ end
+
+ def parse_json(text)
+ JSON.parse(text)
+ rescue JSON::ParserError => e
+ raise ParseError, e.message
+ end
+
+ def account_row(row)
+ display, full, _depth, row_amounts = row
+ account = (full || display).to_s
+
+ {
+ "account" => account,
+ "depth" => depth_of(account),
+ "amounts" => amounts(Array(row_amounts)),
+ }
+ end
+
+ def parse_compound_csv(text)
+ rows = CSV.parse(text)
+ sections = []
+ current = nil
+ net = nil
+
+ rows.each_with_index do |row, index|
+ next if index < 2
+
+ label = row[0].to_s
+ raw_amount = row[1].to_s
+ next if label.blank?
+
+ case label
+ when "Total:"
+ current["total"] = [parse_amount_string(raw_amount)] if current
+ when "Net:"
+ net = [parse_amount_string(raw_amount)]
+ else
+ if raw_amount.blank?
+ current = { "title" => label, "rows" => [], "total" => nil }
+ sections << current
+ elsif current
+ current["rows"] << {
+ "account" => label,
+ "depth" => depth_of(label),
+ "amounts" => [parse_amount_string(raw_amount)],
+ }
+ end
+ end
+ end
+
+ [sections, net]
+ end
+
+ def amounts(list)
+ Array(list).map { |amount| amount_to_h(amount) }
+ end
+
+ def amount_to_h(amount)
+ quantity = quantity_of(amount["aquantity"])
+ style = amount["astyle"] || {}
+ commodity = amount["acommodity"].to_s
+
+ {
+ "commodity" => commodity,
+ "quantity" => quantity.to_s("F"),
+ "display" => display_amount(quantity, commodity, style),
+ "side" => style["ascommodityside"] || "R",
+ "spaced" => style["ascommodityspaced"] ? true : false,
+ "precision" => style["asprecision"],
+ }
+ end
+
+ def parse_amount_string(raw)
+ text = raw.to_s.strip
+ return nil if text.empty?
+
+ if (match = text.match(/\A(-?\d+(?:[.,]\d+)?)\s*(.*)\z/))
+ number = match[1]
+ commodity = match[2].strip
+ side = "R"
+ elsif (match = text.match(/\A(.*?)\s*(-?\d+(?:[.,]\d+)?)\z/))
+ commodity = match[1].strip
+ number = match[2]
+ side = "L"
+ else
+ return nil
+ end
+
+ quantity = BigDecimal(number.tr(",", "."))
+
+ {
+ "commodity" => commodity,
+ "quantity" => quantity.to_s("F"),
+ "display" => text,
+ "side" => side,
+ "spaced" => commodity.present? && text.include?(" "),
+ "precision" => number.match?(/[.,]/) ? number.split(/[.,]/).last.length : 0,
+ }
+ end
+
+ def quantity_of(aquantity)
+ return BigDecimal(0) if aquantity.blank?
+
+ mantissa = aquantity["decimalMantissa"].to_i
+ places = aquantity["decimalPlaces"].to_i
+ BigDecimal(mantissa) / (10**places)
+ end
+
+ def display_amount(quantity, commodity, style)
+ precision = style["asprecision"]
+ if precision.nil?
+ rendered = quantity.to_s("F")
+ precision = rendered.include?(".") ? rendered.split(".").last.length : 0
+ end
+ mark = style["asdecimalmark"].presence || "."
+
+ number = format("%.#{precision}f", quantity)
+ number = number.tr(".", mark) if mark != "."
+
+ return number if commodity.blank?
+
+ spaced = style["ascommodityspaced"] ? " " : ""
+ if style["ascommodityside"] == "L"
+ "#{commodity}#{spaced}#{number}"
+ else
+ "#{number}#{spaced}#{commodity}"
+ end
+ end
+
+ def depth_of(account)
+ account.to_s.count(":")
+ end
+
+ def balance_flags(begin_date, end_date)
+ flags = []
+ flags += ["-e", (end_date + 1).iso8601] if end_date
+ flags
+ end
+
+ def period_flags(begin_date, end_date)
+ flags = []
+ flags += ["-b", begin_date.iso8601] if begin_date
+ flags += ["-e", (end_date + 1).iso8601] if end_date
+ flags
+ end
+ end
+end
diff --git a/lib/hledger/runner.rb b/lib/hledger/runner.rb
new file mode 100644
index 0000000..2f8be52
--- /dev/null
+++ b/lib/hledger/runner.rb
@@ -0,0 +1,163 @@
+# frozen_string_literal: true
+
+require "open3"
+require "tmpdir"
+require "fileutils"
+
+module Hledger
+ # Runs the hledger binary in a locked-down child process: scrubbed
+ # environment, private working directory, process group, CPU/file limits and
+ # a hard wall-clock timeout.
+ class Runner
+ Result =
+ Struct.new(:success, :stdout, :stderr, :exit_status, :timed_out, keyword_init: true) do
+ def success?
+ success
+ end
+ end
+
+ READ_CHUNK = 16_384
+
+ def initialize(path: nil, timeout: nil, max_output: nil)
+ @path = (path || SiteSetting.hledger_path).to_s
+ @timeout = (timeout || SiteSetting.hledger_timeout_seconds).to_i
+ @max_output = (max_output || SiteSetting.hledger_max_output_bytes).to_i
+ end
+
+ def available?
+ version.present?
+ end
+
+ def version
+ return @version if defined?(@version)
+
+ @version = detect_version
+ end
+
+ def execute(argv, stdin_data: nil)
+ binary = resolve_binary
+ return failure_result if binary.nil?
+
+ run(binary, argv, stdin_data)
+ end
+
+ private
+
+ def run(binary, argv, stdin_data)
+ tmpdir = Dir.mktmpdir("hledger-")
+ env = {
+ "PATH" => ENV["PATH"].to_s,
+ "HOME" => tmpdir,
+ "TMPDIR" => tmpdir,
+ "XDG_CACHE_HOME" => tmpdir,
+ "XDG_CONFIG_HOME" => tmpdir,
+ "NO_COLOR" => "1",
+ "LANG" => "C.UTF-8",
+ "LC_ALL" => "C.UTF-8",
+ }
+ spawn_opts = {
+ pgroup: true,
+ unsetenv_others: true,
+ chdir: tmpdir,
+ rlimit_cpu: @timeout + 5,
+ rlimit_nofile: 64,
+ }
+
+ status = nil
+ timed_out = false
+ stdout = +""
+ stderr = +""
+
+ Open3.popen3(env, binary, *argv, **spawn_opts) do |stdin, out, err, wait_thr|
+ writer = Thread.new { write_stdin(stdin, stdin_data) }
+ out_reader = Thread.new { read_capped(out) }
+ err_reader = Thread.new { read_capped(err) }
+
+ if wait_thr.join(@timeout)
+ status = wait_thr.value
+ else
+ timed_out = true
+ kill_group(wait_thr.pid)
+ wait_thr.join(2)
+ end
+
+ writer.join(1)
+ stdout = out_reader.value
+ stderr = err_reader.value
+ end
+
+ Result.new(
+ success: status&.success? && !timed_out,
+ stdout: stdout,
+ stderr: stderr,
+ exit_status: status&.exitstatus,
+ timed_out: timed_out,
+ )
+ rescue SystemCallError
+ failure_result
+ ensure
+ FileUtils.remove_entry(tmpdir) if tmpdir && Dir.exist?(tmpdir)
+ end
+
+ def write_stdin(stdin, data)
+ stdin.write(data) if data
+ stdin.close
+ rescue IOError, Errno::EPIPE
+ end
+
+ def read_capped(io)
+ buffer = +""
+ while (chunk = io.read(READ_CHUNK))
+ break if chunk.empty?
+
+ remaining = @max_output - buffer.bytesize
+ buffer << chunk.byteslice(0, remaining) if remaining.positive?
+ end
+ buffer
+ rescue IOError, Errno::EPIPE
+ buffer
+ ensure
+ io.close
+ end
+
+ def kill_group(pid)
+ Process.kill("KILL", -pid)
+ rescue Errno::ESRCH, Errno::EPERM
+ end
+
+ def resolve_binary
+ return @binary if defined?(@binary)
+
+ @binary =
+ if @path.include?(File::SEPARATOR)
+ File.executable?(@path) ? @path : nil
+ else
+ which(@path)
+ end
+ end
+
+ def which(name)
+ return nil if name.blank?
+
+ ENV["PATH"]
+ .to_s
+ .split(File::PATH_SEPARATOR)
+ .each do |dir|
+ candidate = File.join(dir, name)
+ return candidate if File.file?(candidate) && File.executable?(candidate)
+ end
+ nil
+ end
+
+ def detect_version
+ result = execute(["--version"])
+ return nil unless result.success?
+
+ result.stdout[/\d+\.\d+(?:\.\d+)?/]
+ end
+
+ def failure_result
+ Result.new(success: false, stdout: "", stderr: "", exit_status: nil, timed_out: false)
+ end
+ end
+end
diff --git a/lib/hledger/unavailable_error.rb b/lib/hledger/unavailable_error.rb
new file mode 100644
index 0000000..8143e83
--- /dev/null
+++ b/lib/hledger/unavailable_error.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Hledger
+ class UnavailableError < Error
+ end
+end
diff --git a/plugin.rb b/plugin.rb
index d5bd998..27bc7c6 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -1,13 +1,15 @@
# frozen_string_literal: true
# name: hledger
-# about: TODO
+# about: Render hledger journals in topics
# meta_topic_id: TODO
# version: 0.0.1
-# authors: Discourse
+# authors: Râu Cao
# url: TODO
# required_version: 2.7.0
+register_asset "stylesheets/hledger.scss"
+
enabled_site_setting :hledger_enabled
module ::Hledger
@@ -15,7 +17,3 @@ module ::Hledger
end
require_relative "lib/hledger/engine"
-
-after_initialize do
- # Code which should run after Rails has finished booting
-end
diff --git a/spec/fixtures/accounts.json b/spec/fixtures/accounts.json
new file mode 100644
index 0000000..3839f2e
--- /dev/null
+++ b/spec/fixtures/accounts.json
@@ -0,0 +1,150 @@
+[
+ [
+ [
+ "assets:bank:checking",
+ "assets:bank:checking",
+ 0,
+ [
+ {
+ "acommodity": "EUR",
+ "acost": null,
+ "acostbasis": null,
+ "aquantity": {
+ "decimalMantissa": 102950,
+ "decimalPlaces": 2,
+ "floatingPoint": 1029.5
+ },
+ "astyle": {
+ "ascommodityside": "R",
+ "ascommodityspaced": true,
+ "asdecimalmark": ".",
+ "asdigitgroups": null,
+ "asprecision": 2,
+ "asrounding": "HardRounding"
+ }
+ }
+ ]
+ ],
+ [
+ "equity:alice",
+ "equity:alice",
+ 0,
+ [
+ {
+ "acommodity": "EUR",
+ "acost": null,
+ "acostbasis": null,
+ "aquantity": {
+ "decimalMantissa": -60000,
+ "decimalPlaces": 2,
+ "floatingPoint": -600
+ },
+ "astyle": {
+ "ascommodityside": "R",
+ "ascommodityspaced": true,
+ "asdecimalmark": ".",
+ "asdigitgroups": null,
+ "asprecision": 2,
+ "asrounding": "HardRounding"
+ }
+ }
+ ]
+ ],
+ [
+ "equity:bob",
+ "equity:bob",
+ 0,
+ [
+ {
+ "acommodity": "EUR",
+ "acost": null,
+ "acostbasis": null,
+ "aquantity": {
+ "decimalMantissa": -40000,
+ "decimalPlaces": 2,
+ "floatingPoint": -400
+ },
+ "astyle": {
+ "ascommodityside": "R",
+ "ascommodityspaced": true,
+ "asdecimalmark": ".",
+ "asdigitgroups": null,
+ "asprecision": 2,
+ "asrounding": "HardRounding"
+ }
+ }
+ ]
+ ],
+ [
+ "expenses:hosting:server",
+ "expenses:hosting:server",
+ 0,
+ [
+ {
+ "acommodity": "EUR",
+ "acost": null,
+ "acostbasis": null,
+ "aquantity": {
+ "decimalMantissa": 2050,
+ "decimalPlaces": 2,
+ "floatingPoint": 20.5
+ },
+ "astyle": {
+ "ascommodityside": "R",
+ "ascommodityspaced": true,
+ "asdecimalmark": ".",
+ "asdigitgroups": null,
+ "asprecision": 2,
+ "asrounding": "HardRounding"
+ }
+ }
+ ]
+ ],
+ [
+ "revenues:donations",
+ "revenues:donations",
+ 0,
+ [
+ {
+ "acommodity": "EUR",
+ "acost": null,
+ "acostbasis": null,
+ "aquantity": {
+ "decimalMantissa": -5000,
+ "decimalPlaces": 2,
+ "floatingPoint": -50
+ },
+ "astyle": {
+ "ascommodityside": "R",
+ "ascommodityspaced": true,
+ "asdecimalmark": ".",
+ "asdigitgroups": null,
+ "asprecision": 2,
+ "asrounding": "HardRounding"
+ }
+ }
+ ]
+ ]
+ ],
+ [
+ {
+ "acommodity": "EUR",
+ "acost": null,
+ "acostbasis": null,
+ "aquantity": {
+ "decimalMantissa": 0,
+ "decimalPlaces": 2,
+ "floatingPoint": 0
+ },
+ "astyle": {
+ "ascommodityside": "R",
+ "ascommodityspaced": true,
+ "asdecimalmark": ".",
+ "asdigitgroups": null,
+ "asprecision": 2,
+ "asrounding": "HardRounding"
+ }
+ }
+ ]
+]
+
diff --git a/spec/fixtures/balance_sheet.csv b/spec/fixtures/balance_sheet.csv
new file mode 100644
index 0000000..5368822
--- /dev/null
+++ b/spec/fixtures/balance_sheet.csv
@@ -0,0 +1,8 @@
+"Balance Sheet 2024-03-15",""
+"Account","2024-03-15"
+"Assets",""
+"assets:bank:checking","1029.50 EUR"
+"Total:","1029.50 EUR"
+"Liabilities",""
+"Total:","0"
+"Net:","1029.50 EUR"
diff --git a/spec/fixtures/equity.json b/spec/fixtures/equity.json
new file mode 100644
index 0000000..26ac8c3
--- /dev/null
+++ b/spec/fixtures/equity.json
@@ -0,0 +1,75 @@
+[
+ [
+ [
+ "equity:alice",
+ "equity:alice",
+ 0,
+ [
+ {
+ "acommodity": "EUR",
+ "acost": null,
+ "acostbasis": null,
+ "aquantity": {
+ "decimalMantissa": -60000,
+ "decimalPlaces": 2,
+ "floatingPoint": -600
+ },
+ "astyle": {
+ "ascommodityside": "R",
+ "ascommodityspaced": true,
+ "asdecimalmark": ".",
+ "asdigitgroups": null,
+ "asprecision": 2,
+ "asrounding": "HardRounding"
+ }
+ }
+ ]
+ ],
+ [
+ "equity:bob",
+ "equity:bob",
+ 0,
+ [
+ {
+ "acommodity": "EUR",
+ "acost": null,
+ "acostbasis": null,
+ "aquantity": {
+ "decimalMantissa": -40000,
+ "decimalPlaces": 2,
+ "floatingPoint": -400
+ },
+ "astyle": {
+ "ascommodityside": "R",
+ "ascommodityspaced": true,
+ "asdecimalmark": ".",
+ "asdigitgroups": null,
+ "asprecision": 2,
+ "asrounding": "HardRounding"
+ }
+ }
+ ]
+ ]
+ ],
+ [
+ {
+ "acommodity": "EUR",
+ "acost": null,
+ "acostbasis": null,
+ "aquantity": {
+ "decimalMantissa": -100000,
+ "decimalPlaces": 2,
+ "floatingPoint": -1000
+ },
+ "astyle": {
+ "ascommodityside": "R",
+ "ascommodityspaced": true,
+ "asdecimalmark": ".",
+ "asdigitgroups": null,
+ "asprecision": 2,
+ "asrounding": "HardRounding"
+ }
+ }
+ ]
+]
+
diff --git a/spec/fixtures/income_statement.csv b/spec/fixtures/income_statement.csv
new file mode 100644
index 0000000..da1eb2f
--- /dev/null
+++ b/spec/fixtures/income_statement.csv
@@ -0,0 +1,9 @@
+"Income Statement 2024-01-01..2024-03-15",""
+"Account","2024-01-01..2024-03-15"
+"Revenues",""
+"revenues:donations","50.00 EUR"
+"Total:","50.00 EUR"
+"Expenses",""
+"expenses:hosting:server","20.50 EUR"
+"Total:","20.50 EUR"
+"Net:","29.50 EUR"
diff --git a/spec/fixtures/journal.txt b/spec/fixtures/journal.txt
new file mode 100644
index 0000000..f696e04
--- /dev/null
+++ b/spec/fixtures/journal.txt
@@ -0,0 +1,12 @@
+2024-01-01 Opening balances
+ assets:bank:checking 1000.00 EUR
+ equity:alice -600.00 EUR
+ equity:bob -400.00 EUR
+
+2024-02-10 Hosting
+ expenses:hosting:server 20.50 EUR
+ assets:bank:checking -20.50 EUR
+
+2024-03-15 Donation
+ assets:bank:checking 50.00 EUR
+ revenues:donations -50.00 EUR
diff --git a/spec/lib/hledger/equity_spec.rb b/spec/lib/hledger/equity_spec.rb
new file mode 100644
index 0000000..605f0b7
--- /dev/null
+++ b/spec/lib/hledger/equity_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+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" }]],
+ ]
+
+ group = described_class.distribute(rows).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])
+ 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.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])
+ end
+
+ it "ignores accounts outside of equity" do
+ rows = [["assets:bank", [{ "commodity" => "EUR", "quantity" => "100" }]]]
+
+ expect(described_class.distribute(rows)).to eq([])
+ end
+end
diff --git a/spec/lib/hledger/integration_spec.rb b/spec/lib/hledger/integration_spec.rb
new file mode 100644
index 0000000..f566136
--- /dev/null
+++ b/spec/lib/hledger/integration_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# rubocop:disable RSpec/DescribeClass
+RSpec.describe "Hledger integration" do
+ before { skip "hledger is not installed" if !Hledger::Runner.new.available? }
+
+ let(:journal) { File.read(Rails.root.join("plugins/hledger/spec/fixtures/journal.txt")) }
+ let(:builder) { Hledger::ReportBuilder.new }
+
+ it "builds every supported report" do
+ %w[accounts balance_sheet income_statement equity].each do |type|
+ expect(builder.build(type, journal: journal)["report_type"]).to eq(type)
+ end
+ end
+
+ it "treats the end date as inclusive" do
+ report =
+ builder.build(
+ "income_statement",
+ journal: journal,
+ begin_date: Date.new(2024, 1, 1),
+ end_date: Date.new(2024, 3, 15),
+ )
+
+ expect(report["net"].first["display"]).to eq("29.50 EUR")
+ end
+
+ it "computes the equity distribution" do
+ holders = builder.build("equity", journal: journal)["groups"].first["holders"]
+
+ expect(holders.map { |holder| [holder["holder"], holder["percentage"]] }).to eq(
+ [%w[alice 60.00], %w[bob 40.00]],
+ )
+ end
+
+ it "rejects journals with include directives" do
+ expect(Hledger::Journal.extract("```hledger\ninclude /etc/hostname\n```").error).to eq(
+ :include_not_allowed,
+ )
+ end
+end
+# rubocop:enable RSpec/DescribeClass
diff --git a/spec/lib/hledger/journal_spec.rb b/spec/lib/hledger/journal_spec.rb
new file mode 100644
index 0000000..804ab39
--- /dev/null
+++ b/spec/lib/hledger/journal_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+RSpec.describe Hledger::Journal do
+ def raw_with(journal)
+ "Intro\n\n```hledger\n#{journal}\n```\n\nOutro\n"
+ end
+
+ it "extracts a single fenced journal" do
+ result = described_class.extract(raw_with("2024-01-01 x\n a 1\n b -1"))
+
+ expect(result).to be_ok
+ expect(result.source).to include("2024-01-01 x")
+ end
+
+ it "returns :none when there is no journal" do
+ expect(described_class.extract("just text").error).to eq(:none)
+ end
+
+ it "returns :ambiguous for multiple journals" do
+ raw = <<~RAW
+ ```hledger
+ 2024-01-01 x
+ a 1
+ b -1
+ ```
+ ```hledger
+ 2024-01-02 y
+ a 1
+ b -1
+ ```
+ RAW
+
+ expect(described_class.extract(raw).error).to eq(:ambiguous)
+ end
+
+ it "rejects include directives" do
+ result = described_class.extract(raw_with("include /etc/hostname"))
+
+ expect(result.error).to eq(:include_not_allowed)
+ end
+
+ it "rejects oversized journals" do
+ SiteSetting.hledger_max_journal_bytes = 10
+
+ result = described_class.extract(raw_with("2024-01-01 xxxxxxxxxxxxxxxxxxxx"))
+
+ expect(result.error).to eq(:too_large)
+ ensure
+ SiteSetting.hledger_max_journal_bytes = 262_144
+ end
+end
diff --git a/spec/lib/hledger/report_builder_spec.rb b/spec/lib/hledger/report_builder_spec.rb
new file mode 100644
index 0000000..5e5c862
--- /dev/null
+++ b/spec/lib/hledger/report_builder_spec.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+class FakeHledgerRunner
+ FIXTURES = Rails.root.join("plugins/hledger/spec/fixtures")
+ FILES = {
+ "accounts" => "accounts.json",
+ "balance_sheet" => "balance_sheet.csv",
+ "income_statement" => "income_statement.csv",
+ "equity" => "equity.json",
+ }.freeze
+
+ attr_reader :version
+
+ def initialize(version: "1.52.1")
+ @version = version
+ end
+
+ def available?
+ version.present?
+ end
+
+ def execute(argv, stdin_data: nil)
+ Hledger::Runner::Result.new(
+ success: true,
+ stdout: File.read(FIXTURES.join(FILES.fetch(detect(argv)))),
+ stderr: "",
+ exit_status: 0,
+ timed_out: false,
+ )
+ end
+
+ def detect(argv)
+ return "equity" if argv.include?("^equity:")
+ return "balance_sheet" if argv.include?("bs")
+ return "income_statement" if argv.include?("is")
+
+ "accounts"
+ end
+end
+
+RSpec.describe Hledger::ReportBuilder do
+ subject(:builder) { described_class.new(runner: FakeHledgerRunner.new) }
+
+ describe "accounts" do
+ it "returns flat account rows with totals" do
+ report = builder.build("accounts", journal: "journal")
+
+ expect(report["report_type"]).to eq("accounts")
+ rows = report["sections"].first["rows"]
+ expect(rows.map { |row| row["account"] }).to include("assets:bank:checking", "equity:alice")
+ checking = rows.find { |row| row["account"] == "assets:bank:checking" }
+ expect(checking["depth"]).to eq(2)
+ expect(checking["amounts"].first["display"]).to eq("1029.50 EUR")
+ expect(report["sections"].first["total"].first["display"]).to eq("0.00 EUR")
+ end
+ end
+
+ describe "balance sheet" do
+ it "returns sections, totals and net" do
+ report = builder.build("balance_sheet", journal: "journal")
+
+ expect(report["sections"].map { |s| s["title"] }).to eq(%w[Assets Liabilities])
+ assets = report["sections"].first
+ expect(assets["total"].first["display"]).to eq("1029.50 EUR")
+ expect(report["net"].first["display"]).to eq("1029.50 EUR")
+ end
+ end
+
+ describe "income statement" do
+ it "returns sections, totals and net" do
+ report = builder.build("income_statement", journal: "journal")
+
+ expect(report["sections"].map { |s| s["title"] }).to eq(%w[Revenues Expenses])
+ expect(report["net"].first["display"]).to eq("29.50 EUR")
+ end
+ end
+
+ describe "equity" do
+ it "distributes contributed capital per holder" 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])
+ end
+ end
+
+ describe "period" do
+ it "serializes the requested period" do
+ report =
+ builder.build(
+ "income_statement",
+ journal: "journal",
+ begin_date: Date.new(2024, 1, 1),
+ end_date: Date.new(2024, 3, 15),
+ )
+
+ expect(report["period"]).to eq("begin" => "2024-01-01", "end" => "2024-03-15")
+ expect(report["meta"]["hledger_version"]).to eq("1.52.1")
+ end
+ end
+
+ it "raises for unsupported report types" do
+ expect { builder.build("nope", journal: "journal") }.to raise_error(Hledger::Error)
+ end
+
+ it "raises when hledger is unavailable" do
+ unavailable = FakeHledgerRunner.new(version: nil)
+
+ expect {
+ described_class.new(runner: unavailable).build("accounts", journal: "x")
+ }.to raise_error(Hledger::UnavailableError)
+ end
+end
diff --git a/spec/lib/hledger/runner_spec.rb b/spec/lib/hledger/runner_spec.rb
new file mode 100644
index 0000000..319e85e
--- /dev/null
+++ b/spec/lib/hledger/runner_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+RSpec.describe Hledger::Runner do
+ def runner(path:, timeout: 5, max_output: 1_000)
+ described_class.new(path: path, timeout: timeout, max_output: max_output)
+ end
+
+ it "captures stdout and exit status" do
+ result = runner(path: "/bin/echo").execute(["hello"])
+
+ expect(result).to be_success
+ expect(result.stdout).to eq("hello\n")
+ expect(result.exit_status).to eq(0)
+ end
+
+ it "reports failure for a non-zero exit" do
+ expect(runner(path: "/bin/false").execute([])).not_to be_success
+ end
+
+ it "kills a process that exceeds the timeout" do
+ result = runner(path: "/bin/sleep", timeout: 1).execute(["5"])
+
+ expect(result.timed_out).to be(true)
+ expect(result).not_to be_success
+ end
+
+ it "caps captured output" do
+ result = runner(path: "/bin/sh", max_output: 10).execute(["-c", "printf '%0.sa' {1..100}"])
+
+ expect(result.stdout.bytesize).to be <= 10
+ end
+
+ it "returns a failure when the binary is missing" do
+ expect(runner(path: "/nonexistent/hledger").execute([])).not_to be_success
+ end
+
+ it "feeds stdin to the process" do
+ result = runner(path: "/bin/cat").execute([], stdin_data: "journal")
+
+ expect(result.stdout).to eq("journal")
+ end
+
+ it "does not inherit the parent environment" do
+ ENV["HLEDGER_SECRET"] = "leak"
+ result = runner(path: "/usr/bin/env").execute([])
+
+ expect(result.stdout).not_to include("HLEDGER_SECRET")
+ ensure
+ ENV.delete("HLEDGER_SECRET")
+ end
+end
diff --git a/spec/lib/pretty_text_spec.rb b/spec/lib/pretty_text_spec.rb
new file mode 100644
index 0000000..06b00b8
--- /dev/null
+++ b/spec/lib/pretty_text_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+RSpec.describe PrettyText do
+ it "renders the hledger fence as a dashboard placeholder" do
+ SiteSetting.hledger_enabled = true
+
+ cooked = PrettyText.cook(<<~MD)
+ ```hledger
+ 2024-01-01 x
+ assets:cash 1 EUR
+ equity:opening -1 EUR
+ ```
+ MD
+
+ expect(cooked).to include('class="hledger-dashboard"')
+ expect(cooked).not_to include("assets:cash")
+ end
+
+ it "keeps the code block when the plugin is disabled" do
+ SiteSetting.hledger_enabled = false
+
+ cooked = PrettyText.cook("```hledger\n2024-01-01 x\n```")
+
+ expect(cooked).not_to include("hledger-dashboard")
+ expect(cooked).to include("lang-hledger")
+ end
+end
diff --git a/spec/requests/hledger/reports_spec.rb b/spec/requests/hledger/reports_spec.rb
new file mode 100644
index 0000000..5359daa
--- /dev/null
+++ b/spec/requests/hledger/reports_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+class StubHledgerRunner
+ FIXTURES = Rails.root.join("plugins/hledger/spec/fixtures")
+
+ attr_reader :version
+
+ def initialize(version: "1.52.1")
+ @version = version
+ end
+
+ def execute(argv, stdin_data: nil)
+ file =
+ if argv.include?("^equity:")
+ "equity.json"
+ elsif argv.include?("bs")
+ "balance_sheet.csv"
+ elsif argv.include?("is")
+ "income_statement.csv"
+ else
+ "accounts.json"
+ end
+
+ Hledger::Runner::Result.new(
+ success: true,
+ stdout: File.read(FIXTURES.join(file)),
+ stderr: "",
+ exit_status: 0,
+ timed_out: false,
+ )
+ end
+end
+
+RSpec.describe Hledger::ReportsController do
+ fab!(:post) { Fabricate(:post, raw: <<~RAW) }
+ Intro
+
+ ```hledger
+ 2024-01-01 Opening balances
+ assets:bank:checking 1000.00 EUR
+ equity:alice -600.00 EUR
+ equity:bob -400.00 EUR
+ ```
+ RAW
+
+ before do
+ SiteSetting.hledger_enabled = true
+ allow(Hledger::Runner).to receive(:new).and_return(StubHledgerRunner.new)
+ Discourse.cache.clear
+ end
+
+ def get_report(report_type, params: {})
+ get "/hledger/topics/#{post.topic_id}/reports/#{report_type}.json", params: params
+ end
+
+ it "returns the accounts report" do
+ get_report("accounts")
+
+ expect(response.status).to eq(200)
+ json = response.parsed_body
+ expect(json["report_type"]).to eq("accounts")
+ expect(json["sections"].first["rows"].map { |row| row["account"] }).to include(
+ "assets:bank:checking",
+ )
+ end
+
+ it "returns the balance sheet report" do
+ get_report("balance_sheet")
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["sections"].map { |s| s["title"] }).to eq(%w[Assets Liabilities])
+ end
+
+ it "returns the income statement report" do
+ get_report("income_statement")
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["net"].first["display"]).to eq("29.50 EUR")
+ end
+
+ it "returns the equity distribution" 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],
+ )
+ end
+
+ it "rejects unsupported report types" do
+ get_report("nope")
+
+ expect(response.status).to eq(422)
+ end
+
+ it "returns not found when the topic has no journal" do
+ post.update!(raw: "no journal here")
+
+ get_report("accounts")
+
+ expect(response.status).to eq(404)
+ end
+
+ it "rejects invalid dates" do
+ get_report("accounts", params: { begin: "not-a-date" })
+
+ expect(response.status).to eq(422)
+ end
+
+ it "returns service unavailable when hledger is missing" do
+ allow(Hledger::Runner).to receive(:new).and_return(StubHledgerRunner.new(version: nil))
+
+ get_report("accounts")
+
+ expect(response.status).to eq(503)
+ end
+
+ it "hides topics the user cannot see" do
+ private_category = Fabricate(:private_category, group: Fabricate(:group))
+ hidden_post = Fabricate(:post, topic: Fabricate(:topic, category: private_category))
+
+ get "/hledger/topics/#{hidden_post.topic_id}/reports/accounts.json"
+
+ expect(response.status).to eq(404)
+ end
+
+ it "is not found when the plugin is disabled" do
+ SiteSetting.hledger_enabled = false
+
+ get_report("accounts")
+
+ expect(response.status).to eq(404)
+ end
+end
diff --git a/test/javascripts/acceptance/hledger-test.js b/test/javascripts/acceptance/hledger-test.js
new file mode 100644
index 0000000..8ea2ea0
--- /dev/null
+++ b/test/javascripts/acceptance/hledger-test.js
@@ -0,0 +1,89 @@
+import { click, visit } from "@ember/test-helpers";
+import { test } from "qunit";
+import { cloneJSON } from "discourse/lib/object";
+import topicFixtures from "discourse/tests/fixtures/topic";
+import { acceptance } from "discourse/tests/helpers/qunit-helpers";
+
+const TOPIC_ID = 28830;
+
+const accountsReport = {
+ report_type: "accounts",
+ period: { begin: null, end: null },
+ sections: [
+ {
+ title: null,
+ rows: [
+ {
+ account: "assets:bank",
+ depth: 1,
+ amounts: [
+ { commodity: "EUR", quantity: "1000.0", display: "1000.00 EUR" },
+ ],
+ },
+ ],
+ total: [{ commodity: "EUR", quantity: "1000.0", display: "1000.00 EUR" }],
+ },
+ ],
+ net: null,
+ meta: { hledger_version: "1.52.1" },
+};
+
+const equityReport = {
+ report_type: "equity",
+ period: { begin: null, end: null },
+ groups: [
+ {
+ commodity: "EUR",
+ total: { commodity: "EUR", quantity: "1000.0", display: "1000.0 EUR" },
+ holders: [
+ {
+ holder: "alice",
+ amount: { commodity: "EUR", quantity: "600.0", display: "600.0 EUR" },
+ percentage: "60.00",
+ },
+ {
+ holder: "bob",
+ amount: { commodity: "EUR", quantity: "400.0", display: "400.0 EUR" },
+ percentage: "40.00",
+ },
+ ],
+ },
+ ],
+ meta: { hledger_version: "1.52.1" },
+};
+
+acceptance("Hledger plugin", function (needs) {
+ needs.settings({ hledger_enabled: true });
+
+ needs.pretender((server, helper) => {
+ server.get("/t/45.json", () => {
+ const topic = cloneJSON(topicFixtures["/t/28830/1.json"]);
+ topic.post_stream.posts[0].cooked = ``;
+ return helper.response(topic);
+ });
+
+ server.get(`/hledger/topics/${TOPIC_ID}/reports/accounts`, () =>
+ helper.response(accountsReport)
+ );
+ server.get(`/hledger/topics/${TOPIC_ID}/reports/equity`, () =>
+ helper.response(equityReport)
+ );
+ });
+
+ 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__table").includesText("assets:bank");
+ assert.dom(".hledger-dashboard__table").includesText("1000.00 EUR");
+ });
+
+ test("switches to the equity distribution", async function (assert) {
+ await visit("/t/-/45");
+
+ await click(".hledger-dashboard__toolbar .btn:nth-of-type(4)");
+
+ assert.dom(".hledger-dashboard__table").includesText("alice");
+ assert.dom(".hledger-dashboard__table").includesText("60.00%");
+ });
+});