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

@ -197,6 +197,32 @@ export const LegalPage& AboutPage() {
return page;
}
// The open-financials page's authored prose. The numbers themselves come from
// the order ledger and the bank-aggregates state file at render time; what is
// compiled in is the promise around them — what the page shows, what it never
// will, and how to read it. Kept in the LegalPage shape so it reuses that
// renderer's section markup and CSS, exactly as the about page does.
export const LegalPage& FinancialsPage() {
static const LegalPage page{
.slug = "financials",
.title = "Financials",
.updated = "2026-08-14",
.lede = "Catcrafts' money, in the open: running totals of what the company earns and spends, live from its own records. Aggregates only — individual transactions are never published.",
.sections = {
{ "How this page works",
{
"Sales come straight from the shop's order ledger and update the moment an order is paid. Donations and expenses are aggregated from the business bank account by category and carry the date they were last brought up to date. Anything the categoriser does not recognise is held back until it has been classified, never published as a guess.",
"Everything is a running total in euros on a cash basis: money counts when it moves, not when an invoice says it should. Amounts include VAT where VAT was charged. These are the company's own live numbers, not audited statements; the tax filings are the authoritative record.",
} },
{ "What is never published",
{
"No individual transactions, no timestamps, no counterparties, no account balances, and nothing about who paid or was paid. Totals only. One consequence is accepted openly: the totals update live, so someone watching the page closely could infer that a sale or a donation happened. That is as far as it goes.",
} },
},
};
return page;
}
export const std::vector<LegalPage>& LegalPages() {
static const std::vector<LegalPage> pages = {
{

View file

@ -281,6 +281,67 @@ export Rates LoadRates(std::string_view json) {
return out;
}
// The bank-derived aggregates for the public /financials page, read from a
// state file the owner's tooling writes (<orders>.financials.json on the
// server). Aggregates by construction: a category is a label and a running
// total, donations are a count and a running total, and nothing finer ever
// exists in this structure — that is the page's privacy design, not an
// implementation shortcut. Sales are not in here: they fold live out of the
// order ledger on the server and arrive at the renderer as two integers.
export struct FinCategory {
std::string label; // "Hosting" — shown verbatim
std::int64_t totalMinor = 0; // running total, EUR cents
};
export struct Financials {
std::string asOf; // ISO date the figures are current to;
// empty = nothing published yet
std::int64_t donationCount = 0;
std::int64_t donationsMinor = 0;
std::vector<FinCategory> recurring; // insurance, hosting, …
std::vector<FinCategory> single; // inventory, fees, tax, …
bool Loaded() const { return !asOf.empty(); }
std::int64_t ExpensesMinor() const {
std::int64_t sum = 0;
for (const FinCategory& c : recurring) sum += c.totalMinor;
for (const FinCategory& c : single) sum += c.totalMinor;
return sum;
}
};
export Financials LoadFinancials(std::string_view json) {
Financials out;
auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return out;
out.asOf = std::string(doc->Str("as_of"));
// Undated figures stay unpublished: the page promises an honest
// freshness line, and numbers that cannot carry one are not shown.
if (out.asOf.empty()) return out;
if (const Json::Value* d = doc->Find("donations"); d && d->IsObject()) {
out.donationCount = d->Int("count");
out.donationsMinor = d->Int("total_minor");
}
auto categories = [](const Json::Value* arr) {
std::vector<FinCategory> cats;
if (!arr || !arr->IsArray()) return cats;
for (const Json::Value& v : arr->array) {
if (!v.IsObject()) continue;
FinCategory c;
c.label = std::string(v.Str("label"));
c.totalMinor = v.Int("total_minor");
// A category with no label has nothing to render as; dropping it
// beats an anonymous row that looks like a redaction.
if (c.label.empty()) continue;
cats.push_back(std::move(c));
}
return cats;
};
out.recurring = categories(doc->Find("recurring"));
out.single = categories(doc->Find("single"));
return out;
}
// Everything the order status page needs to render — a projection of the
// server's order record, not the record itself. The renderer stays a pure
// function in Shared; the server owns storage and fills this in.

View file

@ -31,6 +31,7 @@ export enum class RouteKind {
Order, // /order/<token> — an order's status page
Invoice, // /order/<token>/invoice.md — the signed invoice download
Legal, // /legal/<slug> — privacy, imprint, terms
Financials, // /financials — the open money page: live aggregate totals
// The blog these routes replace. sitemap.xml advertised /blog and
// /blog/<slug>, and those URLs are in the wild — in shared links and in
// whatever the crawlers already have. They resolve to Posts and carry a
@ -100,6 +101,7 @@ export Route ParseRoute(std::string_view path, std::string_view query = {}) {
if (p == "/posts") { r.kind = RouteKind::Posts; return r; }
if (p == "/demos") { r.kind = RouteKind::Demos; return r; }
if (p == "/shop") { r.kind = RouteKind::Shop; return r; }
if (p == "/financials") { r.kind = RouteKind::Financials; return r; }
// /order/<token>. The token is validated structurally here for the same
// reason slugs are: nothing downstream should ever see one it must
@ -232,8 +234,12 @@ export std::span<const std::string_view> SitemapPaths() {
// /shop/<slug> entries are appended by the caller from the loaded product
// list — the sitemap has to reflect what actually exists, and a hardcoded
// slug list here would be one more thing to forget to update.
static constexpr std::array<std::string_view, 9> paths{
static constexpr std::array<std::string_view, 10> paths{
"/", "/about", "/shop", "/projects", "/posts", "/demos",
// Indexable for the same reason the legal pages are: open financials
// are a trust signal, and someone checking the company out should be
// able to land on them from a search engine.
"/financials",
// Legal pages are indexable on purpose: they are trust signals, and a
// buyer looking for the returns policy before purchasing should be able
// to find it from a search engine.

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.