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 )" R"(

Two-letter code. Decides shipping, and whether the )" - R"(price includes VAT. No US or CA — see the terms.

)" + R"(price includes VAT. No US or CA, and no sanctioned countries — see the terms.

)" R"({})" R"()" 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 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 diff --git a/tests/ShouldComputeMoney/main.cpp b/tests/ShouldComputeMoney/main.cpp index 0515a77..10117a5 100644 --- a/tests/ShouldComputeMoney/main.cpp +++ b/tests/ShouldComputeMoney/main.cpp @@ -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( diff --git a/tests/ShouldProcessCheckout/main.cpp b/tests/ShouldProcessCheckout/main.cpp index 7e06b2e..41bd2c9 100644 --- a/tests/ShouldProcessCheckout/main.cpp +++ b/tests/ShouldProcessCheckout/main.cpp @@ -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 diff --git a/tests/ShouldValidateForms/main.cpp b/tests/ShouldValidateForms/main.cpp index 0bf0e60..7036ad7 100644 --- a/tests/ShouldValidateForms/main.cpp +++ b/tests/ShouldValidateForms/main.cpp @@ -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