MVP
This commit is contained in:
35 files changed
+2098
-25
No files matched your search
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
@@ -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" }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Hledger
|
||||
class Error < StandardError
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Hledger
|
||||
class ExecutionError < Error
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Hledger
|
||||
class ParseError < Error
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Hledger
|
||||
class UnavailableError < Error
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
Vendored
+150
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
Vendored
+8
@@ -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"
|
||||
|
Vendored
+75
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
Vendored
+9
@@ -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"
|
||||
|
Vendored
+12
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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%");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user