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", .slug = "terms",
.title = "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.", .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 = { .sections = {
{ "Ordering and payment", { "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.", "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.", "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.", "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", { "Warranty",

View file

@ -234,22 +234,33 @@ export inline constexpr std::int64_t kMaxQuantity = 99;
export inline constexpr std::string_view kNoSaleMessage = export inline constexpr std::string_view kNoSaleMessage =
"Catcrafts does not sell or ship to the United States or Canada."; "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. // The two shipping refusals, worded once.
// //
// Since the shop stopped carrying its own rate table, the carrier's coverage // 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 // 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. // 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 // 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 // a no. The uncovered-country refusal is final: no carrier service means the
// guess whether to try again or give up. // 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 // 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 // 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 // hands them to the total preview verbatim to fill client-side. Same sentence
// before and after the submit, from one definition. // before and after the submit, from one definition.
export inline constexpr std::string_view kNoShippingTemplate = export inline constexpr std::string_view kNoShippingTemplate =
"No carrier rate for {cc} is available right now, so this order can't be " "No carrier rate for {cc} is available, so Catcrafts can't ship there.";
"priced. Email orders@catcrafts.net and it gets arranged by hand.";
export inline constexpr std::string_view kTooHeavyTemplate = export inline constexpr std::string_view kTooHeavyTemplate =
"That is more than fits one parcel to {cc} — up to {n} per order. For a " "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 // 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 // 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 = export inline constexpr std::string_view kTooHeavyNoneTemplate =
"A parcel this heavy can't be shipped to {cc} by any rate available. " "A parcel this heavy can't be shipped to {cc} by any rate available, "
"Email orders@catcrafts.net."; "so this order can't be placed.";
export std::string FillShipMessage(std::string_view tmpl, std::string_view cc, export std::string FillShipMessage(std::string_view tmpl, std::string_view cc,
std::int64_t n) { 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." }); r.errors.push_back({ "country", "Pick a country — it decides shipping and VAT treatment." });
} else if (!LooksLikeCountryCode(country)) { } else if (!LooksLikeCountryCode(country)) {
r.errors.push_back({ "country", "Country must be a two-letter code." }); 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)) { } else if (!Money::SellsTo(r.value.country)) {
// The refusal happens here, in validation, rather than at the payment // The refusal happens here, in validation, rather than at the payment
// step: no order record, no payment link, nothing charged to undo. // 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; 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 // 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 // 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) { 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 // 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::kNoShippingTemplate),
JsonStr(Form::kTooHeavyTemplate), JsonStr(Form::kTooHeavyTemplate),
JsonStr(Form::kTooHeavyNoneTemplate)); JsonStr(Form::kTooHeavyNoneTemplate));
// The destinations checkout refuses, and the sentence that says so. The // The destinations checkout refuses, and the sentences that say so — the
// preview has to refuse exactly where the server does — a page that quotes // policy list (x/xm) and the sanctions list (s/sm), each with its own
// a total for an order the server will reject is worse than one that never // wording. The preview has to refuse exactly where the server does — a
// quoted it. // 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 most units any destination's heaviest bracket can carry, clamped to
// the parsing ceiling. With no table (the wasm fallback path) this stays at // the parsing ceiling. With no table (the wasm fallback path) this stays at
// kMaxQuantity — that page cannot quote a total or reach checkout anyway, // kMaxQuantity — that page cannot quote a total or reach checkout anyway,
@ -782,7 +783,12 @@ SafeHtml RenderCheckoutForm(const Product& product,
if (i) cc += ','; if (i) cc += ',';
cc += JsonStr(Money::NoSaleCountries()[i]); 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 // The payment choice. A radio group rather than a <select> because both
// options carry a sentence the buyer should read BEFORE choosing — one // 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. // entered. A three-zone summary next to exact rates was misinformation.
R"(<p class="checkout__shipnote">Shipping is priced per country at carrier )" 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"(rates. Enter your country below and the exact total appears before )"
R"(you order. {}</p>)" R"(you order. {} {}</p>)"
R"({})" R"({})"
R"({})" R"({})"
R"(<form class="form" method="post"{}{} novalidate>)" 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"(<input id="f-country" name="country" type="text" autocomplete="country" )"
R"(maxlength="2" placeholder="NL" required{}>)" R"(maxlength="2" placeholder="NL" required{}>)"
R"(<p class="field__hint">Two-letter code. Decides shipping, and whether the )" 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"({})"
R"(</div>)" R"(</div>)"
R"({})" R"({})"
@ -906,6 +912,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
offerCrypto ? SafeHtml{} offerCrypto ? SafeHtml{}
: Raw(": iDEAL, card, or a plain bank transfer, handled by Mollie"), : Raw(": iDEAL, card, or a plain bank transfer, handled by Mollie"),
Escape(Form::kNoSaleMessage), Escape(Form::kNoSaleMessage),
Escape(Form::kSanctionsMessage),
CustomsNote(), CustomsNote(),
formError, formError,
Url("action", "/shop/" + product.slug + "#buy"), 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 // Every destination this listing may advertise: the carrier has a rate
// for a single boxed unit, and the shop is willing to sell there. // for a single boxed unit, and the shop is willing to sell there.
// US and CA drop out by policy, not oversight (Money::SellsTo): // US and CA drop out by policy and the sanctioned countries by law,
// listing a shipping rate to a country checkout refuses would publish // not oversight (Money::SellsTo denies both): listing a shipping rate
// an offer that cannot be accepted, and feed it to shopping crawlers // to a country checkout refuses would publish an offer that cannot be
// as an invitation to buy from there. Everywhere else drops out // accepted, and feed it to shopping crawlers as an invitation to buy
// because no carrier rate exists — which is now the same sentence. // 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; }; struct FeedDest { std::string cc; std::int64_t rate; Money::Zone zone; };
std::vector<FeedDest> dests; std::vector<FeedDest> dests;
for (const Money::ShipRates& r : liveShipping) { 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 qty=qe?parseInt(qe.value,10)||1:1;"
"var k=(ke&&ke.value?ke.value:\"\").replace(/\\s/g,\"\").toUpperCase();" "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}" "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 // Refused destination — policy or sanctions: say so where the total would
// pricing an order the server will decline. // 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.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 eu=ecc.indexOf(k)>-1,line=unit*qty;"
"var goods=eu?line:Math.floor((line*10000+6050)/12100);" "var goods=eu?line:Math.floor((line*10000+6050)/12100);"
// No ladder for this destination: there is no price, and saying so beats // No ladder for this destination: there is no price, and saying so beats

View file

@ -74,6 +74,15 @@ int main() {
"policy: the rest of the world still sells"); "policy: the rest of the world still sells");
Check(SellsTo("us"), "policy: matched on the normalised code, like membership"); Check(SellsTo("us"), "policy: matched on the normalised code, like membership");
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify"); 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 ─────────────────────────────────────── // ── carrier weight brackets ───────────────────────────────────────
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg / // The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
@ -150,6 +159,10 @@ int main() {
Check(!CurrencyFor(cc).has_value(), Check(!CurrencyFor(cc).has_value(),
"fx: refused destinations have no display currency", cc); "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 ────────────────────────────────────────────────── // ── rates loader ──────────────────────────────────────────────────
const Rates r = LoadRates( const Rates r = LoadRates(

View file

@ -480,6 +480,15 @@ void AlwaysOnValidation(TestServer& srv) {
"email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA"); "email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us"); "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 // Refused in validation means nothing reached the ledger and no payment
// link was ever created. // link was ever created.
Check(LedgerLines(srv).size() == before, "a refused destination creates no order record"); 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, Check(refused.body.find("value=\"Pat\"") != std::string::npos,
"a refused submission keeps what was typed"); "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 // 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. // refuse.
srv.BodyHas("/shop/fp6-pmos", "does not sell or ship to the United States or Canada", 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"); "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", "&quot;x&quot;:[&quot;US&quot;,&quot;CA&quot;]", srv.BodyHas("/shop/fp6-pmos", "&quot;x&quot;:[&quot;US&quot;,&quot;CA&quot;]",
"total preview knows the refused destinations"); "total preview knows the refused destinations");
srv.BodyHas("/shop/fp6-pmos", "&quot;s&quot;:[&quot;RU&quot;,&quot;BY&quot;,&quot;KP&quot;]",
"total preview knows the sanctioned destinations");
// The honeypot message must not name the trap, or it teaches the next // The honeypot message must not name the trap, or it teaches the next
// bot. Only the ERROR NOTICE is inspected: the re-rendered form // bot. Only the ERROR NOTICE is inspected: the re-rendered form
// legitimately contains the name="website" field itself — that IS the // legitimately contains the name="website" field itself — that IS the

View file

@ -193,6 +193,23 @@ int main() {
Check(us.value.country == "US", "checkout: refused country echoed back"); 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 // The shipping refusals. These are templates rather than plain strings
// because the buy page fills the same ones client-side, so the substitution // 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 // 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"); "shipping copy: with nothing fitting it does not promise a quantity");
Check(FillShipMessage("{cc} {n} {cc}", "NL", 2) == "NL 2 NL", Check(FillShipMessage("{cc} {n} {cc}", "NL", 2) == "NL 2 NL",
"shipping copy: every placeholder is filled, not just the first"); "shipping copy: every placeholder is filled, not just the first");
// Both messages must offer the way out, since the shop is refusing // Only the splittable refusal offers a way to order anyway — that
// business it would otherwise take. // buyer's order works as several smaller ones. The other two are
Check(none.find("orders@catcrafts.net") != std::string::npos // final: what the carrier can't take, the shop doesn't ship, and a
&& heavy.find("orders@catcrafts.net") != std::string::npos // refusal that invites hand-arranging would promise exactly the
&& nofit.find("orders@catcrafts.net") != std::string::npos, // ad-hoc export the shop decided against.
"shipping copy: every refusal names a human to email"); 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 // A rejected field must still come back, or the visitor has to retype the