From b0666841f674e0a685649349d1085954410b406f Mon Sep 17 00:00:00 2001
From: Jorijn van der Graaf
Date: Sat, 15 Aug 2026 03:22:25 +0200
Subject: [PATCH] sanctions block list
---
.../interfaces/Catcrafts.Shared-Content.cppm | 3 +-
shared/interfaces/Catcrafts.Shared-Form.cppm | 31 ++++++++++++----
shared/interfaces/Catcrafts.Shared-Money.cppm | 27 +++++++++++++-
shared/interfaces/Catcrafts.Shared-Views.cppm | 37 ++++++++++++-------
tests/ShouldComputeMoney/main.cpp | 13 +++++++
tests/ShouldProcessCheckout/main.cpp | 26 ++++++++++++-
tests/ShouldValidateForms/main.cpp | 33 ++++++++++++++---
7 files changed, 139 insertions(+), 31 deletions(-)
diff --git a/shared/interfaces/Catcrafts.Shared-Content.cppm b/shared/interfaces/Catcrafts.Shared-Content.cppm
index 5ae60e2..c4ef26d 100644
--- a/shared/interfaces/Catcrafts.Shared-Content.cppm
+++ b/shared/interfaces/Catcrafts.Shared-Content.cppm
@@ -291,7 +291,7 @@ export const std::vector& 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& 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",
diff --git a/shared/interfaces/Catcrafts.Shared-Form.cppm b/shared/interfaces/Catcrafts.Shared-Form.cppm
index 9818e9c..f2863a4 100644
--- a/shared/interfaces/Catcrafts.Shared-Form.cppm
+++ b/shared/interfaces/Catcrafts.Shared-Form.cppm
@@ -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.
diff --git a/shared/interfaces/Catcrafts.Shared-Money.cppm b/shared/interfaces/Catcrafts.Shared-Money.cppm
index 2885e02..8aabffc 100644
--- a/shared/interfaces/Catcrafts.Shared-Money.cppm
+++ b/shared/interfaces/Catcrafts.Shared-Money.cppm
@@ -98,11 +98,34 @@ export std::span 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 SanctionedCountries() {
+ static constexpr std::array 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
diff --git a/shared/interfaces/Catcrafts.Shared-Views.cppm b/shared/interfaces/Catcrafts.Shared-Views.cppm
index 5ce00a0..b5563d2 100644
--- a/shared/interfaces/Catcrafts.Shared-Views.cppm
+++ b/shared/interfaces/Catcrafts.Shared-Views.cppm
@@ -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