This commit is contained in:
parent
749f525f83
commit
b0666841f6
7 changed files with 139 additions and 31 deletions
|
|
@ -291,7 +291,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
.slug = "terms",
|
||||
.title = "Terms",
|
||||
.updated = "2026-08-14",
|
||||
.updated = "2026-08-15",
|
||||
.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",
|
||||
|
|
@ -311,6 +311,7 @@ export const std::vector<LegalPage>& LegalPages() {
|
|||
{
|
||||
"Catcrafts does not sell or ship to the United States or Canada. Checkout refuses a delivery address in either country, and no order can be placed from one. This is a deliberate decision about liability cover, which for a shop this size is written for the world excluding those two countries, and not a judgement about anyone living there.",
|
||||
"The same applies to an order that is bound for either country by another route: if a parcel is to be forwarded there, or the delivery address belongs to a forwarding service acting for a customer there, the order is refused, and refunded in full if that only becomes clear after payment. Please do not try to route around this — the point is that the sale does not happen, not that the address looks European.",
|
||||
"Separately, Catcrafts cannot sell or ship to Russia, Belarus or North Korea. Unlike the paragraphs above this is not a choice: European Union sanctions prohibit exporting consumer electronics to those countries, and the prohibition covers indirect routes — a forwarding address, a reseller, or a purchase made on behalf of someone there — just as much as a direct parcel. Checkout refuses a delivery address in any of them, and an order that turns out to be bound there by another route is refused and the payment returned.",
|
||||
"Everywhere else Catcrafts ships is served on the terms above, and the software itself remains free for anyone anywhere: the sources and images are public, and flashing a device you already own is not a purchase and is not restricted by this section.",
|
||||
} },
|
||||
{ "Warranty",
|
||||
|
|
|
|||
|
|
@ -234,22 +234,33 @@ export inline constexpr std::int64_t kMaxQuantity = 99;
|
|||
export inline constexpr std::string_view kNoSaleMessage =
|
||||
"Catcrafts does not sell or ship to the United States or Canada.";
|
||||
|
||||
// The sanctions refusal, in different words on purpose: kNoSaleMessage states
|
||||
// a choice, this states a prohibition. Naming the reason here rather than only
|
||||
// on the terms page — unlike the insurance one — because "sanctions" is the
|
||||
// whole answer: nothing about the shop could change it, and a buyer told only
|
||||
// "no" would rightly ask why.
|
||||
export inline constexpr std::string_view kSanctionsMessage =
|
||||
"Catcrafts cannot sell or ship to Russia, Belarus or North Korea: "
|
||||
"EU sanctions prohibit exporting consumer electronics there.";
|
||||
|
||||
// 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.
|
||||
// a no. The uncovered-country refusal is final: no carrier service means the
|
||||
// shop does not ship there, and offering to arrange it by hand would promise
|
||||
// exactly the ad-hoc export the shop decided not to do. The too-heavy refusal
|
||||
// still names the quantity that WOULD fit, because that buyer has an order the
|
||||
// shop can take — just not in one parcel.
|
||||
//
|
||||
// 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.";
|
||||
"No carrier rate for {cc} is available, so Catcrafts can't ship there.";
|
||||
|
||||
export inline constexpr std::string_view kTooHeavyTemplate =
|
||||
"That is more than fits one parcel to {cc} — up to {n} per order. For a "
|
||||
|
|
@ -257,10 +268,11 @@ export inline constexpr std::string_view kTooHeavyTemplate =
|
|||
|
||||
// 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.
|
||||
// its own sentence — and like the uncovered country, it is a final no: what
|
||||
// the carrier can't take, the shop doesn't ship.
|
||||
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.";
|
||||
"A parcel this heavy can't be shipped to {cc} by any rate available, "
|
||||
"so this order can't be placed.";
|
||||
|
||||
export std::string FillShipMessage(std::string_view tmpl, std::string_view cc,
|
||||
std::int64_t n) {
|
||||
|
|
@ -346,6 +358,11 @@ 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::IsSanctioned(r.value.country)) {
|
||||
// Checked before the general refusal because SellsTo denies both and
|
||||
// the words differ: this one says the law forbids the sale, not that
|
||||
// the shop chose not to make it.
|
||||
r.errors.push_back({ "country", std::string(kSanctionsMessage) });
|
||||
} 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.
|
||||
|
|
|
|||
|
|
@ -98,11 +98,34 @@ export std::span<const std::string_view> NoSaleCountries() {
|
|||
return blocked;
|
||||
}
|
||||
|
||||
// Destinations the law forbids, as opposed to the insurance choice above.
|
||||
//
|
||||
// EU sanctions — Regulation 833/2014 for Russia, its Belarus mirror, and the
|
||||
// North Korea embargo — prohibit exporting consumer electronics to these
|
||||
// countries, by customs code and by the luxury-goods value threshold both, and
|
||||
// the prohibition covers indirect routes (a forwarder, a reseller) as much as
|
||||
// a direct parcel. That binds every EU seller as criminal law; there is no
|
||||
// small-shop exemption and no surcharge version of compliance. A separate list
|
||||
// rather than more entries in NoSaleCountries because the refusal needs
|
||||
// different words: "does not" is a choice, "cannot" is the law, and each gets
|
||||
// its own explanation on the terms page.
|
||||
export std::span<const std::string_view> SanctionedCountries() {
|
||||
static constexpr std::array<std::string_view, 3> blocked{ "RU", "BY", "KP" };
|
||||
return blocked;
|
||||
}
|
||||
|
||||
export bool IsSanctioned(std::string_view cc) {
|
||||
return std::ranges::find(SanctionedCountries(), cc) != SanctionedCountries().end();
|
||||
}
|
||||
|
||||
// ISO 3166-1 alpha-2, uppercase, like everything else here. Callers ask this
|
||||
// rather than comparing against "US" themselves, so the policy has exactly one
|
||||
// definition and adding a country later is a one-line change.
|
||||
// definition and adding a country later is a one-line change. Both lists deny:
|
||||
// most callers only need "is this destination for sale", and only the checkout
|
||||
// error message cares which refusal it is (IsSanctioned above).
|
||||
export bool SellsTo(std::string_view cc) {
|
||||
return std::ranges::find(NoSaleCountries(), cc) == NoSaleCountries().end();
|
||||
return !IsSanctioned(cc) &&
|
||||
std::ranges::find(NoSaleCountries(), cc) == NoSaleCountries().end();
|
||||
}
|
||||
|
||||
// Delivery-time tiers. NOT a price concept — every rate comes from the carrier
|
||||
|
|
|
|||
|
|
@ -762,10 +762,11 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
JsonStr(Form::kNoShippingTemplate),
|
||||
JsonStr(Form::kTooHeavyTemplate),
|
||||
JsonStr(Form::kTooHeavyNoneTemplate));
|
||||
// The destinations checkout refuses, and the sentence that says so. The
|
||||
// preview has to refuse exactly where the server does — a page that quotes
|
||||
// a total for an order the server will reject is worse than one that never
|
||||
// quoted it.
|
||||
// The destinations checkout refuses, and the sentences that say so — the
|
||||
// policy list (x/xm) and the sanctions list (s/sm), each with its own
|
||||
// wording. The preview has to refuse exactly where the server does — a
|
||||
// page that quotes a total for an order the server will reject is worse
|
||||
// than one that never quoted it.
|
||||
// The most units any destination's heaviest bracket can carry, clamped to
|
||||
// the parsing ceiling. With no table (the wasm fallback path) this stays at
|
||||
// kMaxQuantity — that page cannot quote a total or reach checkout anyway,
|
||||
|
|
@ -782,7 +783,12 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
if (i) cc += ',';
|
||||
cc += JsonStr(Money::NoSaleCountries()[i]);
|
||||
}
|
||||
cc += std::format(R"(],"xm":{}}})", JsonStr(Form::kNoSaleMessage));
|
||||
cc += std::format(R"(],"xm":{},"s":[)", JsonStr(Form::kNoSaleMessage));
|
||||
for (std::size_t i = 0; i < Money::SanctionedCountries().size(); ++i) {
|
||||
if (i) cc += ',';
|
||||
cc += JsonStr(Money::SanctionedCountries()[i]);
|
||||
}
|
||||
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
|
||||
|
|
@ -830,7 +836,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
// entered. A three-zone summary next to exact rates was misinformation.
|
||||
R"(<p class="checkout__shipnote">Shipping is priced per country at carrier )"
|
||||
R"(rates. Enter your country below and the exact total appears before )"
|
||||
R"(you order. {}</p>)"
|
||||
R"(you order. {} {}</p>)"
|
||||
R"({})"
|
||||
R"({})"
|
||||
R"(<form class="form" method="post"{}{} novalidate>)"
|
||||
|
|
@ -882,7 +888,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
R"(<input id="f-country" name="country" type="text" autocomplete="country" )"
|
||||
R"(maxlength="2" placeholder="NL" required{}>)"
|
||||
R"(<p class="field__hint">Two-letter code. Decides shipping, and whether the )"
|
||||
R"(price includes VAT. No US or CA — see the terms.</p>)"
|
||||
R"(price includes VAT. No US or CA, and no sanctioned countries — see the terms.</p>)"
|
||||
R"({})"
|
||||
R"(</div>)"
|
||||
R"({})"
|
||||
|
|
@ -906,6 +912,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
|
|||
offerCrypto ? SafeHtml{}
|
||||
: Raw(": iDEAL, card, or a plain bank transfer, handled by Mollie"),
|
||||
Escape(Form::kNoSaleMessage),
|
||||
Escape(Form::kSanctionsMessage),
|
||||
CustomsNote(),
|
||||
formError,
|
||||
Url("action", "/shop/" + product.slug + "#buy"),
|
||||
|
|
@ -973,11 +980,12 @@ export RenderedPage RenderProduct(const Product& product,
|
|||
|
||||
// Every destination this listing may advertise: the carrier has a rate
|
||||
// for a single boxed unit, and the shop is willing to sell there.
|
||||
// US and CA drop out by policy, not oversight (Money::SellsTo):
|
||||
// listing a shipping rate to a country checkout refuses would publish
|
||||
// an offer that cannot be accepted, and feed it to shopping crawlers
|
||||
// as an invitation to buy from there. Everywhere else drops out
|
||||
// because no carrier rate exists — which is now the same sentence.
|
||||
// US and CA drop out by policy and the sanctioned countries by law,
|
||||
// not oversight (Money::SellsTo denies both): listing a shipping rate
|
||||
// to a country checkout refuses would publish an offer that cannot be
|
||||
// accepted, and feed it to shopping crawlers as an invitation to buy
|
||||
// from there. Everywhere else drops out because no carrier rate
|
||||
// exists — which is now the same sentence.
|
||||
struct FeedDest { std::string cc; std::int64_t rate; Money::Zone zone; };
|
||||
std::vector<FeedDest> dests;
|
||||
for (const Money::ShipRates& r : liveShipping) {
|
||||
|
|
@ -2001,9 +2009,10 @@ inline constexpr std::string_view kGeoPriceHintScript =
|
|||
"var qty=qe?parseInt(qe.value,10)||1:1;"
|
||||
"var k=(ke&&ke.value?ke.value:\"\").replace(/\\s/g,\"\").toUpperCase();"
|
||||
"if(k.length!==2||!unit||qty<1||qty>d.q){if(out)out.hidden=true;return}"
|
||||
// Refused destination: say so where the total would have been, instead of
|
||||
// pricing an order the server will decline.
|
||||
// Refused destination — policy or sanctions: say so where the total would
|
||||
// have been, instead of pricing an order the server will decline.
|
||||
"if(d.x&&d.x.indexOf(k)>-1){if(out){out.textContent=d.xm;out.hidden=false}return}"
|
||||
"if(d.s&&d.s.indexOf(k)>-1){if(out){out.textContent=d.sm;out.hidden=false}return}"
|
||||
"var eu=ecc.indexOf(k)>-1,line=unit*qty;"
|
||||
"var goods=eu?line:Math.floor((line*10000+6050)/12100);"
|
||||
// No ladder for this destination: there is no price, and saying so beats
|
||||
|
|
|
|||
|
|
@ -74,6 +74,15 @@ int main() {
|
|||
"policy: the rest of the world still sells");
|
||||
Check(SellsTo("us"), "policy: matched on the normalised code, like membership");
|
||||
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify");
|
||||
// Sanctioned destinations refuse through the same gate, but the two lists
|
||||
// stay distinguishable — the checkout error names the law for one and the
|
||||
// shop's own choice for the other.
|
||||
Check(!SellsTo("RU") && !SellsTo("BY") && !SellsTo("KP"),
|
||||
"sanctions: RU, BY and KP refused");
|
||||
Check(IsSanctioned("RU") && IsSanctioned("BY") && IsSanctioned("KP"),
|
||||
"sanctions: the list knows its members");
|
||||
Check(!IsSanctioned("US") && !IsSanctioned("NL"),
|
||||
"sanctions: the insurance refusal is not a sanctions refusal");
|
||||
|
||||
// ── carrier weight brackets ───────────────────────────────────────
|
||||
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
|
||||
|
|
@ -150,6 +159,10 @@ int main() {
|
|||
Check(!CurrencyFor(cc).has_value(),
|
||||
"fx: refused destinations have no display currency", cc);
|
||||
}
|
||||
for (const std::string_view cc : SanctionedCountries()) {
|
||||
Check(!CurrencyFor(cc).has_value(),
|
||||
"fx: sanctioned destinations have no display currency", cc);
|
||||
}
|
||||
|
||||
// ── rates loader ──────────────────────────────────────────────────
|
||||
const Rates r = LoadRates(
|
||||
|
|
|
|||
|
|
@ -480,6 +480,15 @@ void AlwaysOnValidation(TestServer& srv) {
|
|||
"email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us");
|
||||
// Sanctioned destinations (Money::SanctionedCountries) refuse through the
|
||||
// same always-on gate — this refusal is the law, so of all the checks in
|
||||
// this file it is the one that must survive every refactor.
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
"email=by%40example.org&name=Vanya&street=1%20Kastrychnitskaya&postal=220030&city=Minsk&country=BY");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=ru");
|
||||
// Refused in validation means nothing reached the ledger and no payment
|
||||
// link was ever created.
|
||||
Check(LedgerLines(srv).size() == before, "a refused destination creates no order record");
|
||||
|
|
@ -514,13 +523,28 @@ void RejectedFormEcho(TestServer& srv) {
|
|||
Check(refused.body.find("value=\"Pat\"") != std::string::npos,
|
||||
"a refused submission keeps what was typed");
|
||||
}
|
||||
// A sanctioned destination gets the sanctions sentence, not the policy
|
||||
// one — the buyer should learn the law forbids the sale, not wonder what
|
||||
// insurance has to do with Moscow.
|
||||
{
|
||||
const auto refused = srv.Post("/shop/fp6-pmos",
|
||||
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
|
||||
Check(refused.body.find("EU sanctions prohibit") != std::string::npos,
|
||||
"sanctions refusal explains itself on the form");
|
||||
Check(refused.body.find("field__error\">Catcrafts does not sell") == std::string::npos,
|
||||
"sanctions refusal is not worded as the policy one");
|
||||
}
|
||||
// The buy panel warns before anyone fills it in, and the preview script
|
||||
// carries the same list so it cannot quote a total the server would
|
||||
// carries the same lists so it cannot quote a total the server would
|
||||
// refuse.
|
||||
srv.BodyHas("/shop/fp6-pmos", "does not sell or ship to the United States or Canada",
|
||||
"buy panel states where the shop does not sell");
|
||||
srv.BodyHas("/shop/fp6-pmos", "cannot sell or ship to Russia, Belarus or North Korea",
|
||||
"buy panel states where the law forbids selling");
|
||||
srv.BodyHas("/shop/fp6-pmos", ""x":["US","CA"]",
|
||||
"total preview knows the refused destinations");
|
||||
srv.BodyHas("/shop/fp6-pmos", ""s":["RU","BY","KP"]",
|
||||
"total preview knows the sanctioned destinations");
|
||||
// The honeypot message must not name the trap, or it teaches the next
|
||||
// bot. Only the ERROR NOTICE is inspected: the re-rendered form
|
||||
// legitimately contains the name="website" field itself — that IS the
|
||||
|
|
|
|||
|
|
@ -193,6 +193,23 @@ int main() {
|
|||
Check(us.value.country == "US", "checkout: refused country echoed back");
|
||||
}
|
||||
|
||||
// Sanctioned destinations: same gate, different sentence. The message has
|
||||
// to name the law rather than shop policy — a buyer told "Catcrafts does
|
||||
// not sell to Russia" would reasonably email to ask; one told the EU
|
||||
// forbids it knows nothing can be arranged.
|
||||
Check(!withCountry("RU").Ok(), "checkout: RU refused");
|
||||
Check(!withCountry("BY").Ok(), "checkout: BY refused");
|
||||
Check(!withCountry("KP").Ok(), "checkout: KP refused");
|
||||
Check(!withCountry("ru").Ok(), "checkout: lowercase RU refused too");
|
||||
{
|
||||
auto ru = withCountry("RU");
|
||||
Check(ru.errors.size() == 1 && ru.errors[0].field == "country",
|
||||
"checkout: sanctions refusal is a country error, nothing else");
|
||||
Check(ru.errors[0].message == Catcrafts::Form::kSanctionsMessage,
|
||||
"checkout: sanctions refusal names the law, not shop policy");
|
||||
Check(ru.value.country == "RU", "checkout: sanctioned country echoed back");
|
||||
}
|
||||
|
||||
// The shipping refusals. These are templates rather than plain strings
|
||||
// because the buy page fills the same ones client-side, so the substitution
|
||||
// has to work on both {cc} and {n} — a template that silently kept its
|
||||
|
|
@ -212,12 +229,16 @@ int main() {
|
|||
"shipping copy: with nothing fitting it does not promise a quantity");
|
||||
Check(FillShipMessage("{cc} {n} {cc}", "NL", 2) == "NL 2 NL",
|
||||
"shipping copy: every placeholder is filled, not just the first");
|
||||
// Both messages must offer the way out, since the shop is refusing
|
||||
// business it would otherwise take.
|
||||
Check(none.find("orders@catcrafts.net") != std::string::npos
|
||||
&& heavy.find("orders@catcrafts.net") != std::string::npos
|
||||
&& nofit.find("orders@catcrafts.net") != std::string::npos,
|
||||
"shipping copy: every refusal names a human to email");
|
||||
// Only the splittable refusal offers a way to order anyway — that
|
||||
// buyer's order works as several smaller ones. The other two are
|
||||
// final: what the carrier can't take, the shop doesn't ship, and a
|
||||
// refusal that invites hand-arranging would promise exactly the
|
||||
// ad-hoc export the shop decided against.
|
||||
Check(heavy.find("orders@catcrafts.net") != std::string::npos,
|
||||
"shipping copy: the splittable refusal names a human to email");
|
||||
Check(none.find("orders@catcrafts.net") == std::string::npos
|
||||
&& nofit.find("orders@catcrafts.net") == std::string::npos,
|
||||
"shipping copy: unshippable refusals are final, no workaround offered");
|
||||
}
|
||||
|
||||
// A rejected field must still come back, or the visitor has to retype the
|
||||
|
|
|
|||
Loading…
Reference in a new issue