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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue