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
|
|
@ -23,18 +23,36 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// them at build time (fediverse fetch, ECB rates), and shell writes JSON,
|
||||
// not C++.
|
||||
//
|
||||
// PRICING RULE (the user's): retail = supplier price + markup, exactly.
|
||||
// Supplier prices are what the retailer currently charges (incl VAT);
|
||||
// change one number when the supplier moves and the margin stays put.
|
||||
// PRICING RULE (the user's): retail = supplier price + markup, exactly, and
|
||||
// the markup is what Catcrafts walks away with AFTER shipping and VAT — €50
|
||||
// net, with Mollie's transaction fee as the only accepted leak. Supplier
|
||||
// prices are what the retailer currently charges (incl VAT); change one
|
||||
// number when the supplier moves and the margin stays put.
|
||||
|
||||
export module Catcrafts.Shared:Content;
|
||||
import std;
|
||||
import :Model;
|
||||
import :Money;
|
||||
|
||||
namespace Catcrafts::Content {
|
||||
|
||||
// The flat markup on every variant — "whatever it costs me + 50".
|
||||
inline constexpr std::int64_t kMarkupMinor = 5000;
|
||||
// The flat markup on every variant — "whatever it costs me + 50", where the
|
||||
// €50 is what Catcrafts KEEPS. Retail is VAT-inclusive, so a markup added to
|
||||
// it is taxed with the rest of the price: a flat +5000 would net only €41.32
|
||||
// once the VAT on it is remitted. Grossed up once here (€60.50 on the
|
||||
// sticker), the walk-away margin is exactly €50 per unit — on EU sales and
|
||||
// on exports alike, since the export price is the net of the same retail.
|
||||
inline constexpr std::int64_t kMarkupNetMinor = 5000;
|
||||
inline constexpr std::int64_t kMarkupMinor = Money::GrossFromNet(kMarkupNetMinor);
|
||||
// Exactness is not luck: NetFromGross strips a gross amount additively only
|
||||
// when markup × 10000 divides evenly by the VAT denominator, and 6050 does
|
||||
// (6050 × 10000 = 12100 × 5000). That makes NetFromGross(supplier + markup)
|
||||
// equal NetFromGross(supplier) + 5000 for EVERY supplier price — no variant
|
||||
// can round the margin away.
|
||||
static_assert(kMarkupMinor * 10000 % (10000 + Money::kVatRateBp) == 0,
|
||||
"gross markup must net back exactly for every supplier price");
|
||||
static_assert(kMarkupMinor * 10000 / (10000 + Money::kVatRateBp) == kMarkupNetMinor,
|
||||
"the exact net of the markup must be the €50 the rule promises");
|
||||
|
||||
export const std::vector<Product>& Products() {
|
||||
static const std::vector<Product> products = [] {
|
||||
|
|
@ -89,7 +107,25 @@ export const std::vector<Product>& Products() {
|
|||
if (const Variant* cheapest = p.CheapestVariant()) {
|
||||
p.priceInclMinor = cheapest->priceInclMinor;
|
||||
}
|
||||
return std::vector<Product>{ std::move(p) };
|
||||
|
||||
// The donation item: the shop's soft opening. It is "available" while
|
||||
// the phone above stays "coming-soon" on purpose — the two rails, the
|
||||
// ledger, the reconciler and the mailer all run for real money before
|
||||
// the first phone order depends on them. The buyer names the amount
|
||||
// (Form::ValidateDonation bounds it); nothing ships and no VAT is
|
||||
// charged, because a gift with nothing supplied in return is not a
|
||||
// taxable supply — which is also why a donation never gets an invoice.
|
||||
// fp6-pmos stays FIRST in this vector: it is the shop's headline, and
|
||||
// tests address Products()[0] as the priced product.
|
||||
Product d;
|
||||
d.slug = "donation";
|
||||
d.name = "Donation";
|
||||
d.tagline = "Fund the open-source work directly: any amount, no goods, "
|
||||
"no VAT.";
|
||||
d.status = "available";
|
||||
d.donation = true;
|
||||
d.summary = "Thank you very much for considering to donate! Your donation will contribute to the maintenance of the image and R&D for new devices.";
|
||||
return std::vector<Product>{ std::move(p), std::move(d) };
|
||||
}();
|
||||
return products;
|
||||
}
|
||||
|
|
@ -206,12 +242,12 @@ export const LegalPage& FinancialsPage() {
|
|||
static const LegalPage page{
|
||||
.slug = "financials",
|
||||
.title = "Financials",
|
||||
.updated = "2026-08-14",
|
||||
.updated = "2026-08-17",
|
||||
.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.",
|
||||
"Sales, and donations made through the shop, come straight from the shop's order ledger and update the moment they are paid. Donations to the bank account 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.",
|
||||
} },
|
||||
{ "What is never published",
|
||||
|
|
@ -291,7 +327,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
.slug = "terms",
|
||||
.title = "Terms",
|
||||
.updated = "2026-08-15",
|
||||
.updated = "2026-08-17",
|
||||
.lede = "The terms for buying from this shop. Written to be read: short sections, no boilerplate imported from anywhere, and every claim checkable against what the site actually does.",
|
||||
.sections = {
|
||||
{ "Ordering and payment",
|
||||
|
|
@ -302,6 +338,12 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
"Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. Payment is accepted in EURC only, which is denominated in euros: the amount to send is exactly the euro price, with no exchange rate involved, and it must arrive in full on one of the networks the order page lists — a payment split across several networks cannot be accepted automatically. Every refund under the sections below is likewise owed in euros and is paid in EURC, to a wallet address you give at the time, since there is nothing to send it back to otherwise.",
|
||||
"For support related to orders please contact orders@catcrafts.net"
|
||||
} },
|
||||
{ "Donations",
|
||||
{
|
||||
"The shop also takes donations: you name the amount, and the same payment methods apply — Mollie for bank and card, EURC for cryptocurrency. A donation is a gift that funds the open-source work; nothing is supplied in return, so no VAT is charged and no invoice is issued. The order page for a donation is its receipt.",
|
||||
"Because nothing ships, no name or address is asked for. An email address is optional and is used only to send the confirmation. Donations appear on the financials page as an aggregate running total, never individually.",
|
||||
"A donation is not a purchase, so the returns section below does not apply to it. A mistaken donation — a typo in the amount, a double payment — is refunded on request: email info@catcrafts.net.",
|
||||
} },
|
||||
{ "Fulfilment",
|
||||
{
|
||||
"Devices are sourced, flashed and tested to order. There is no warehouse. Allow up to a week between payment and dispatch; the order page and email updates track it. If sourcing falls through, you get the money back, promptly and in full.",
|
||||
|
|
|
|||
|
|
@ -193,6 +193,12 @@ export struct Checkout {
|
|||
std::string color; // variant slug; whether it EXISTS is the handler's
|
||||
// check against the catalogue, not a shape check
|
||||
std::int64_t quantity = 1;
|
||||
// The donation amount in cents, set only by ValidateDonation. This is the
|
||||
// ONE amount that ever arrives from the client — a donation has no
|
||||
// catalogue price to compute from — and it is bounded here and re-derived
|
||||
// nowhere, so the handler charges exactly what was validated. Zero for
|
||||
// every goods checkout, where money still never comes from the client.
|
||||
std::int64_t amountMinor = 0;
|
||||
std::string payChoice; // kPayBank | kPayCrypto; empty means the form did
|
||||
// not offer a choice, which the handler reads as
|
||||
// bank. Whether the chosen rail is CONFIGURED is
|
||||
|
|
@ -414,4 +420,100 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
|
|||
return r;
|
||||
}
|
||||
|
||||
// ── donations ─────────────────────────────────────────────────────────
|
||||
|
||||
// The bounds on a donation, in cents. The floor keeps the amount above the
|
||||
// payment rails' own minimums and the fees that would eat a smaller gift; the
|
||||
// ceiling is an anti-fat-finger and anti-abuse bound — anyone genuinely
|
||||
// wanting to give more is an email conversation, not a form post.
|
||||
export inline constexpr std::int64_t kMinDonationMinor = 100; // €1
|
||||
export inline constexpr std::int64_t kMaxDonationMinor = 1'000'000; // €10,000
|
||||
|
||||
// Exact decimal-euros-to-cents parsing: "25" -> 2500, "12.50" -> 1250, and a
|
||||
// comma decimal mark is accepted because half the donors here will type one.
|
||||
// Anything else — sign, exponent, a third decimal, stray text — is nullopt
|
||||
// rather than a guess. Integer arithmetic throughout; like every money path
|
||||
// in this codebase, no float ever touches the amount.
|
||||
export std::optional<std::int64_t> ParseEuroAmountToMinor(std::string_view s) {
|
||||
if (s.empty() || s.size() > 10) return std::nullopt;
|
||||
std::size_t mark = std::string_view::npos;
|
||||
for (std::size_t i = 0; i < s.size(); ++i) {
|
||||
if (s[i] == '.' || s[i] == ',') {
|
||||
if (mark != std::string_view::npos) return std::nullopt;
|
||||
mark = i;
|
||||
} else if (s[i] < '0' || s[i] > '9') {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
const std::string_view whole = s.substr(0, mark);
|
||||
const std::string_view frac =
|
||||
mark == std::string_view::npos ? std::string_view{} : s.substr(mark + 1);
|
||||
if (whole.empty() || frac.size() > 2) return std::nullopt;
|
||||
std::int64_t euros = 0;
|
||||
auto [p, ec] = std::from_chars(whole.data(), whole.data() + whole.size(), euros);
|
||||
if (ec != std::errc{} || p != whole.data() + whole.size()) return std::nullopt;
|
||||
std::int64_t cents = 0;
|
||||
if (!frac.empty()) {
|
||||
auto [fp, fec] = std::from_chars(frac.data(), frac.data() + frac.size(), cents);
|
||||
if (fec != std::errc{} || fp != frac.data() + frac.size()) return std::nullopt;
|
||||
if (frac.size() == 1) cents *= 10; // "2.5" is €2.50, not €2.05
|
||||
}
|
||||
return euros * 100 + cents;
|
||||
}
|
||||
|
||||
// Validate a submitted donation. Deliberately NOT ValidateCheckout with
|
||||
// fields waived: a donation ships nothing, so no name or address is even
|
||||
// asked for — collecting them would break the privacy notice's "what
|
||||
// fulfilling it requires" rule, not just pad the form.
|
||||
//
|
||||
// Email is OPTIONAL, the one shape difference worth a comment: the order
|
||||
// page's capability URL is already the receipt, so identity is only needed
|
||||
// if the donor wants the confirmation emailed. An empty email means no email,
|
||||
// never an error.
|
||||
export CheckoutResult ValidateDonation(const Fields& f) {
|
||||
CheckoutResult r;
|
||||
r.value.quantity = 1; // a donation is one line, always
|
||||
|
||||
// The same honeypot as checkout, reported just as namelessly.
|
||||
if (!Trim(f.Get("website")).empty()) {
|
||||
r.errors.push_back({ "", "Submission rejected." });
|
||||
return r;
|
||||
}
|
||||
|
||||
const std::string_view email = Trim(f.Get("email"));
|
||||
r.value.email = std::string(email);
|
||||
if (!email.empty() && !LooksLikeEmail(email)) {
|
||||
r.errors.push_back({ "email", "That doesn't look like an email address." });
|
||||
}
|
||||
|
||||
// The amount: present, parseable, in bounds. Out of range is rejected
|
||||
// rather than clamped — silently moving someone's gift is worse than
|
||||
// asking again, same rule as checkout's quantity.
|
||||
const std::string_view amount = Trim(f.Get("amount"));
|
||||
if (amount.empty()) {
|
||||
r.errors.push_back({ "amount", "Name an amount — any euro amount you like." });
|
||||
} else if (const auto minor = ParseEuroAmountToMinor(amount); !minor) {
|
||||
r.errors.push_back({ "amount", "That doesn't look like a euro amount." });
|
||||
} else if (*minor < kMinDonationMinor || *minor > kMaxDonationMinor) {
|
||||
r.errors.push_back({ "amount",
|
||||
std::format("Donations are accepted from {} to {} — for more, "
|
||||
"email info@catcrafts.net.",
|
||||
Money::FormatEuro(kMinDonationMinor),
|
||||
Money::FormatEuro(kMaxDonationMinor)) });
|
||||
} else {
|
||||
r.value.amountMinor = *minor;
|
||||
}
|
||||
|
||||
// The payment choice, exactly as checkout reads it: absent means the form
|
||||
// offered no choice and the handler takes the bank rail; an unrecognised
|
||||
// word is a tampered post or a drifted form, and both are refused.
|
||||
const std::string_view pay = Trim(f.Get("pay"));
|
||||
r.value.payChoice = std::string(pay);
|
||||
if (!pay.empty() && pay != kPayBank && pay != kPayCrypto) {
|
||||
r.errors.push_back({ "pay", "Pick one of the payment methods." });
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Form
|
||||
|
|
|
|||
|
|
@ -200,6 +200,13 @@ export struct Product {
|
|||
// gap, price swing). In both closed states the page stays up, the buy
|
||||
// form does not, and the checkout POST is refused server-side.
|
||||
std::string status;
|
||||
// A donation rather than goods: the buyer names the amount, nothing ships,
|
||||
// and no VAT is charged (a gift with nothing supplied in return is not a
|
||||
// taxable supply). This flag is what routes a checkout POST through the
|
||||
// donation validator instead of the address-and-shipping one, so setting
|
||||
// it on a priced product would let orders skip shipping — it belongs on
|
||||
// exactly one kind of catalogue entry.
|
||||
bool donation = false;
|
||||
// The EU consumer price, VAT-inclusive, in cents. With variants present
|
||||
// this is the FROM price (cheapest variant) and is kept in sync by the
|
||||
// loader; without variants it is simply the price.
|
||||
|
|
@ -226,7 +233,11 @@ export struct Product {
|
|||
std::string safetyNote;
|
||||
std::vector<Spec> specs;
|
||||
|
||||
bool Buyable() const { return status == "available" && priceInclMinor > 0; }
|
||||
// A donation has no fixed price by definition, so the price check applies
|
||||
// only to goods — where a zero price is a content bug that must not sell.
|
||||
bool Buyable() const {
|
||||
return status == "available" && (donation || priceInclMinor > 0);
|
||||
}
|
||||
bool ComingSoon() const { return status == "coming-soon"; }
|
||||
|
||||
// nullptr for a colour we never listed — the checkout rejects rather than
|
||||
|
|
@ -381,6 +392,10 @@ export struct OrderView {
|
|||
std::int64_t shippingMinor = 0;
|
||||
std::int64_t totalMinor = 0;
|
||||
bool vatIncluded = false;
|
||||
// A donation order: one amount, nothing ships, no VAT, no invoice. The
|
||||
// renderer folds the money table to a single row and swaps the paid-state
|
||||
// copy from "your device ships" to a thank-you.
|
||||
bool donation = false;
|
||||
// Present only for an awaiting order on a rail without a hosted checkout;
|
||||
// the page then renders instructions instead of a "resume payment" button.
|
||||
std::optional<OrderCryptoPay> cryptoPay;
|
||||
|
|
|
|||
|
|
@ -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