Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
importable_files_validator,
ledgerDataValidator,
options_validator,
returns_validator,
source_validator,
type SourceFile,
statistics_validator,
Expand Down Expand Up @@ -64,6 +65,7 @@ type GetEndpoint =
| "narration_transaction"
| "narrations"
| "query"
| "returns"
| "source"
| "statistics";
type PutEndpoint =
Expand All @@ -82,11 +84,15 @@ type ApiParams = Partial<{
a: string;
account: string;
conversion: string;
currency: string;
end_date: string;
entry_hash: string;
filename: string;
filter: string;
importer: string;
income: string;
interval: string;
investments: string;
narration: string;
order: "asc" | "desc";
page: number;
Expand Down Expand Up @@ -232,6 +238,12 @@ export const get_balance_sheet = define_endpoint(
filters_conversion_interval,
);
export const get_changed = define_paramless_endpoint("changed", boolean);
export const get_returns = define_endpoint("returns", returns_validator, [
"investments",
"income",
"currency",
"end_date",
]);
export const get_commodities = define_endpoint(
"commodities",
commodities_validator,
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/api/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ import {
} from "../lib/validation.ts";
import { Inventory } from "../reports/query/query_table.ts";

/**
* Money- and time-weighted returns over a scope of investment accounts.
*
* The decimal fields arrive as raw full-precision strings and are NOT
* display-formatted — the engine leaves formatting to the host so no precision
* is lost in transit. The two rates are annualized fractions (0.1 is 10%) and
* are null where the metric is undefined, which is not the same as zero.
*/
export const returns_validator = object({
cash_flows: number,
invested: string,
distributions: string,
current_value: string,
money_weighted: optional(number),
time_weighted: optional(number),
});

/** The result of a returns calculation. */
export type ReturnsResult = ValidationT<typeof returns_validator>;

/** A Beancount error that should be shown to the user in the list of errors. */
export interface BeancountError {
readonly type: string;
Expand Down
138 changes: 138 additions & 0 deletions frontend/src/reports/returns/Returns.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<script lang="ts">
import { formatPercentage } from "../../format.ts";
import { _ } from "../../i18n.ts";
import { router } from "../../router.ts";
import { ctx } from "../../stores/format.ts";
import type { ReturnsReportProps } from "./index.ts";

let { params, result, error }: ReturnsReportProps = $props();

// Editable copies of the URL scope. Deliberately not initialised from
// `params` directly: the route re-renders this same component instance with
// new props on navigation, so the effect below is what keeps the inputs
// following the URL. Seeding them here as well would capture only the first
// value and leave the form showing a previous scope after back/forward.
let investments = $state("");
let income = $state("");
let currency = $state("");
let end_date = $state("");

$effect(() => {
investments = params.investments;
income = params.income;
currency = params.currency;
end_date = params.end_date;
});

function submit(event: SubmitEvent) {
event.preventDefault();
router.set_search_param("investments", investments);
router.set_search_param("income", income);
router.set_search_param("currency", currency);
router.set_search_param("end_date", end_date);
}

/** The reporting currency the figures are expressed in. */
let reporting = $derived(currency.trim());

/**
* Format a raw decimal string.
*
* The engine sends full precision deliberately and leaves formatting to the
* host, so this is where locale and precision are applied.
*/
const amount = (value: string): string =>
reporting === "" ? value : $ctx.amount(Number(value), reporting);

/** A rate, or an explicit "not defined" — which is not the same as zero. */
const rate = (value: number | null): string =>
value == null ? _("n/a") : formatPercentage(value);
</script>

<form onsubmit={submit}>
<p>
<label>
{_("Investment accounts")}
<input
bind:value={investments}
placeholder="Assets:Investments"
size="30"
/>
</label>
<label>
{_("Income accounts")}
<input bind:value={income} placeholder="Income:Investments" size="30" />
</label>
</p>
<p>
<label>
{_("Currency")}
<input bind:value={currency} placeholder="USD" size="8" />
</label>
<label>
{_("As of")}
<input bind:value={end_date} type="date" required />
</label>
<button type="submit">{_("Calculate")}</button>
</p>
<p class="hint">
{_(
"Account names are prefixes: everything beneath them is in scope. Leave the currency empty to use the ledger's first operating currency.",
)}
</p>
</form>

{#if error != null}
<div class="error-state">
<h3>{_("Returns could not be computed")}</h3>
<p>{error}</p>
<p class="hint">
{_(
"The engine reports an error rather than a figure it cannot stand behind — fix the ledger issue above and try again.",
)}
</p>
</div>
{:else if result != null}
<table>
<tbody>
<tr>
<td>{_("Invested")}</td>
<td class="num">{amount(result.invested)}</td>
</tr>
<tr>
<td>{_("Distributions")}</td>
<td class="num">{amount(result.distributions)}</td>
</tr>
<tr>
<td>{_("Current value")}</td>
<td class="num">{amount(result.current_value)}</td>
</tr>
<tr>
<td>{_("Cash flows")}</td>
<td class="num">{result.cash_flows}</td>
</tr>
<tr>
<td title={_("Money-weighted return (XIRR)")}>
{_("Money-weighted return")}
</td>
<td class="num">{rate(result.money_weighted)}</td>
</tr>
<tr>
<td title={_("Time-weighted return")}>{_("Time-weighted return")}</td>
<td class="num">{rate(result.time_weighted)}</td>
</tr>
</tbody>
</table>
{/if}

<style>
.hint {
color: var(--text-color-lighter);
}

.error-state {
padding: 1em;
margin: 1em 0;
border-left: 3px solid var(--error);
}
</style>
63 changes: 63 additions & 0 deletions frontend/src/reports/returns/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { get_returns } from "../../api/index.ts";
import type { ReturnsResult } from "../../api/validators.ts";
import { _ } from "../../i18n.ts";
import { Route } from "../route.ts";
import ReturnsSvelte from "./Returns.svelte";

/** The scope a returns calculation was requested for. */
export interface ReturnsParams {
investments: string;
income: string;
currency: string;
end_date: string;
}

export interface ReturnsReportProps {
params: ReturnsParams;
/** The computed figures, or null when no scope has been chosen yet. */
result: ReturnsResult | null;
/** The engine's refusal message, if it declined to compute. */
error: string | null;
}

/** Today as YYYY-MM-DD — the component has no clock, so the host supplies one. */
function today(): string {
return new Date().toISOString().slice(0, 10);
}

function params_from(url: URL): ReturnsParams {
const p = url.searchParams;
return {
investments: p.get("investments") ?? "",
income: p.get("income") ?? "",
currency: p.get("currency") ?? "",
end_date: p.get("end_date") ?? today(),
};
}

export const returns = new Route<ReturnsReportProps>(
"returns",
ReturnsSvelte,
async (url) => {
const params = params_from(url);
// No scope yet: render the form rather than asking the engine to compute
// returns over nothing.
if (params.investments.trim() === "") {
return { params, result: null, error: null };
}
try {
const result = await get_returns(params);
return { params, result, error: null };
} catch (error) {
// The engine refuses rather than reporting a figure it cannot stand
// behind, and its message names the ledger problem — so show it instead
// of a blank table.
return {
params,
result: null,
error: error instanceof Error ? error.message : String(error),
};
}
},
() => _("Returns"),
);
2 changes: 2 additions & 0 deletions frontend/src/reports/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { import_report } from "./import/index.ts";
import { journal } from "./journal/index.ts";
import { options } from "./options/index.ts";
import { query } from "./query/index.ts";
import { returns } from "./returns/index.ts";
import type { FrontendRoute } from "./route.ts";
import { statistics } from "./statistics/index.ts";
import {
Expand All @@ -34,6 +35,7 @@ export const frontend_routes: FrontendRoute[] = [
errors,
events,
holdings,
returns,
import_report,
income_statement,
journal,
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@ type FavaQueryParameters =
| "account"
| "charts"
| "conversion"
| "currency"
| "end_date"
| "filter"
| "income"
| "interval"
| "investments"
| "query_string"
| "time";

Expand Down Expand Up @@ -344,8 +348,12 @@ export class Router {
key:
| "account"
| "conversion"
| "currency"
| "end_date"
| "filter"
| "income"
| "interval"
| "investments"
| "query_string"
| "time",
value: string,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/sidebar/AsideContents.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
<ul class="navigation">
<Link report="holdings" name={_("Holdings")} key="g h" />
<Link report="commodities" name={_("Commodities")} key="g c" />
<Link report="returns" name={_("Returns")} key="g r" />
<Link report="documents" name={_("Documents")} key="g d" />
<Link
report="events"
Expand Down
22 changes: 15 additions & 7 deletions src/rustfava/application.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""rustfava's main WSGI application.

you can use `create_app` to create a rustfava WSGI app for a given list of files.
you can use `create_app` to create a rustfava WSGI app for a given list of
files.
To start a simple server::

from rustfava.application import create_app
Expand All @@ -17,7 +18,7 @@
import mimetypes
from datetime import date
from datetime import datetime
from datetime import timezone
from datetime import UTC
from functools import lru_cache
from io import BytesIO
from pathlib import Path
Expand Down Expand Up @@ -84,6 +85,7 @@
"income_statement",
"options",
"query",
"returns",
"statistics",
"trial_balance",
]
Expand Down Expand Up @@ -419,26 +421,32 @@ def download_query(result_format: str) -> Response:
@fava_app.route("/<bfile>/download-journal/")
def download_journal() -> Response:
"""Download a Journal file."""
now = datetime.now(tz=timezone.utc).replace(microsecond=0)
now = datetime.now(tz=UTC).replace(microsecond=0)
filename = f"journal_{now.isoformat()}.beancount"
data = BytesIO(bytes(render_template("beancount_file"), "utf8"))
return send_file(data, as_attachment=True, download_name=filename)

@fava_app.route("/<bfile>/help/", defaults={"page_slug": "_index"})
@fava_app.route("/<bfile>/help/<page_slug>")
def help_page(page_slug: str) -> str:
"""rustfava's included documentation."""
"""Rustfava's included documentation."""
from markdown2 import markdown

from rustfava import __version__ as rustfava_version

# Validate against whitelist (defense-in-depth: also check for path traversal)
if page_slug not in HELP_PAGES or "/" in page_slug or "\\" in page_slug:
# Validate against whitelist (defense-in-depth: also check for
# path traversal)
if (
page_slug not in HELP_PAGES
or "/" in page_slug
or "\\" in page_slug
):
return abort(404)
help_dir = (Path(__file__).parent / "help").resolve()
help_path = (help_dir / (page_slug + ".md")).resolve()
# Ensure resolved path is within help directory
# Note: With whitelist check above, this is unreachable (defense-in-depth)
# Note: With whitelist check above, this is unreachable
# (defense-in-depth)
if not help_path.is_relative_to(help_dir): # pragma: no cover
return abort(404)
contents = help_path.read_text(encoding="utf-8")
Expand Down
Loading
Loading