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",
|
.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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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", ""x":["US","CA"]",
|
srv.BodyHas("/shop/fp6-pmos", ""x":["US","CA"]",
|
||||||
"total preview knows the refused destinations");
|
"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
|
// 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue