donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b0666841f6
commit
abbd616b40
23 changed files with 2898 additions and 209 deletions
|
|
@ -610,7 +610,11 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
|
|||
R"(</article>)",
|
||||
thumb,
|
||||
Url("href", "/shop/" + p.slug), Escape(p.name), Escape(p.tagline),
|
||||
p.Buyable() ? RenderCardPrice(p, rates)
|
||||
// A donation has no price to quote — the card says so instead of
|
||||
// rendering a €0 that the checkout would never charge.
|
||||
p.donation && p.Buyable()
|
||||
? Raw(R"(<p class="price price--card">any amount</p>)")
|
||||
: p.Buyable() ? RenderCardPrice(p, rates)
|
||||
: p.ComingSoon()
|
||||
? Format(R"({}<p class="product-card__status"><span class="badge badge--experiment">coming soon</span></p>)",
|
||||
RenderCardPrice(p, rates))
|
||||
|
|
@ -650,7 +654,8 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
|
|||
R"(<h1 class="page-header__title">Shop</h1>)"
|
||||
R"(<p class="page-header__lede">Hardware that runs the software from the )"
|
||||
R"(projects page. Assembled to order and flashed. Please allow up to a )"
|
||||
R"(week before dispatch. Payment is handled by Mollie.)"
|
||||
R"(week before dispatch. Or fund the work directly: the donation item )"
|
||||
R"(takes any amount.)"
|
||||
R"(</header>)"
|
||||
R"(<div class="product-grid">{}</div>)",
|
||||
cards.empty() ? Raw(R"(<p class="empty">No products listed.</p>)") : Join(cards));
|
||||
|
|
@ -674,6 +679,40 @@ SafeHtml CustomsNote() {
|
|||
R"(estimate them bindingly, and is not a party to them.</p>)");
|
||||
}
|
||||
|
||||
// The payment choice, shared by the checkout form and the donation form so
|
||||
// the two can never describe the same rails differently. A radio group rather
|
||||
// than a <select> because both options carry a sentence the buyer should read
|
||||
// BEFORE choosing — one settles in euro from their bank, the other locks a
|
||||
// euro price against a coin — and a collapsed dropdown hides exactly that. It
|
||||
// also needs no JavaScript, like everything else in these forms.
|
||||
//
|
||||
// Bank is pre-selected: it is what nearly every buyer wants, and an
|
||||
// unselected group would let a distracted submit land on neither.
|
||||
SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError) {
|
||||
const bool wantsCrypto = prev.payChoice == Form::kPayCrypto;
|
||||
return Format(
|
||||
R"(<fieldset class="field field--pay">)"
|
||||
R"(<legend>How you want to pay</legend>)"
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Bank or card</strong> — iDEAL, card, or a plain )"
|
||||
R"(bank transfer. Handled by Mollie.</span></label>)"
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Cryptocurrency</strong> — EURC, a euro )"
|
||||
R"(stablecoin, paid from your own wallet. The amount to send is the )"
|
||||
R"(euro total exactly, no exchange rate; the receiving address and )"
|
||||
R"(the networks it takes appear on the order page, and stay reserved )"
|
||||
R"(for about a day.</span></label>)"
|
||||
R"({})"
|
||||
R"(</fieldset>)",
|
||||
Attr("value", std::string(Form::kPayBank)),
|
||||
wantsCrypto ? SafeHtml{} : Raw(" checked"),
|
||||
Attr("value", std::string(Form::kPayCrypto)),
|
||||
wantsCrypto ? Raw(" checked") : SafeHtml{},
|
||||
payError);
|
||||
}
|
||||
|
||||
// The checkout form.
|
||||
//
|
||||
// A real <form method="post">, not a JavaScript submit handler. It works with
|
||||
|
|
@ -790,39 +829,8 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
}
|
||||
cc += std::format(R"(],"sm":{}}})", JsonStr(Form::kSanctionsMessage));
|
||||
|
||||
// The payment choice. A radio group rather than a <select> because both
|
||||
// options carry a sentence the buyer should read BEFORE choosing — one
|
||||
// settles in euro from their bank, the other locks a euro price against a
|
||||
// coin — and a collapsed dropdown hides exactly that. It also needs no
|
||||
// JavaScript, like everything else in this form.
|
||||
//
|
||||
// Bank is pre-selected: it is what nearly every buyer wants, and an
|
||||
// unselected group would let a distracted submit land on neither.
|
||||
SafeHtml payFieldset;
|
||||
if (offerCrypto) {
|
||||
const bool wantsCrypto = prev.payChoice == Form::kPayCrypto;
|
||||
payFieldset = Format(
|
||||
R"(<fieldset class="field field--pay">)"
|
||||
R"(<legend>How you want to pay</legend>)"
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Bank or card</strong> — iDEAL, card, or a plain )"
|
||||
R"(bank transfer. Handled by Mollie.</span></label>)"
|
||||
R"(<label class="pay-option">)"
|
||||
R"(<input type="radio" name="pay"{}{}>)"
|
||||
R"(<span><strong>Cryptocurrency</strong> — EURC, a euro )"
|
||||
R"(stablecoin, paid from your own wallet. The amount to send is the )"
|
||||
R"(euro total exactly, no exchange rate; the receiving address and )"
|
||||
R"(the networks it takes appear on the order page, and stay reserved )"
|
||||
R"(for about a day.</span></label>)"
|
||||
R"({})"
|
||||
R"(</fieldset>)",
|
||||
Attr("value", std::string(Form::kPayBank)),
|
||||
wantsCrypto ? SafeHtml{} : Raw(" checked"),
|
||||
Attr("value", std::string(Form::kPayCrypto)),
|
||||
wantsCrypto ? Raw(" checked") : SafeHtml{},
|
||||
errorFor("pay"));
|
||||
}
|
||||
const SafeHtml payFieldset =
|
||||
offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{};
|
||||
|
||||
return Format(
|
||||
R"(<section class="checkout" id="buy">)"
|
||||
|
|
@ -931,6 +939,76 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
payFieldset);
|
||||
}
|
||||
|
||||
// The donation form: the checkout form's small sibling. An amount instead of
|
||||
// a price, an OPTIONAL email instead of a shipping address — nothing ships,
|
||||
// so nothing more is asked for (the privacy notice's "what fulfilling it
|
||||
// requires" rule, applied to a gift). Same POST target, same honeypot, same
|
||||
// payment fieldset, same no-JavaScript guarantee.
|
||||
SafeHtml RenderDonationForm(const Product& product,
|
||||
std::span<const Form::FieldError> errors,
|
||||
const Form::Checkout& prev,
|
||||
bool offerCrypto) {
|
||||
auto errorFor = [&](std::string_view field) -> SafeHtml {
|
||||
for (const Form::FieldError& e : errors) {
|
||||
if (e.field == field) {
|
||||
return Format(R"(<p class="field__error">{}</p>)", Escape(e.message));
|
||||
}
|
||||
}
|
||||
return SafeHtml{};
|
||||
};
|
||||
SafeHtml formError;
|
||||
for (const Form::FieldError& e : errors) {
|
||||
if (e.field.empty()) {
|
||||
formError = Format(R"(<p class="notice notice--error">{}</p>)", Escape(e.message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Format(
|
||||
R"(<section class="checkout" id="buy">)"
|
||||
R"(<h2 class="section__title">Donate</h2>)"
|
||||
R"(<p class="checkout__lede">Pick any amount{}. Submitting creates the )"
|
||||
R"(donation and takes you straight to the payment page. Nothing is owed )"
|
||||
R"(until you actually pay; an unpaid donation just lapses.</p>)"
|
||||
R"(<p class="checkout__shipnote">No VAT is charged on a donation and no )"
|
||||
R"(invoice is issued — the donation page is its receipt. Donations )"
|
||||
R"(appear on the financials page as an aggregate total, never )"
|
||||
R"(individually.</p>)"
|
||||
R"({})"
|
||||
R"(<form class="form" method="post"{} novalidate>)"
|
||||
R"(<div class="field">)"
|
||||
R"(<label for="f-amount">Amount in euros <span class="field__req">required</span></label>)"
|
||||
R"(<input id="f-amount" name="amount" type="number" inputmode="decimal" )"
|
||||
R"(min="1" max="10000" step="0.01" required{}>)"
|
||||
R"({})"
|
||||
R"(</div>)"
|
||||
R"(<div class="field">)"
|
||||
R"(<label for="f-email">Email</label>)"
|
||||
R"(<input id="f-email" name="email" type="email" autocomplete="email"{}>)"
|
||||
R"(<p class="field__hint">Optional — only used to send the )"
|
||||
R"(confirmation. Leave it empty and the donation page is your receipt.</p>)"
|
||||
R"({})"
|
||||
R"(</div>)"
|
||||
R"({})"
|
||||
R"(<div class="honeypot" aria-hidden="true">)"
|
||||
R"(<label for="f-website">Leave this empty</label>)"
|
||||
R"(<input id="f-website" name="website" type="text" tabindex="-1" autocomplete="off">)"
|
||||
R"(</div>)"
|
||||
R"(<button class="btn btn--primary" type="submit">Donate — continue to payment</button>)"
|
||||
R"(</form>)"
|
||||
R"(</section>)",
|
||||
offerCrypto ? SafeHtml{}
|
||||
: Raw(", paid by iDEAL, card, or a plain bank transfer, "
|
||||
"handled by Mollie"),
|
||||
formError,
|
||||
Url("action", "/shop/" + product.slug + "#buy"),
|
||||
prev.amountMinor > 0
|
||||
? Attr("value", Money::FormatMinor(prev.amountMinor)) : SafeHtml{},
|
||||
errorFor("amount"),
|
||||
Attr("value", prev.email), errorFor("email"),
|
||||
offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{});
|
||||
}
|
||||
|
||||
// `offerCrypto` reaches the checkout form; see RenderCheckoutForm for why it
|
||||
// defaults to false. Only the native server passes it true, because only the
|
||||
// server knows whether the crypto rail is configured.
|
||||
|
|
@ -952,7 +1030,9 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
page.meta.canonical = "/shop/" + product.slug;
|
||||
page.meta.ogType = "product";
|
||||
page.meta.ogImage = product.image;
|
||||
page.meta.geoPriceHint = true;
|
||||
// The donation page shows no converted prices — there is no price — so it
|
||||
// ships no price-hint script either, keeping it entirely script-free.
|
||||
page.meta.geoPriceHint = !product.donation;
|
||||
|
||||
// The commercial record: a ProductGroup with one variant Product per
|
||||
// colour, each carrying its ONE offer, prices from the same integers the
|
||||
|
|
@ -964,6 +1044,10 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
// the status field, so launch day flips PreOrder to InStock with no edit
|
||||
// here.
|
||||
//
|
||||
// A donation emits none of it: it has no price, no shipping and no return
|
||||
// policy, and a Product record whose offer names no amount is a claim
|
||||
// shopping crawlers can only misread.
|
||||
//
|
||||
// Merchant-grade: each offer also carries shippingDetails and a return
|
||||
// policy, which is what Google Merchant Center's website-crawl feed needs
|
||||
// to list the product without a CSV in sight — productGroupID is what it
|
||||
|
|
@ -971,7 +1055,7 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
// at single-unit weight, the same integers checkout charges, so the listing
|
||||
// and the till cannot disagree; a destination with no carrier rate is
|
||||
// simply not advertised, because it is not for sale.
|
||||
{
|
||||
if (!product.donation) {
|
||||
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
|
||||
std::string_view availability =
|
||||
product.Buyable() ? "https://schema.org/InStock"
|
||||
|
|
@ -1154,7 +1238,9 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
Escape(product.safetyNote));
|
||||
|
||||
SafeHtml buy;
|
||||
if (product.Buyable()) {
|
||||
if (product.donation && product.Buyable()) {
|
||||
buy = RenderDonationForm(product, errors, prev, offerCrypto);
|
||||
} else if (product.Buyable()) {
|
||||
buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto);
|
||||
} else if (product.ComingSoon()) {
|
||||
// The launch prices are already public, per colour, with the same
|
||||
|
|
@ -1181,6 +1267,26 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
R"(is in flux. Check back, or watch the posts.</p></section>)");
|
||||
}
|
||||
|
||||
// The spec and warranty sections exist only where the content does: a
|
||||
// donation has neither a spec sheet nor a warranty, and an empty table
|
||||
// under a Fairphone-specific lede would be nonsense on its page.
|
||||
const SafeHtml specsSection = product.specs.empty() ? SafeHtml{} : Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Specifications</h2>)"
|
||||
R"(<p class="section__lede">The hardware is a stock Fairphone )"
|
||||
R"((Gen. 6), unmodified. Fairphone's spec sheet is this product's spec sheet, )"
|
||||
R"(and all of it works under postmarketOS. The one caveat is the )"
|
||||
R"(emergency-calling warning above.</p>)"
|
||||
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
|
||||
R"(</section>)",
|
||||
Join(specRows));
|
||||
const SafeHtml warrantySection = product.warranty.empty() ? SafeHtml{} : Format(
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Warranty</h2>)"
|
||||
R"(<p>{}</p>)"
|
||||
R"(</section>)",
|
||||
Escape(product.warranty));
|
||||
|
||||
page.main = Format(
|
||||
R"(<header class="page-header">)"
|
||||
R"(<h1 class="page-header__title">{}</h1>)"
|
||||
|
|
@ -1190,23 +1296,15 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
R"({})"
|
||||
R"(<p class="product__summary">{}</p>)"
|
||||
R"({})"
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Specifications</h2>)"
|
||||
R"(<p class="section__lede">The hardware is a stock Fairphone )"
|
||||
R"((Gen. 6), unmodified. Fairphone's spec sheet is this product's spec sheet, )"
|
||||
R"(and all of it works under postmarketOS. The one caveat is the )"
|
||||
R"(emergency-calling warning above.</p>)"
|
||||
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
|
||||
R"(</section>)"
|
||||
R"(<section class="section">)"
|
||||
R"(<h2 class="section__title">Warranty</h2>)"
|
||||
R"(<p>{}</p>)"
|
||||
R"(</section>)"
|
||||
R"({})"
|
||||
R"({})"
|
||||
R"({})",
|
||||
Escape(product.name), Escape(product.tagline),
|
||||
media, RenderPriceLine(product, rates), Escape(product.summary),
|
||||
safety, Join(specRows),
|
||||
Escape(product.warranty),
|
||||
media,
|
||||
product.donation ? SafeHtml{} : RenderPriceLine(product, rates),
|
||||
Escape(product.summary),
|
||||
safety, specsSection,
|
||||
warrantySection,
|
||||
buy);
|
||||
return page;
|
||||
}
|
||||
|
|
@ -1346,15 +1444,25 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
Escape(o.reference),
|
||||
Raw("A payment left uncompleted simply lapses the order."));
|
||||
} else if (o.status == "paid") {
|
||||
payBlock = Format(
|
||||
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
|
||||
R"(<p>The device is ordered, flashed and tested, then shipped. Allow up )"
|
||||
R"(to a week before dispatch. Updates land in your email.</p>)"
|
||||
R"(<p><a class="btn btn--primary"{} download>Download invoice (.md)</a></p>)"
|
||||
R"(<p class="order__note">GPG-clearsigned markdown. It verifies with )"
|
||||
R"(gpg --verify, independent of this site.</p>)"
|
||||
R"(</section>)",
|
||||
Url("href", "/order/" + o.token + "/invoice.md"));
|
||||
// A donation ships nothing and gets no invoice — a gift with nothing
|
||||
// supplied in return is not a taxable supply — so its paid state is a
|
||||
// thank-you, not a dispatch promise with a download button.
|
||||
payBlock = o.donation
|
||||
? Raw(R"(<section class="section"><h2 class="section__title">Thank you</h2>)"
|
||||
R"(<p>Your donation funds the open-source work directly. It will )"
|
||||
R"(appear in the running total on the financials page — as )"
|
||||
R"(an aggregate, never individually. This page is your receipt; )"
|
||||
R"(no invoice is issued for a donation.</p>)"
|
||||
R"(</section>)")
|
||||
: Format(
|
||||
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
|
||||
R"(<p>The device is ordered, flashed and tested, then shipped. Allow up )"
|
||||
R"(to a week before dispatch. Updates land in your email.</p>)"
|
||||
R"(<p><a class="btn btn--primary"{} download>Download invoice (.md)</a></p>)"
|
||||
R"(<p class="order__note">GPG-clearsigned markdown. It verifies with )"
|
||||
R"(gpg --verify, independent of this site.</p>)"
|
||||
R"(</section>)",
|
||||
Url("href", "/order/" + o.token + "/invoice.md"));
|
||||
}
|
||||
|
||||
// The paid state IS the success page — say so before the receipt table.
|
||||
|
|
@ -1363,6 +1471,18 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
R"(confirmed. This page is your receipt.</p>)")
|
||||
: SafeHtml{};
|
||||
|
||||
// A donation's money is one line — an amount with nothing shipped adds no
|
||||
// shipping row and needs no separate total. Goods orders keep the full
|
||||
// breakdown.
|
||||
const SafeHtml moneyRows = o.donation
|
||||
? MoneyRow("Donation", o.totalMinor)
|
||||
: Format(R"({}{}{})",
|
||||
MoneyRow(o.quantity > 1
|
||||
? std::format("Device × {}", o.quantity)
|
||||
: std::string("Device"), o.goodsMinor),
|
||||
MoneyRow("Shipping", o.shippingMinor),
|
||||
MoneyRow("Total", o.totalMinor));
|
||||
|
||||
page.main = Format(
|
||||
R"(<header class="page-header">)"
|
||||
R"(<h1 class="page-header__title">Order {}</h1>)"
|
||||
|
|
@ -1374,14 +1494,12 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
R"(<h2 class="section__title">Total</h2>)"
|
||||
R"(<table class="spec-table"><tbody>)"
|
||||
R"({})"
|
||||
R"({})"
|
||||
R"({})"
|
||||
R"(</tbody></table>)"
|
||||
R"(<p class="order__vat">{}</p>)"
|
||||
R"(</section>)"
|
||||
R"({})"
|
||||
R"(<p class="order__keep">There is no account; this link is the access. )"
|
||||
R"(Download the invoice and keep it. This page is not archived )"
|
||||
R"({} This page is not archived )"
|
||||
R"(forever.</p>)",
|
||||
Escape(o.reference),
|
||||
Escape(o.colorLabel.empty()
|
||||
|
|
@ -1390,19 +1508,22 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
|
|||
Escape(o.createdAt),
|
||||
statusLine,
|
||||
confirmation,
|
||||
MoneyRow(o.quantity > 1
|
||||
? std::format("Device × {}", o.quantity)
|
||||
: std::string("Device"), o.goodsMinor),
|
||||
MoneyRow("Shipping", o.shippingMinor),
|
||||
MoneyRow("Total", o.totalMinor),
|
||||
o.vatIncluded
|
||||
? Raw("Includes 21% Dutch VAT.")
|
||||
: Raw("Zero-rated export: no EU VAT charged. Import duty, import "
|
||||
"VAT, tariffs and any carrier handling fee are charged on arrival "
|
||||
"and are solely between you, the courier and your customs "
|
||||
"authority; Catcrafts does not collect them and is not a party "
|
||||
"to them."),
|
||||
payBlock);
|
||||
moneyRows,
|
||||
o.donation
|
||||
// The user's rule, stated plainly: 0% — a donation is a gift, not
|
||||
// a supply, so no VAT arises and neither export wording applies.
|
||||
? Raw("VAT 0%: no VAT is charged on a donation — nothing is "
|
||||
"supplied in return.")
|
||||
: o.vatIncluded
|
||||
? Raw("Includes 21% Dutch VAT.")
|
||||
: Raw("Zero-rated export: no EU VAT charged. Import duty, import "
|
||||
"VAT, tariffs and any carrier handling fee are charged on arrival "
|
||||
"and are solely between you, the courier and your customs "
|
||||
"authority; Catcrafts does not collect them and is not a party "
|
||||
"to them."),
|
||||
payBlock,
|
||||
o.donation ? Raw("Keep it if you want the receipt.")
|
||||
: Raw("Download the invoice and keep it."));
|
||||
// A plain <meta refresh> is the no-JavaScript way to make the page track
|
||||
// the payment: the browser refetches, the server re-reads the order. Only
|
||||
// while awaiting — a paid page has nothing to poll for.
|
||||
|
|
@ -1528,9 +1649,18 @@ export RenderedPage RenderAbout(const LegalPage& about) {
|
|||
// 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.
|
||||
//
|
||||
// Donations arrive from TWO ledgers: the bank aggregates in `fin`, and the
|
||||
// shop's own order ledger (`shopDonationCount`/`shopDonationsMinor`) — the
|
||||
// donation item is paid through the same rails as a sale, so its money never
|
||||
// touches the bank categoriser. The page shows one Donations row summing
|
||||
// both; splitting them by collection channel would be bookkeeping trivia the
|
||||
// reader has no use for.
|
||||
export RenderedPage RenderFinancials(std::int64_t salesCount,
|
||||
std::int64_t salesTotalMinor,
|
||||
const Financials& fin) {
|
||||
const Financials& fin,
|
||||
std::int64_t shopDonationCount = 0,
|
||||
std::int64_t shopDonationsMinor = 0) {
|
||||
const LegalPage& notes = Content::FinancialsPage();
|
||||
|
||||
// A total row is ruled off from the rows it sums, the way a ledger is.
|
||||
|
|
@ -1540,20 +1670,25 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
|
|||
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.
|
||||
// Income. Sales are always live; the donation row exists once EITHER
|
||||
// source has figures — the bank aggregates, or a donation paid through
|
||||
// the shop (live from the order ledger, like sales). Before both, a €0
|
||||
// the page cannot yet know would be a lie, and so would an income total
|
||||
// missing half its inputs.
|
||||
const std::int64_t donationCount = fin.donationCount + shopDonationCount;
|
||||
const std::int64_t donationsMinor = fin.donationsMinor + shopDonationsMinor;
|
||||
const bool showDonations = fin.Loaded() || shopDonationCount > 0;
|
||||
std::vector<SafeHtml> incomeRows;
|
||||
if (fin.Loaded()) {
|
||||
if (showDonations) {
|
||||
incomeRows.push_back(MoneyRow(
|
||||
std::format("Donations ({})", fin.donationCount),
|
||||
fin.donationsMinor));
|
||||
std::format("Donations ({})", donationCount),
|
||||
donationsMinor));
|
||||
}
|
||||
incomeRows.push_back(MoneyRow(
|
||||
std::format("Sales ({})", salesCount),
|
||||
salesTotalMinor));
|
||||
if (fin.Loaded()) {
|
||||
incomeRows.push_back(totalRow("Income", fin.donationsMinor + salesTotalMinor));
|
||||
incomeRows.push_back(totalRow("Income", donationsMinor + salesTotalMinor));
|
||||
}
|
||||
|
||||
// Expenses: one flat table. No recurring/one-off grouping — see the note
|
||||
|
|
@ -1583,7 +1718,7 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
|
|||
// 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();
|
||||
donationsMinor + salesTotalMinor - fin.ExpensesMinor();
|
||||
SafeHtml netBlock;
|
||||
if (fin.Loaded()) {
|
||||
netBlock = Format(
|
||||
|
|
@ -1601,10 +1736,12 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
|
|||
|
||||
// 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 · )"
|
||||
? Format(R"(<p class="legal__updated">Sales and shop donations are live )"
|
||||
R"(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>)");
|
||||
: Raw(R"(<p class="legal__updated">Sales and shop donations are live )"
|
||||
R"(from the order ledger</p>)");
|
||||
|
||||
// The methodology prose, in the legal pages' section shape and CSS.
|
||||
std::vector<SafeHtml> sections;
|
||||
|
|
@ -1639,10 +1776,10 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
|
|||
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{},
|
||||
showDonations ? Attr("data-fin-donations-count", std::to_string(donationCount))
|
||||
: SafeHtml{},
|
||||
showDonations ? Attr("data-fin-donations-minor", std::to_string(donationsMinor))
|
||||
: SafeHtml{},
|
||||
fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor()))
|
||||
: SafeHtml{},
|
||||
fin.Loaded() ? Attr("data-fin-net-minor", std::to_string(netMinor)) : SafeHtml{},
|
||||
|
|
|
|||
Loading…
Reference in a new issue