financial page with bank data
All checks were successful
Deploy / build-deploy (push) Successful in 1m48s
All checks were successful
Deploy / build-deploy (push) Successful in 1m48s
This commit is contained in:
parent
e68d2c245c
commit
33c68c2f44
11 changed files with 394 additions and 87 deletions
|
|
@ -207,12 +207,12 @@ export const LegalPage& FinancialsPage() {
|
|||
.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.",
|
||||
.lede = "Catcrafts believes in openness, that's why its financials are open as well. As a supporter you deserve to know where your money is going.",
|
||||
.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.",
|
||||
"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.",
|
||||
} },
|
||||
{ "What is never published",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -298,14 +298,18 @@ export struct Financials {
|
|||
// 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, …
|
||||
// ONE flat list of expense categories. This carried a recurring/one-off
|
||||
// split until it was removed deliberately: the page publishes LIFETIME
|
||||
// running totals, and a lifetime total cannot express a rate. "Telecom
|
||||
// €158.18" says nothing about whether that is monthly or once ever, so
|
||||
// the grouping conveyed no information while inviting the reader to infer
|
||||
// one. Rate belongs to a cash-flow view this page is not.
|
||||
std::vector<FinCategory> expenses;
|
||||
|
||||
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;
|
||||
for (const FinCategory& c : expenses) sum += c.totalMinor;
|
||||
return sum;
|
||||
}
|
||||
};
|
||||
|
|
@ -322,9 +326,7 @@ export Financials LoadFinancials(std::string_view json) {
|
|||
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;
|
||||
if (const Json::Value* arr = doc->Find("expenses"); arr && arr->IsArray()) {
|
||||
for (const Json::Value& v : arr->array) {
|
||||
if (!v.IsObject()) continue;
|
||||
FinCategory c;
|
||||
|
|
@ -333,12 +335,9 @@ export Financials LoadFinancials(std::string_view json) {
|
|||
// 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));
|
||||
out.expenses.push_back(std::move(c));
|
||||
}
|
||||
return cats;
|
||||
};
|
||||
out.recurring = categories(doc->Find("recurring"));
|
||||
out.single = categories(doc->Find("single"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1473,32 +1473,22 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
|
|||
std::vector<SafeHtml> incomeRows;
|
||||
if (fin.Loaded()) {
|
||||
incomeRows.push_back(MoneyRow(
|
||||
fin.donationCount == 1 ? std::string("Donations (1)")
|
||||
: std::format("Donations ({})", fin.donationCount),
|
||||
std::format("Donations ({})", fin.donationCount),
|
||||
fin.donationsMinor));
|
||||
}
|
||||
incomeRows.push_back(MoneyRow(
|
||||
salesCount == 1 ? std::string("Sales (1 order)")
|
||||
: std::format("Sales ({} orders)", salesCount),
|
||||
std::format("Sales ({})", 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.
|
||||
// Expenses: one flat table. No recurring/one-off grouping — see the note
|
||||
// on Financials::expenses for why a lifetime total cannot carry a rate.
|
||||
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);
|
||||
for (const FinCategory& c : fin.expenses) rows.push_back(MoneyRow(c.label, c.totalMinor));
|
||||
rows.push_back(totalRow("Expenses", fin.ExpensesMinor()));
|
||||
expenses = Format(R"(<table class="spec-table"><tbody>{}</tbody></table>)",
|
||||
Join(rows));
|
||||
|
|
@ -1509,6 +1499,33 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
|
|||
R"(figures above are already live.</p>)");
|
||||
}
|
||||
|
||||
// Net: what is actually left. Shown only when the bank figures exist —
|
||||
// income minus an unknown expense side is not a net of anything, and a
|
||||
// figure equal to sales while expenses are unpublished would read as a
|
||||
// company with no costs.
|
||||
//
|
||||
// Deliberately NOT called profit. On a cash basis this ignores stock
|
||||
// still on the shelf, anything owed in either direction, and tax not yet
|
||||
// paid, so calling it profit would be a claim the arithmetic cannot
|
||||
// support. FormatEuro renders a negative as "€-12.34", which is the
|
||||
// honest thing to show in a month that bought inventory.
|
||||
const std::int64_t netMinor =
|
||||
fin.donationsMinor + salesTotalMinor - fin.ExpensesMinor();
|
||||
SafeHtml netBlock;
|
||||
if (fin.Loaded()) {
|
||||
netBlock = Format(
|
||||
R"(<section class="section"><h2 class="section__title">Net</h2>)"
|
||||
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
|
||||
R"(<p class="section__lede">On the same cash basis as everything above. )"
|
||||
R"(This is not profit: it counts no stock still on the shelf, nothing )"
|
||||
R"(owed in either direction, and no tax.</p>)"
|
||||
R"(</section>)",
|
||||
// The row says the arithmetic rather than repeating the heading —
|
||||
// a section titled "Net" whose only row is also "Net" reads as a
|
||||
// rendering fault, and the sum is worth spelling out anyway.
|
||||
totalRow("Income − expenses", netMinor));
|
||||
}
|
||||
|
||||
// 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 · )"
|
||||
|
|
@ -1539,10 +1556,11 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
|
|||
R"(<p class="page-header__lede">{}</p>)"
|
||||
R"({})"
|
||||
R"(</header>)"
|
||||
R"(<div class="fin"{}{}{}{}{}>)"
|
||||
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"({})"
|
||||
R"(</div>)"
|
||||
R"(<div class="legal">{}</div>)",
|
||||
Escape(notes.title), Escape(notes.lede), freshness,
|
||||
|
|
@ -1554,7 +1572,8 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
|
|||
: SafeHtml{},
|
||||
fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor()))
|
||||
: SafeHtml{},
|
||||
Join(incomeRows), expenses, Join(sections));
|
||||
fin.Loaded() ? Attr("data-fin-net-minor", std::to_string(netMinor)) : SafeHtml{},
|
||||
Join(incomeRows), expenses, netBlock, Join(sections));
|
||||
return page;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue