finacial page
All checks were successful
Deploy / build-deploy (push) Successful in 2m20s

This commit is contained in:
Jorijn van der Graaf 2026-08-14 02:50:58 +02:00
commit e68d2c245c
17 changed files with 1801 additions and 15 deletions

View file

@ -120,7 +120,7 @@ export SafeHtml RenderFooter() {
R"(<a{}>Forgejo</a><a{}>Source</a>)"
R"(</p>)"
R"(<p class="footer-links">)"
R"(<a{}>Privacy</a><a{}>Terms</a><a{}>Imprint &amp; contact</a>)"
R"(<a{}>Privacy</a><a{}>Terms</a><a{}>Imprint &amp; contact</a><a{}>Financials</a>)"
R"(</p>)"
R"(<p class="footer-legal">&copy; 2026 Catcrafts&reg;. Crafter&reg; and Catcrafts&reg; )"
R"(are registered trademarks with the EUIPO.</p>)"
@ -129,7 +129,8 @@ export SafeHtml RenderFooter() {
Url("href", "https://forgejo.catcrafts.net/Catcrafts/catcrafts.net"),
Url("href", "/legal/privacy"),
Url("href", "/legal/terms"),
Url("href", "/legal/imprint"));
Url("href", "/legal/imprint"),
Url("href", "/financials"));
}
// ── schema.org JSON-LD ────────────────────────────────────────────────
@ -1441,6 +1442,122 @@ export RenderedPage RenderAbout(const LegalPage& about) {
return page;
}
// ── financials ────────────────────────────────────────────────────────
// The open-financials page: the company's money as live running totals.
//
// The privacy design is structural, not editorial. This renderer can only
// ever see aggregates: sales arrive as two integers folded out of the order
// ledger, donations and expenses arrive as category totals from the
// bank-aggregates file. No transaction, timestamp or counterparty exists in
// either input, so no future edit here can accidentally publish one.
//
// The data-fin-* attributes are the machine-readable copy of the figures —
// what the e2e suite asserts against, and what anyone scraping the page in
// good faith should read instead of parsing euro signs.
export RenderedPage RenderFinancials(std::int64_t salesCount,
std::int64_t salesTotalMinor,
const Financials& fin) {
const LegalPage& notes = Content::FinancialsPage();
// A total row is ruled off from the rows it sums, the way a ledger is.
auto totalRow = [](std::string_view label, std::int64_t minor) {
return Format(
R"(<tr class="fin-total"><th scope="row">{}</th><td class="order__amount">{}</td></tr>)",
Escape(label), Escape(Money::FormatEuro(minor)));
};
// Income. Sales are always live; the donation row exists only once the
// bank figures do — a €0 the page cannot yet know would be a lie, and so
// would an income total missing half its inputs.
std::vector<SafeHtml> incomeRows;
if (fin.Loaded()) {
incomeRows.push_back(MoneyRow(
fin.donationCount == 1 ? std::string("Donations (1)")
: std::format("Donations ({})", fin.donationCount),
fin.donationsMinor));
}
incomeRows.push_back(MoneyRow(
salesCount == 1 ? std::string("Sales (1 order)")
: std::format("Sales ({} orders)", salesCount),
salesTotalMinor));
if (fin.Loaded()) {
incomeRows.push_back(totalRow("Income", fin.donationsMinor + salesTotalMinor));
}
// Expenses: one table, group-header rows for the recurring/one-off split,
// so the amounts stay in a single aligned column.
SafeHtml expenses;
if (fin.Loaded()) {
std::vector<SafeHtml> rows;
auto group = [&](std::string_view heading, std::span<const FinCategory> cats) {
if (cats.empty()) return;
rows.push_back(Format(
R"(<tr class="fin-group"><th colspan="2">{}</th></tr>)",
Escape(heading)));
for (const FinCategory& c : cats) rows.push_back(MoneyRow(c.label, c.totalMinor));
};
group("Recurring", fin.recurring);
group("One-off", fin.single);
rows.push_back(totalRow("Expenses", fin.ExpensesMinor()));
expenses = Format(R"(<table class="spec-table"><tbody>{}</tbody></table>)",
Join(rows));
} else {
expenses = Raw(
R"(<p class="notice">Donations and expenses are aggregated from the )"
R"(business bank account and have not been published yet. The sales )"
R"(figures above are already live.</p>)");
}
// The freshness line keeps the page honest about its two cadences.
const SafeHtml freshness = fin.Loaded()
? Format(R"(<p class="legal__updated">Sales are live from the order ledger &middot; )"
R"(bank figures as of <time{}>{}</time></p>)",
Attr("datetime", fin.asOf), Escape(fin.asOf))
: Raw(R"(<p class="legal__updated">Sales are live from the order ledger</p>)");
// The methodology prose, in the legal pages' section shape and CSS.
std::vector<SafeHtml> sections;
for (const LegalSection& sec : notes.sections) {
std::vector<SafeHtml> paras;
for (const std::string& para : sec.body) {
paras.push_back(Format(R"(<p>{}</p>)", Autolink(para)));
}
sections.push_back(Format(
R"(<section class="legal__section">)"
R"(<h2 class="legal__heading">{}</h2>{}</section>)",
Escape(sec.heading), Join(paras)));
}
RenderedPage page;
page.meta.title = notes.title + " — Catcrafts";
page.meta.description = notes.lede;
page.meta.canonical = "/financials";
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)"
R"(<p class="page-header__lede">{}</p>)"
R"({})"
R"(</header>)"
R"(<div class="fin"{}{}{}{}{}>)"
R"(<section class="section"><h2 class="section__title">Income</h2>)"
R"(<table class="spec-table"><tbody>{}</tbody></table></section>)"
R"(<section class="section"><h2 class="section__title">Expenses</h2>{}</section>)"
R"(</div>)"
R"(<div class="legal">{}</div>)",
Escape(notes.title), Escape(notes.lede), freshness,
Attr("data-fin-sales-count", std::to_string(salesCount)),
Attr("data-fin-sales-minor", std::to_string(salesTotalMinor)),
fin.Loaded() ? Attr("data-fin-donations-count", std::to_string(fin.donationCount))
: SafeHtml{},
fin.Loaded() ? Attr("data-fin-donations-minor", std::to_string(fin.donationsMinor))
: SafeHtml{},
fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor()))
: SafeHtml{},
Join(incomeRows), expenses, Join(sections));
return page;
}
// ── demos ─────────────────────────────────────────────────────────────
export RenderedPage RenderDemos(std::span<const Demo> demos) {
@ -1632,6 +1749,24 @@ RenderedPage RenderRouteBody(const Route& route, const SiteContent& content) {
}
break;
}
case RouteKind::Financials: {
// The live totals are server state, and the server intercepts this
// route before shared dispatch, exactly like orders. Reaching this
// case means the wasm app is rendering with the backend down — an
// honest notice beats a page of zeros posing as the company's
// finances.
RenderedPage page;
page.meta.title = "Financials — Catcrafts";
page.meta.canonical = "/financials";
page.meta.refreshSeconds = 30;
page.main = Raw(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">Financials unavailable</h1>)"
R"(<p class="page-header__lede">The live figures aren't reachable )"
R"(right now. This page retries automatically.</p>)"
R"(</header>)");
return page;
}
case RouteKind::Product: {
// A slug that parsed but names nothing is a 404, not an empty
// product page — otherwise every typo becomes an indexable URL.