donation item, shop soft open
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:
Jorijn van der Graaf 2026-08-17 11:04:03 +02:00
commit abbd616b40
23 changed files with 2898 additions and 209 deletions

View file

@ -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