This commit is contained in:
raucao committed 2026-09-27 10:31:47 +02:00
1 parent ce3b6ffb78
commit 5c6dce9fbc
35 files changed
+2098 -25

No files matched your search

+69 -3
View File
@@ -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
@@ -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
@@ -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
+42
View File
@@ -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
@@ -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 = () =>
`<div class="hledger-dashboard"></div>\n`;
});
}
@@ -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" }
);
});
@@ -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)}`;
}
<template>
<div class="hledger-dashboard__inner">
<div class="hledger-dashboard__toolbar">
{{#each this.reports as |report|}}
<DButton
class={{if (eq this.selectedReport report.id) "is-active"}}
@label={{report.label}}
@icon={{report.icon}}
@action={{fn this.select report.id}}
@disabled={{this.loading}}
/>
{{/each}}
</div>
<div class="hledger-dashboard__filters">
<DDatePicker
@value={{this.beginDate}}
@onSelect={{this.setBeginDate}}
@placeholder={{this.labels.beginDate}}
/>
<DDatePicker
@value={{this.endDate}}
@onSelect={{this.setEndDate}}
@placeholder={{this.labels.endDate}}
/>
</div>
<DConditionalLoadingSpinner @condition={{this.loading}}>
{{#if this.error}}
<div
class="hledger-dashboard__error alert alert-error"
>{{this.error}}</div>
{{else if this.report}}
{{#if this.isEquity}}
{{#each this.report.groups as |group|}}
<div class="hledger-dashboard__section">
<h3
class="hledger-dashboard__section-title"
>{{group.commodity}}</h3>
<table class="hledger-dashboard__table">
<thead>
<tr>
<th>{{this.labels.holder}}</th>
<th
class="hledger-dashboard__amount"
>{{this.labels.amount}}</th>
<th
class="hledger-dashboard__amount"
>{{this.labels.share}}</th>
</tr>
</thead>
<tbody>
{{#each group.holders as |holder|}}
<tr>
<td>{{holder.holder}}</td>
<td
class="hledger-dashboard__amount"
>{{holder.amount.display}}</td>
<td
class="hledger-dashboard__amount"
>{{holder.percentage}}%</td>
</tr>
{{/each}}
<tr class="hledger-dashboard__total">
<td>{{this.labels.total}}</td>
<td
class="hledger-dashboard__amount"
>{{group.total.display}}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
{{/each}}
<p class="hledger-dashboard__note">{{this.labels.note}}</p>
{{else}}
{{#each this.report.sections as |section|}}
<div class="hledger-dashboard__section">
{{#if section.title}}
<h3
class="hledger-dashboard__section-title"
>{{section.title}}</h3>
{{/if}}
<table class="hledger-dashboard__table">
<tbody>
{{#each section.rows as |row|}}
<tr>
<td
class="hledger-dashboard__account
{{this.depthClass row.depth}}"
>
{{row.account}}
</td>
<td class="hledger-dashboard__amount">{{this.amountText
row.amounts
}}</td>
</tr>
{{/each}}
{{#if section.total}}
<tr class="hledger-dashboard__total">
<td>{{this.labels.total}}</td>
<td class="hledger-dashboard__amount">{{this.amountText
section.total
}}</td>
</tr>
{{/if}}
</tbody>
</table>
</div>
{{/each}}
{{#if this.report.net}}
<div class="hledger-dashboard__net">
<span>{{this.labels.net}}</span>
<span class="hledger-dashboard__amount">{{this.amountText
this.report.net
}}</span>
</div>
{{/if}}
{{/if}}
{{/if}}
</DConditionalLoadingSpinner>
</div>
</template>
}
+106
View File
@@ -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;
}
}
Executable
+22
View File
@@ -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
+17 -1
View File
@@ -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."
+21
View File
@@ -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."
+1 -4
View File
@@ -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" }
+8
View File
@@ -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
+68
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Hledger
class Error < StandardError
end
end
+6
View File
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Hledger
class ExecutionError < Error
end
end
+54
View File
@@ -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(?<source>.*?)^[ \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
+6
View File
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Hledger
class ParseError < Error
end
end
+255
View File
@@ -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
+163
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Hledger
class UnavailableError < Error
end
end
+4 -6
View File
@@ -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
+150
View File
@@ -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"
}
}
]
]
+8
View File
@@ -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"
1 Balance Sheet 2024-03-15
2 Account 2024-03-15
3 Assets
4 assets:bank:checking 1029.50 EUR
5 Total: 1029.50 EUR
6 Liabilities
7 Total: 0
8 Net: 1029.50 EUR
+75
View File
@@ -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"
}
}
]
]
+9
View File
@@ -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"
1 Income Statement 2024-01-01..2024-03-15
2 Account 2024-01-01..2024-03-15
3 Revenues
4 revenues:donations 50.00 EUR
5 Total: 50.00 EUR
6 Expenses
7 expenses:hosting:server 20.50 EUR
8 Total: 20.50 EUR
9 Net: 29.50 EUR
+12
View File
@@ -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
+37
View File
@@ -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
+42
View File
@@ -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
+51
View File
@@ -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
+116
View File
@@ -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
+51
View File
@@ -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
+27
View File
@@ -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
+134
View File
@@ -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
@@ -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 = `<div class="hledger-dashboard"></div>`;
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%");
});
});