This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -25,6 +25,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
|
||||
export module Catcrafts.Shared:Form;
|
||||
import std;
|
||||
import :Money; // country policy: which destinations the shop sells to
|
||||
|
||||
namespace Catcrafts::Form {
|
||||
|
||||
|
|
@ -159,7 +160,7 @@ export bool LooksLikeEmail(std::string_view s) {
|
|||
}
|
||||
|
||||
// ISO 3166-1 alpha-2, uppercased. Shape only — whether we actually ship there
|
||||
// is a policy question answered elsewhere, not a validation one.
|
||||
// is a policy question, answered by Money::SellsTo in ValidateCheckout below.
|
||||
export bool LooksLikeCountryCode(std::string_view s) {
|
||||
if (s.size() != 2) return false;
|
||||
for (const char c : s) {
|
||||
|
|
@ -192,14 +193,100 @@ 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;
|
||||
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
|
||||
// the handler's check, like the colour: this is a
|
||||
// shape check and nothing more.
|
||||
};
|
||||
|
||||
// A technical sanity bound, not a business cap — bulk orders are welcome.
|
||||
// It exists because the integer math (here and mirrored in the preview
|
||||
// script) and a bunq payment link both need SOME ceiling, and an order of a
|
||||
// hundred phones deserves an email conversation more than a form submit.
|
||||
// How the buyer's money moves. Two KINDS of money movement, not two brand
|
||||
// names: which provider serves each is the server's configuration, and writing
|
||||
// the kind (rather than "mollie"/"coingate") into the form and the ledger means
|
||||
// swapping a provider cannot retroactively rewrite what a buyer picked.
|
||||
//
|
||||
// These strings are the wire format — they travel in the form post and land
|
||||
// verbatim in the order log — so they are defined once, here, where both the
|
||||
// form that emits them and the server that stores them can see them.
|
||||
export inline constexpr std::string_view kPayBank = "bank";
|
||||
export inline constexpr std::string_view kPayCrypto = "crypto";
|
||||
|
||||
// The outer sanity bound on the parsed integer — NOT the quantity a buyer can
|
||||
// actually order. That limit is physical and per-destination: one order is one
|
||||
// parcel, so what fits is the heaviest carrier bracket for that country
|
||||
// divided by the boxed unit weight (Money::MaxUnitsFor). The form renders the
|
||||
// best case across destinations as its `max`, the preview narrows it the
|
||||
// moment a country is typed, and the handler enforces the real one.
|
||||
//
|
||||
// This constant survives because validation has to reject a hostile
|
||||
// "quantity=99999999999" before any of that arithmetic runs.
|
||||
export inline constexpr std::int64_t kMaxQuantity = 99;
|
||||
|
||||
// What a buyer in a NoSaleCountries destination is told. Stated as a standing
|
||||
// fact about where the shop sells, not as an apology or an outage: someone
|
||||
// there should close the tab rather than retry tomorrow or hunt for a
|
||||
// workaround. The reason itself (insurance territory) is on the terms page —
|
||||
// a form field is the wrong place for it.
|
||||
//
|
||||
// One definition, three renderings: this error, the note above the form, and
|
||||
// the on-page total preview, so the page can never encourage an order the
|
||||
// server will refuse.
|
||||
export inline constexpr std::string_view kNoSaleMessage =
|
||||
"Catcrafts does not sell or ship to the United States or Canada.";
|
||||
|
||||
// The two shipping refusals, worded once.
|
||||
//
|
||||
// Since the shop stopped carrying its own rate table, the carrier's coverage
|
||||
// IS the shop's coverage: no bracket for a country means no price exists to
|
||||
// charge, and a parcel above every bracket is one the carrier will not take.
|
||||
// Both refuse rather than guess — a quote the shop cannot honour is worse than
|
||||
// a no — and both name a way forward, because a bare "can't" makes the buyer
|
||||
// guess whether to try again or give up.
|
||||
//
|
||||
// Kept as {cc}/{n} templates rather than format strings because they have two
|
||||
// consumers: the handler fills them for its field errors, and the buy page
|
||||
// hands them to the total preview verbatim to fill client-side. Same sentence
|
||||
// before and after the submit, from one definition.
|
||||
export inline constexpr std::string_view kNoShippingTemplate =
|
||||
"No carrier rate for {cc} is available right now, so this order can't be "
|
||||
"priced. Email orders@catcrafts.net and it gets arranged by hand.";
|
||||
|
||||
export inline constexpr std::string_view kTooHeavyTemplate =
|
||||
"That is more than fits one parcel to {cc} — up to {n} per order. For a "
|
||||
"larger order email orders@catcrafts.net.";
|
||||
|
||||
// The degenerate case: a destination whose heaviest bracket does not even carry
|
||||
// one boxed unit. "Order fewer" is not advice when fewer is zero, so it gets
|
||||
// its own sentence.
|
||||
export inline constexpr std::string_view kTooHeavyNoneTemplate =
|
||||
"A parcel this heavy can't be shipped to {cc} by any rate available. "
|
||||
"Email orders@catcrafts.net.";
|
||||
|
||||
export std::string FillShipMessage(std::string_view tmpl, std::string_view cc,
|
||||
std::int64_t n) {
|
||||
std::string out(tmpl);
|
||||
for (const auto& [token, value] :
|
||||
std::initializer_list<std::pair<std::string_view, std::string>>{
|
||||
{ "{cc}", std::string(cc) }, { "{n}", std::to_string(n) } }) {
|
||||
for (std::size_t at = out.find(token); at != std::string::npos;
|
||||
at = out.find(token, at + value.size())) {
|
||||
out.replace(at, token.size(), value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export std::string NoShippingMessage(std::string_view cc) {
|
||||
return FillShipMessage(kNoShippingTemplate, cc, 0);
|
||||
}
|
||||
|
||||
// `maxUnits` is what the heaviest bracket to that country actually fits, and
|
||||
// may be zero.
|
||||
export std::string TooHeavyMessage(std::string_view cc, std::int64_t maxUnits) {
|
||||
return FillShipMessage(maxUnits > 0 ? kTooHeavyTemplate : kTooHeavyNoneTemplate,
|
||||
cc, maxUnits);
|
||||
}
|
||||
|
||||
export struct CheckoutResult {
|
||||
Checkout value;
|
||||
std::vector<FieldError> errors;
|
||||
|
|
@ -259,6 +346,10 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
|
|||
r.errors.push_back({ "country", "Pick a country — it decides shipping and VAT treatment." });
|
||||
} else if (!LooksLikeCountryCode(country)) {
|
||||
r.errors.push_back({ "country", "Country must be a two-letter code." });
|
||||
} else if (!Money::SellsTo(r.value.country)) {
|
||||
// The refusal happens here, in validation, rather than at the payment
|
||||
// step: no order record, no payment link, nothing charged to undo.
|
||||
r.errors.push_back({ "country", std::string(kNoSaleMessage) });
|
||||
}
|
||||
|
||||
// Colour: shape only (slug-ish, bounded). Whether it names a variant that
|
||||
|
|
@ -269,6 +360,20 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
|
|||
r.errors.push_back({ "color", "That is not one of the colours." });
|
||||
}
|
||||
|
||||
// How they want to pay. Absent means the form did not render the choice
|
||||
// (only one rail configured, or the no-JS fallback page), which the
|
||||
// handler resolves to bank.
|
||||
//
|
||||
// An unrecognised value is rejected rather than defaulted: the two ways it
|
||||
// can happen are a tampered post and a form that has drifted from this
|
||||
// validator, and quietly charging someone through a rail they did not pick
|
||||
// is the wrong answer to both.
|
||||
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." });
|
||||
}
|
||||
|
||||
// Quantity: a small positive integer, nothing else. Absent means 1 (the
|
||||
// no-JS form default); anything unparseable or out of range is rejected
|
||||
// rather than clamped — silently changing how many phones someone buys is
|
||||
|
|
|
|||
Loading…
Reference in a new issue