This commit is contained in:
parent
70668af8f5
commit
e68d2c245c
17 changed files with 1801 additions and 15 deletions
|
|
@ -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 & contact</a>)"
|
||||
R"(<a{}>Privacy</a><a{}>Terms</a><a{}>Imprint & contact</a><a{}>Financials</a>)"
|
||||
R"(</p>)"
|
||||
R"(<p class="footer-legal">© 2026 Catcrafts®. Crafter® and Catcrafts® )"
|
||||
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 · )"
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue