sanctions block list
All checks were successful
Deploy / build-deploy (push) Successful in 2m18s

This commit is contained in:
Jorijn van der Graaf 2026-08-15 03:22:25 +02:00
commit b0666841f6
7 changed files with 139 additions and 31 deletions

View file

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

View file

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

View file

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

View file

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