markdown fix
All checks were successful
Deploy / build-deploy (push) Successful in 2m23s

This commit is contained in:
Jorijn van der Graaf 2026-08-18 11:12:24 +02:00
commit 4666c1995f
14 changed files with 876 additions and 346 deletions

File diff suppressed because one or more lines are too long

View file

@ -350,10 +350,14 @@ export const std::vector<LegalPage>& LegalPages() {
} }, } },
{ "Where Catcrafts sells", { "Where Catcrafts sells",
{ {
"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 ships to a short list of countries, and the list is the whole policy: the Netherlands, Switzerland, Serbia, Montenegro, Albania, Kosovo, Georgia, Australia, Hong Kong and Singapore. Checkout accepts a delivery address in those and refuses every other country. The current list is always the one checkout enforces, so if a country is not offered at checkout it is not offered at all. Outside the Netherlands you are the importer: the parcel is sent duty unpaid, and your own customs authority charges you import VAT and any duty when it arrives. That is not a way of pushing costs onto you — in two of these countries it is the specific legal reason the sale is permitted at all.",
"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 as the point is that the sale does not happen, not that the address looks European.", "The reason it is a list of what is allowed rather than a list of what is not, is that the rules are national and there are nearly two hundred of them. Selling a phone into a country can enrol Catcrafts in that country's recycling and battery schemes, require its own radio approval, or require the handset to be registered in a national database before any local network will carry a call. Those obligations attach before the first parcel, not after it, and a shop this size cannot pretend to have read every country's version. So a country is added when it has actually been checked, and until then the answer is no. That is a deliberately cautious default and it will sometimes say no to a country that would have been fine.",
"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.", "If your country is not on the list, please write to orders@catcrafts.net. There is no waiting list and no promised date, but which country gets looked at next is decided by who asks, so asking genuinely works.",
"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.", "Catcrafts does not ship to the United States or Canada, and these are worth naming because they are the two people ask about most. Canada is not a choice: the phone holds no Canadian radio certification, and Canadian law prohibits importing, distributing or selling uncertified radio equipment, so it cannot lawfully be sold there by anyone at present. The United States is a combination — a customs entry is now required for every individual parcel, the mobile networks certify which handsets may be activated, and none of the legal protection that shields telephone companies over emergency calls extends to whoever made the device. Insurance is part of the picture too, but it is no longer the interesting part, and buying a policy would not open either country.",
"Separately, Catcrafts cannot sell or ship to Russia, Belarus or North Korea. Unlike everything above this is not a judgement call: 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.",
"The same anti-routing rule applies to every refused country, not only the sanctioned ones: if a parcel is to be forwarded onward, or the delivery address belongs to a forwarding service acting for a customer elsewhere, the order is refused, and refunded in full if that only becomes clear after payment. Please do not try to route around it. The point is that the sale does not happen, not that the address looks acceptable.",
"One more thing that can refuse an address even inside the list: shipping is priced entirely from the carrier's own rates, so a destination the carrier has no rate for cannot be quoted and cannot be ordered. That is a different and much softer no than the ones above, it is about a price not existing rather than a sale being refused, and it can change whenever the carrier's tariff does.",
"The software itself remains free for anyone anywhere, and none of this touches it: 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

@ -228,23 +228,55 @@ export inline constexpr std::string_view kPayCrypto = "crypto";
// "quantity=99999999999" before any of that arithmetic runs. // "quantity=99999999999" before any of that arithmetic runs.
export inline constexpr std::int64_t kMaxQuantity = 99; export inline constexpr std::int64_t kMaxQuantity = 99;
// What a buyer in a NoSaleCountries destination is told. Stated as a standing // What a buyer outside Money::ShippableCountries is told.
// fact about where the shop sells, not as an apology or an outage: someone
// there should close the tab rather than retry tomorrow or hunt for a
// workaround. The reason itself (insurance territory) is on the terms page —
// a form field is the wrong place for it.
// //
// One definition, three renderings: this error, the note above the form, and // One sentence for every refusal that is not sanctions, because from the buyer's
// the on-page total preview, so the page can never encourage an order the // side they are all one fact: their country has not been cleared. Merging them is
// server will refuse. // also the honest shape — the old split between "we are not registered there",
export inline constexpr std::string_view kNoSaleMessage = // "the handset would not work there" and "our insurer excludes it" described
"Catcrafts does not sell or ship to the United States or Canada."; // Catcrafts' internal reasons, not anything the buyer can act on.
//
// Names the reason as regulatory rather than commercial, because a Dutch shop
// that plainly posts worldwide would otherwise read as arbitrary, or worse as
// quietly declining someone. The truth is duller and better: selling a phone into
// a country means meeting that country's rules first, and doing it in the wrong
// order is the illegal part.
//
// No date is promised. Which country comes next depends on which one people ask
// for, and an invented timeline is worse than none — so the address is the point
// of the last clause.
//
// One definition, three renderings: this error, the note above the form, and the
// on-page total preview, so the page can never encourage an order the server
// will refuse.
export inline constexpr std::string_view kRegulatoryMessage =
"Catcrafts can't ship there. Selling a phone into a country means meeting "
"that country's own rules first — recycling schemes, radio approval, import "
"registration — and only a few are cleared so far. Email "
"orders@catcrafts.net if you would like yours looked at next.";
// The sanctions refusal, in different words on purpose: kNoSaleMessage states // The standing note above the buy form, and the one place the policy is stated
// a choice, this states a prohibition. Naming the reason here rather than only // POSITIVELY. With an allow-list that is the only useful phrasing: "we ship to
// on the terms page — unlike the insurance one — because "sanctions" is the // these five" tells a visitor in one glance what "we cannot ship to two hundred
// whole answer: nothing about the shop could change it, and a buyer told only // others" never would, and it stops someone filling in a whole address before
// "no" would rightly ask why. // the field error tells them no.
//
// Prose rather than generated from Money::ShippableCountries because that array
// holds ISO codes and a buyer should not have to decode "NZ". The duplication is
// real, so ShouldComputeMoney asserts the array's LENGTH — add a country and that
// test fails, pointing here. Update both or neither.
export inline constexpr std::string_view kShipsToMessage =
"Catcrafts currently ships to the Netherlands, Switzerland, Serbia, "
"Montenegro, Albania, Kosovo, Georgia, Australia, Hong Kong and "
"Singapore. Other "
"countries are being worked through one at a time; email "
"orders@catcrafts.net if you would like yours looked at next.";
// The sanctions refusal, in different words on purpose: kRegulatoryMessage
// describes paperwork that could be done, this states a prohibition that cannot.
// Naming the reason here rather than only on the terms page, 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 = export inline constexpr std::string_view kSanctionsMessage =
"Catcrafts cannot sell or ship to Russia, Belarus or North Korea: " "Catcrafts cannot sell or ship to Russia, Belarus or North Korea: "
"EU sanctions prohibit exporting consumer electronics there."; "EU sanctions prohibit exporting consumer electronics there.";
@ -365,14 +397,15 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
} 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)) { } else if (Money::IsSanctioned(r.value.country)) {
// Checked before the general refusal because SellsTo denies both and // Sanctions are checked first because both gates deny and only the words
// the words differ: this one says the law forbids the sale, not that // differ: this one says the law forbids the sale, not that the shop has
// the shop chose not to make it. // not got round to that country yet.
r.errors.push_back({ "country", std::string(kSanctionsMessage) }); r.errors.push_back({ "country", std::string(kSanctionsMessage) });
} else if (!Money::SellsTo(r.value.country)) { } else if (!Money::ShipsTo(r.value.country)) {
// The refusal happens here, in validation, rather than at the payment // Everything not on the shipping list. The refusal happens here, in
// step: no order record, no payment link, nothing charged to undo. // validation, rather than at the payment step: no order record, no
r.errors.push_back({ "country", std::string(kNoSaleMessage) }); // payment link, nothing charged to undo.
r.errors.push_back({ "country", std::string(kRegulatoryMessage) });
} }
// Colour: shape only (slug-ish, bounded). Whether it names a variant that // Colour: shape only (slug-ish, bounded). Whether it names a variant that

View file

@ -18,9 +18,9 @@ No permission is granted to copy, modify, distribute, or create derivative works
// WHAT IT SUPPORTS — everything observed in the real bodies, and nothing else: // WHAT IT SUPPORTS — everything observed in the real bodies, and nothing else:
// //
// blocks ATX headings, fenced code, blockquotes (nested), ordered and // blocks ATX headings, fenced code, blockquotes (nested), ordered and
// unordered lists, thematic breaks, paragraphs // unordered lists, pipe tables, thematic breaks, paragraphs
// inline links, images, code spans, ** strong **, * emphasis *, and bare // inline links, images, code spans, ** strong **, * emphasis *,
// URLs via Html::Autolink // ~~ strikethrough ~~, and bare URLs via Html::Autolink
// //
// WHAT IT DELIBERATELY DOES NOT SUPPORT: // WHAT IT DELIBERATELY DOES NOT SUPPORT:
// //
@ -34,8 +34,15 @@ No permission is granted to copy, modify, distribute, or create derivative works
// these posts unquoted (kworker/u16:8-qc_ufs_qos_swq), and mangling half a // these posts unquoted (kworker/u16:8-qc_ufs_qos_swq), and mangling half a
// symbol name into italics is a worse failure than not italicising a word // symbol name into italics is a worse failure than not italicising a word
// that used the underscore form. Asterisks are unambiguous here. // that used the underscore form. Asterisks are unambiguous here.
// * Setext headings, reference links, tables, footnotes, HTML entities. // * Single-tilde strikethrough, for the same reason. GFM accepts one tilde or
// None appear; adding them speculatively is parser surface with no reader. // two; a lone `~` in these posts is a home directory (`~/.local`) or an
// approximation (`~5 minutes`), so only the doubled form strikes anything.
// * Tilde-fenced code. `~~~` would be ambiguous with the above and no body
// uses it; a fence here is written with backticks.
// * Setext headings, reference links, footnotes, HTML entities. None appear;
// adding them speculatively is parser surface with no reader. (Tables did
// appear — the carrier compatibility list and the GPU price comparison are
// written as pipe tables — which is why they are in the list above now.)
// * Trailing-double-space hard breaks. An invisible two-character difference // * Trailing-double-space hard breaks. An invisible two-character difference
// is not something a reader can see in the source or a writer can rely on // is not something a reader can see in the source or a writer can rely on
// having typed; the lines of a paragraph join with a space, and a break // having typed; the lines of a paragraph join with a space, and a break
@ -264,6 +271,30 @@ SafeHtml RenderInline(std::string_view text, std::span<const PostMedia> media, i
} }
} }
// ~~struck~~, structurally the same as **strong** and sharing its
// open/close tests, so "a ~~ b ~~ c" stays literal the same way
// "a ** b ** c" does.
//
// <s> rather than GFM's <del>: nothing was removed from this document.
// <del> is a claim that an edit happened, and the one body that uses
// this is striking a joke through for effect — which is exactly what
// <s> is for ("no longer accurate or no longer relevant").
if (c == '~' && i + 1 < text.size() && text[i + 1] == '~') {
const std::size_t inner = i + 2;
if (depth < kMaxDepth && OpensEmphasis(text, inner)) {
const std::size_t close = FindEmphasisClose(text, inner, "~~");
if (close != std::string_view::npos) {
flush(i);
out.push_back(Format(
R"(<s>{}</s>)",
RenderInline(text.substr(inner, close - inner), media, depth + 1)));
i = close + 2;
run = i;
continue;
}
}
}
++i; ++i;
} }
@ -338,9 +369,123 @@ ListMarker ParseListMarker(std::string_view line) {
return m; return m;
} }
bool StartsBlock(std::string_view line) { // ── tables ────────────────────────────────────────────────────────────
//
// GFM pipe tables. These arrived after the parser did: the carrier
// compatibility list in the camera post and the GPU price comparison in
// another are written as tables, and with no table support a table is the
// worst-degrading construct there is — the rows join into one run-on
// paragraph of pipes and dashes, which is neither the data nor prose.
//
// Recognition deliberately needs TWO lines: a row, and under it an alignment
// row agreeing about the number of columns. That is what keeps ordinary prose
// safe, because a pipe by itself is common in these posts (`dmesg | grep`, an
// or-list, a pasted command) and nothing under those lines is `|---|---|`.
// A column's alignment, as the delimiter row asked for it. Left is absent
// because it is what the CSS already does — `:---` and a bare `---` produce
// identical markup, so only the two that change something carry a class.
enum class Align { Default, Center, Right };
// A row split into cells on unescaped pipes, with the optional outer pipes
// dropped. Both are optional independently, which is not pedantry: the real
// carrier table is written `| Device | OS | Carrier` — leading pipe, no
// trailing one — and requiring both would leave it as prose.
//
// `\|` is left in the cell text for RenderInline to turn into a literal pipe;
// that is how a cell contains one.
std::vector<std::string_view> SplitRow(std::string_view line) {
std::string_view s = Trim(Undent(line));
if (s.starts_with('|')) s.remove_prefix(1);
if (s.size() > 1 && s.ends_with('|') && !s.ends_with("\\|")) s.remove_suffix(1);
std::vector<std::string_view> cells;
std::size_t start = 0;
for (std::size_t i = 0; i < s.size(); ++i) {
if (s[i] == '\\') { ++i; continue; }
if (s[i] != '|') continue;
cells.push_back(Trim(s.substr(start, i - start)));
start = i + 1;
}
cells.push_back(Trim(s.substr(start)));
return cells;
}
// One cell of the delimiter row: `---`, `:---`, `---:`, `:---:` and nothing
// else. A single dash is enough — `| - |` is a table people write.
bool IsDelimiterCell(std::string_view cell, Align& align) {
const bool left = cell.starts_with(':');
if (left) cell.remove_prefix(1);
const bool right = cell.ends_with(':');
if (right) cell.remove_suffix(1);
if (cell.empty()) return false;
for (const char c : cell) {
if (c != '-') return false;
}
align = (left && right) ? Align::Center : right ? Align::Right : Align::Default;
return true;
}
// The alignment of each column, or an empty vector when `line` is not the
// delimiter row of a table whose header had `columns` cells.
std::vector<Align> ParseDelimiterRow(std::string_view line, std::size_t columns) {
// A pipe is required, so `---` under a one-cell row stays the thematic
// break it looks like — the same call this file already makes for `- - -`
// over a one-item list.
if (Trim(line).find('|') == std::string_view::npos) return {};
std::vector<Align> aligns;
for (const std::string_view cell : SplitRow(line)) {
Align align = Align::Default;
if (!IsDelimiterCell(cell, align)) return {};
aligns.push_back(align);
}
// GFM's rule, and a useful one: a mismatched count is far more likely to be
// prose that happens to contain pipes than a table its author miscounted.
return aligns.size() == columns ? aligns : std::vector<Align>{};
}
bool IsTableStart(std::span<const std::string_view> lines, std::size_t at) {
if (at + 1 >= lines.size()) return false;
if (Trim(Undent(lines[at])).find('|') == std::string_view::npos) return false;
return !ParseDelimiterRow(lines[at + 1], SplitRow(lines[at]).size()).empty();
}
// A row's cells, as <th> or <td>. Rendered per cell rather than per line, so a
// cell holds links, code spans and emphasis like any other prose.
//
// A row shorter than the header is padded with empty cells so the grid stays
// rectangular; a row LONGER than it keeps its extras rather than having them
// dropped, which follows the rule the rest of this file follows — content that
// confuses the parser shows up looking odd instead of disappearing.
SafeHtml RenderRow(std::span<const std::string_view> cells, std::span<const Align> aligns,
bool header, std::span<const PostMedia> media, int depth) {
std::vector<SafeHtml> out;
const std::size_t columns = std::max(cells.size(), aligns.size());
for (std::size_t c = 0; c < columns; ++c) {
const Align align = c < aligns.size() ? aligns[c] : Align::Default;
const SafeHtml cls =
align == Align::Center ? Attr("class", "post-body__cell--center")
: align == Align::Right ? Attr("class", "post-body__cell--right")
: SafeHtml{};
const SafeHtml inner =
RenderInline(c < cells.size() ? cells[c] : std::string_view{}, media, depth);
out.push_back(header ? Format("<th{}>{}</th>", cls, inner)
: Format("<td{}>{}</td>", cls, inner));
}
return Format("<tr>{}</tr>", Join(out));
}
// ── block dispatch ────────────────────────────────────────────────────
// Whether a new block begins at `at`, so a paragraph is interrupted by one
// rather than swallowing it as text. Takes the whole span because a table is
// the one construct that cannot be recognised from a single line.
bool StartsBlock(std::span<const std::string_view> lines, std::size_t at) {
const std::string_view line = lines[at];
return HeadingLevel(Undent(line)) > 0 || IsFence(line) || IsThematicBreak(line) return HeadingLevel(Undent(line)) > 0 || IsFence(line) || IsThematicBreak(line)
|| Undent(line).starts_with('>') || ParseListMarker(line).ok; || Undent(line).starts_with('>') || ParseListMarker(line).ok
|| IsTableStart(lines, at);
} }
SafeHtml RenderBlocks(std::span<const std::string_view> lines, SafeHtml RenderBlocks(std::span<const std::string_view> lines,
@ -524,7 +669,7 @@ SafeHtml RenderBlocks(std::span<const std::string_view> lines,
// A non-marker line that would start some other block ends the // A non-marker line that would start some other block ends the
// list; anything else is this item's text continuing onto the // list; anything else is this item's text continuing onto the
// next line. // next line.
if (StartsBlock(lines[j])) break; if (StartsBlock(lines, j)) break;
current.push_back(lines[j]); current.push_back(lines[j]);
++j; ++j;
} }
@ -545,13 +690,48 @@ SafeHtml RenderBlocks(std::span<const std::string_view> lines,
continue; continue;
} }
// ── table ─────────────────────────────────────────────────────
//
// Below the list branch on purpose: `- a | b` over a `|---|---|`
// satisfies both tests, and a line that opens with a list marker is a
// list item. Ordinary tables are unaffected — a pipe is never a list
// marker, so they reach here either way.
if (IsTableStart(lines, i)) {
const std::vector<std::string_view> header = SplitRow(line);
const std::vector<Align> aligns = ParseDelimiterRow(lines[i + 1], header.size());
std::vector<SafeHtml> rows;
std::size_t j = i + 2;
while (j < lines.size() && !Blank(lines[j])) {
// A row has to have a pipe in it. A line without one directly
// under a table is prose whose author forgot the blank line —
// rendering it as a lone one-column row would be worse than
// ending the table and letting it be the paragraph it is.
if (Trim(Undent(lines[j])).find('|') == std::string_view::npos) break;
if (StartsBlock(lines, j)) break;
rows.push_back(RenderRow(SplitRow(lines[j]), aligns, false, media, depth));
++j;
}
// The wrapper is what scrolls. A table cannot be narrowed below its
// content, so without a container around it a nine-column price
// comparison scrolls the PAGE sideways on a phone — the one thing
// wide content must never do (same reasoning as the code block).
out.push_back(Format(
R"(<div class="post-body__table"><table>)"
R"(<thead>{}</thead><tbody>{}</tbody></table></div>)",
RenderRow(header, aligns, true, media, depth), Join(rows)));
i = j;
continue;
}
// ── paragraph ───────────────────────────────────────────────── // ── paragraph ─────────────────────────────────────────────────
{ {
std::size_t j = i; std::size_t j = i;
while (j < lines.size() && !Blank(lines[j])) { while (j < lines.size() && !Blank(lines[j])) {
// A block marker on a later line interrupts the paragraph // A block marker on a later line interrupts the paragraph
// rather than being absorbed into it as text. // rather than being absorbed into it as text.
if (j > i && StartsBlock(lines[j])) break; if (j > i && StartsBlock(lines, j)) break;
++j; ++j;
} }
out.push_back(RenderParagraph(lines.subspan(i, j - i), media, depth)); out.push_back(RenderParagraph(lines.subspan(i, j - i), media, depth));

View file

@ -82,33 +82,19 @@ export bool IsEuCountry(std::string_view cc) {
return std::ranges::find(EuCountries(), cc) != EuCountries().end(); return std::ranges::find(EuCountries(), cc) != EuCountries().end();
} }
// Destinations this shop refuses outright. // Destinations the law forbids.
//
// Not a carrier problem — parcels reach both fine, and the rate tables price
// them. It is an insurance boundary: liability cover for a Dutch shop is
// written "worldwide excluding USA/Canada", and a phone carrying a lithium
// battery and a replaced OS is precisely the product-liability exposure that
// exclusion exists for. A sale into either country would therefore be
// uninsured, with the buyer's own courts deciding the damages, so checkout
// declines it instead of pricing it. Declining also keeps Catcrafts from
// having marketed into those jurisdictions at all, which is the other half of
// why the answer is no rather than a surcharge.
export std::span<const std::string_view> NoSaleCountries() {
static constexpr std::array<std::string_view, 2> blocked{ "US", "CA" };
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 // EU sanctions — Regulation 833/2014 for Russia, its Belarus mirror, and the
// North Korea embargo — prohibit exporting consumer electronics to these // North Korea embargo — prohibit exporting consumer electronics to these
// countries, by customs code and by the luxury-goods value threshold both, and // 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 // 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 // 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 // small-shop exemption and no surcharge version of compliance.
// rather than more entries in NoSaleCountries because the refusal needs //
// different words: "does not" is a choice, "cannot" is the law, and each gets // Kept apart from the shipping allow-list below even though both refuse,
// its own explanation on the terms page. // because the refusal needs different words: not shipping somewhere yet is a
// state of the paperwork, while this is a prohibition nothing about the shop
// could change. Each gets its own sentence at checkout and on the terms page.
export std::span<const std::string_view> SanctionedCountries() { export std::span<const std::string_view> SanctionedCountries() {
static constexpr std::array<std::string_view, 3> blocked{ "RU", "BY", "KP" }; static constexpr std::array<std::string_view, 3> blocked{ "RU", "BY", "KP" };
return blocked; return blocked;
@ -118,14 +104,189 @@ export bool IsSanctioned(std::string_view cc) {
return std::ranges::find(SanctionedCountries(), cc) != SanctionedCountries().end(); return std::ranges::find(SanctionedCountries(), cc) != SanctionedCountries().end();
} }
// Where this shop ships. An allow-list, and that inversion IS the design.
//
// Everything not named here is refused. That is not timidity, it is the only
// honest posture available: the rules deciding whether a phone may lawfully be
// sold into a country are national, they differ in kind rather than degree, and
// nobody has read all of them. A deny-list ships to every country nobody thought
// about; an allow-list refuses them. Only one of those failure modes is
// survivable, so the default is no, and every code below is a country somebody
// actually checked.
//
// What "checked" has to mean before a code goes in:
//
// * the destination does not reach the FOREIGN SELLER with producer duties of
// its own — packaging, e-waste, batteries. The EU does. The EEA (Iceland,
// Liechtenstein, Norway) does too, through the same directives, which is why
// no EEA country is here. The UK does by national law.
// * a phone posted from here is actually admitted: no national IMEI database
// to be registered against before a local network will attach it, and no
// customs practice of seizing handsets that lack local type approval.
// * the parcel crosses as the BUYER's import, so their own authority charges
// them VAT and duty and their own type-approval rules bind them rather than
// Catcrafts. This is the one thing the price buys: every foreign
// low-value-consignment regime found sits far below €600, so nothing routes
// through a seller-side registration scheme.
// * liability cover reaches it. The AVB is written worldwide EXCLUDING the
// United States and Canada — confirm that territory in the policy schedule
// before trusting this list, because every entry assumes it.
//
// Removed after verification, and NOT to be re-added on a hunch — both were on
// this list once, on the mistaken assumption that a domestic-supply approval
// regime never reaches a personal import:
//
// JP — using a handset without Japan's giteki (技適) mark on a Japanese network
// is a Radio Act offence carrying up to a year's detention or ¥1,000,000.
// The only exemption is for a short-stay visitor carrying a device in; it
// does not reach a resident receiving a parcel. Fairphone holds no Japanese
// certification and says outright that its devices cannot be used in
// countries requiring local homologation. Note where the liability lands:
// the offence is USE, so the person committing it is the customer. Selling
// someone a phone they break the law by switching on is worse than any
// paperwork gap on this page.
// NZ — the radio regulator defines a supplier to include "a seller", says the
// rules apply identically whether stock ships from inside New Zealand or
// reaches the market through a website, and requires a Licence to Supply
// for radio transmitters. The cure is then bolted shut: an overseas company
// cannot register on the compliance database, and unlike Australia there is
// no agent workaround. Structurally the same trap as Norway and the UK,
// living in radio law rather than waste law.
//
// Not here, and deliberately: the United States and Canada. The insurance
// exclusion was only ever half the reason and is no longer the interesting half.
// Canada is legally shut — the Fairphone (Gen. 6) holds no ISED certification,
// and Canadian law bars importing, distributing or selling uncertified radio
// apparatus, so no structure or policy opens it. The US adds per-parcel customs
// entry since de minimis ended, carrier certification that gates activation, and
// no emergency-call immunity of any kind for a device maker. Both are refused for
// regulatory reasons now, not commercial ones, and neither becomes available by
// buying a policy.
// Every entry must also ship DDU/DAP — the buyer as importer of record, paying
// their own authority at the border. That is not a commercial preference, it is
// load-bearing law in three of the five: it is what makes Hong Kong's
// personal-use import exemption apply, what keeps Catcrafts outside Singapore's
// producer definition, and what puts import tax on the consumer everywhere else.
// Appear as importer of record and two of these countries close.
export std::span<const std::string_view> ShippableCountries() {
static constexpr std::array<std::string_view, 10> open{
// Home. Verpact wants nothing under 50,000 kg of packaging, and the
// phones are already on the Dutch market when Catcrafts buys them, so no
// producer duty attaches. That second clause is load-bearing: source
// stock from another member state and Catcrafts becomes the Dutch
// importer, owing Stichting OPEN before this line is honest again.
"NL",
// Checked end to end against the federal texts. CE accepted under the
// bilateral MRA (its chapter 7 is radio equipment); the e-waste ordinance
// reaches only those importing for COMMERCIAL supply, with no
// distance-seller limb and no producer register; and the mail-order VAT
// duty is scoped to consignments cheap enough to be import-tax-exempt, so
// a phone goes through ordinary import with the buyer paying at the
// border. The packaging ordinance expected 1 Jan 2027 turns out not to
// matter — it was adopted 24 June 2026 and its fee covers GLASS only.
//
// The real watch item is elsewhere, and it is specific: the circular-
// economy revision of the environment act, in force since 1 Jan 2025,
// already empowers a disposal fee on "ausländische Online-
// Versandhandelsunternehmen" — defined as whoever offers products
// digitally and delivers to consumers in Switzerland without a Swiss
// seat or establishment. That is this shop, definitionally. It is dormant
// only because the power is discretionary and no ordinance names a phone,
// and the companion article is deliberately not yet in force. An ordinance
// extending the fee to electrical devices is the day Switzerland flips.
"CH",
// Read out of the Radiocommunications Equipment (General) Rules 2021
// rather than off a guidance page, because ACMA's own site is misleading
// here. The labelling and registration duties in s25 bind an importer who
// then SUPPLIES — neither limb is met when the consumer imports one phone
// for themselves. What does reach Catcrafts is s12: a person must not
// supply a device failing a prescribed general standard, extended to trade
// between Australia and places outside it. That is a SUBSTANTIVE standards
// duty, not a registration one — so no Australian establishment is needed,
// unlike New Zealand. Phones are outside the e-waste scheme (which covers
// TVs, printers and computers, and binds Australian corporations anyway).
// Two watch items: a mandatory small-electricals stewardship scheme is
// committed but not law, and the A$1,000 low-value-import line sits close
// enough to €600 that FX moves can cross it.
"AU",
// The best-documented jurisdiction of the set. Mobile phones sit outside
// the e-waste producer-responsibility list; user equipment needs no type
// approval (voluntary certification only); the import ordinance exempts
// equipment brought in for reasonable personal use; and there is no VAT or
// GST at all, so no foreign-seller registration can arise.
"HK",
// Both feared hooks miss on the facts. The e-waste producer duty requires
// importing INTO Singapore in furtherance of a Singapore supply business,
// which a DDU parcel is not, and there is no distance-selling limb of the
// kind UK law uses. IMDA states personal-use imports need no registration
// and sets no quantity limit. On tax the price helps: the low-value-goods
// regime caps at S$400, so a €600 phone is above it and the overseas-vendor
// rules cannot reach it at any turnover.
"SG",
// The Western Balkans four. All share the shape that matters: producer
// responsibility attaches to whoever places goods on the DOMESTIC market
// — the in-country importer, so the buyer — with none of the
// "regardless of sales channel" drafting that catches a distance seller in
// the EU, Norway, Iceland, Moldova and Bosnia. CE is accepted, no IMEI
// whitelist exists, and non-resident VAT reaches services only.
//
// RS — the strongest of them, because the answer comes from the customs
// authority itself: conformity documents are demanded only for
// certain drones, no radio-equipment conformity paper is required at
// import, and per the telecoms ministry's published position the
// Radio Equipment Rulebook does not apply to natural persons at all.
// ME — best-evidenced on tax: the VAT act states in terms that where
// transport begins outside Montenegro the IMPORTER makes the supply,
// and a tax representative is needed only for supplies made inside
// the country. EPR bylaws are still pending.
// AL — its WEEE decree still uses the pre-2012 three-limb producer
// definition, so the distance-selling limb simply is not there, and
// registration would need an Albanian tax number nobody can give a
// foreigner. Smartphones are also duty-free in the 2026 tariff.
// HARD DATE: Law 74/2025 takes effect 1 December 2026. Re-read its
// producer definition and any implementing acts before then, because
// that is when this entry could stop being true.
// XK — cleared on the law, with one operational caveat that is not legal:
// Kosovo is not a UPU member, so there is no treaty-based tracking
// guarantee or loss indemnity. On a €600 parcel that matters — ship
// it by courier rather than post. Note also that XK is a
// user-assigned code rather than official ISO 3166-1; if the carrier
// table spells Kosovo differently this entry simply never matches and
// the destination falls through to the no-carrier-rate refusal, which
// is the safe direction to fail.
"RS", "ME", "AL", "XK",
// Held back for a while on the dual-use crypto question rather than
// anything Georgian, and that question turned out to be a paperwork task
// rather than a gate — the mass-market exemption releases this phone for
// the same reason it releases every unlockable Pixel. On its own law
// Georgia is among the cleanest here: producer duties attach to whoever
// IMPORTS, with no distance-selling limb and no authorised-representative
// concept at all; the electronic communications act contains not one
// mention of IMEI; the product-safety code admits goods built to the
// standards of any EU or OECD country; and phones carry no duty, with the
// buyer paying import VAT at the border. Same caveat as Kosovo: the
// national post is slow with unreliable tracking, so send it by courier.
"GE",
};
return open;
}
export bool ShipsTo(std::string_view cc) {
return std::ranges::find(ShippableCountries(), cc) != ShippableCountries().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 testing the lists themselves, so the policy has exactly one
// definition and adding a country later is a one-line change. Both lists deny: // definition and opening a country is a one-line change.
// most callers only need "is this destination for sale", and only the checkout //
// error message cares which refusal it is (IsSanctioned above). // Two HARD gates, in order of permanence: the law, then the shipping list. A
// destination the carrier happens not to price is a third and much softer
// refusal that lives with the rate table (Form::kNoShippingTemplate) — it says
// no price exists, not that the sale is refused, and a carrier contract can
// change it tomorrow. Most callers only need "is this destination for sale";
// only the checkout error cares which refusal it is.
export bool SellsTo(std::string_view cc) { export bool SellsTo(std::string_view cc) {
return !IsSanctioned(cc) && return !IsSanctioned(cc) && ShipsTo(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
@ -271,10 +432,17 @@ export struct CurrencyRow {
// Only currencies the ECB publishes reference rates for; anywhere else shows // Only currencies the ECB publishes reference rates for; anywhere else shows
// plain euros. Euro countries are deliberately absent — converting EUR to EUR // plain euros. Euro countries are deliberately absent — converting EUR to EUR
// is noise. So are USD and CAD: NoSaleCountries means no order can ever be // is noise. So are USD and CAD, and that one is a policy choice rather than a
// charged from those countries, and quoting a visitor a friendly price in // gap in the ECB feed: those two are refused for regulatory reasons that no
// their own currency before refusing them at checkout is both a worse // amount of demand will lift, so quoting a visitor a friendly price in their own
// experience and the kind of localisation that reads as marketing there. // currency before declining them is both a worse experience and the kind of
// localisation that reads as marketing into a market this shop cannot serve.
//
// The rest of this table deliberately runs AHEAD of ShippableCountries. It
// answers "what would this cost in my money", which stays a fair question for a
// country whose paperwork is merely pending — GB is one small registration from
// opening — and keeping the row spares a delete-and-restore cycle later. The
// refusal that must never be quoted around is enforced in SellsTo, not here.
export std::span<const CurrencyRow> AllCurrencies() { export std::span<const CurrencyRow> AllCurrencies() {
static constexpr std::array<CurrencyRow, 14> rows{{ static constexpr std::array<CurrencyRow, 14> rows{{
{ "GB", { "GBP", "£" } }, { "GB", { "GBP", "£" } },

View file

@ -817,17 +817,21 @@ SafeHtml RenderCheckoutForm(const Product& product,
} }
if (bestUnits <= 0 || bestUnits > Form::kMaxQuantity) bestUnits = Form::kMaxQuantity; if (bestUnits <= 0 || bestUnits > Form::kMaxQuantity) bestUnits = Form::kMaxQuantity;
cc += R"(,"x":[)"; // Sanctions (s/sm) then the shipping allow-list (w/rm). Note w is what the
for (std::size_t i = 0; i < Money::NoSaleCountries().size(); ++i) { // shop CAN ship to, so the preview refuses on absence — which is why this
if (i) cc += ','; // payload stays five codes long while most of the world is closed, instead
cc += JsonStr(Money::NoSaleCountries()[i]); // of carrying a deny-list of two hundred.
} cc += R"(,"s":[)";
cc += std::format(R"(],"xm":{},"s":[)", JsonStr(Form::kNoSaleMessage));
for (std::size_t i = 0; i < Money::SanctionedCountries().size(); ++i) { for (std::size_t i = 0; i < Money::SanctionedCountries().size(); ++i) {
if (i) cc += ','; if (i) cc += ',';
cc += JsonStr(Money::SanctionedCountries()[i]); cc += JsonStr(Money::SanctionedCountries()[i]);
} }
cc += std::format(R"(],"sm":{}}})", JsonStr(Form::kSanctionsMessage)); cc += std::format(R"(],"sm":{},"w":[)", JsonStr(Form::kSanctionsMessage));
for (std::size_t i = 0; i < Money::ShippableCountries().size(); ++i) {
if (i) cc += ',';
cc += JsonStr(Money::ShippableCountries()[i]);
}
cc += std::format(R"(],"rm":{}}})", JsonStr(Form::kRegulatoryMessage));
const SafeHtml payFieldset = const SafeHtml payFieldset =
offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{}; offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{};
@ -919,7 +923,7 @@ SafeHtml RenderCheckoutForm(const Product& product,
// the lede would only repeat half of them. // the lede would only repeat half of them.
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::kShipsToMessage),
Escape(Form::kSanctionsMessage), Escape(Form::kSanctionsMessage),
CustomsNote(), CustomsNote(),
formError, formError,
@ -2146,11 +2150,14 @@ 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 — policy or sanctions: say so where the total would // Refused destination: say so where the total would have been, instead of
// have been, instead of pricing an order the server will decline. // pricing an order the server will decline. The same two gates as
"if(d.x&&d.x.indexOf(k)>-1){if(out){out.textContent=d.xm;out.hidden=false}return}" // ValidateCheckout, in the same order, from the same sentences — sanctions
// first, then absence from the shipping list.
"var eu=ecc.indexOf(k)>-1;"
"if(d.s&&d.s.indexOf(k)>-1){if(out){out.textContent=d.sm;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;" "if(d.w&&d.w.indexOf(k)<0){if(out){out.textContent=d.rm;out.hidden=false}return}"
"var 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
// quoting a total the submit would then reject. // quoting a total the submit would then reject.

View file

@ -511,8 +511,9 @@ treatment it replaces, which read as the wrong category for the work.
/* The measure goes on the prose, not on the container, so media can use the /* The measure goes on the prose, not on the container, so media can use the
full column: a post page is reading material and full-viewport line full column: a post page is reading material and full-viewport line
lengths get skimmed rather than read, but a screenshot of a feature lengths get skimmed rather than read, but a screenshot of a feature
support table is unreadable at text width. */ support table is unreadable at text width. A real table is exempt for
.post-body > :not(.post-media) { max-width: var(--measure); } exactly the same reason a screenshot of one is. */
.post-body > :not(.post-media, .post-body__table) { max-width: var(--measure); }
/* .post-media carries its own top margin for the cards, where it follows an /* .post-media carries its own top margin for the cards, where it follows an
excerpt; inside this grid the gap already provides it. */ excerpt; inside this grid the gap already provides it. */
.post-body > .post-media { margin-top: 0; } .post-body > .post-media { margin-top: 0; }
@ -533,7 +534,20 @@ treatment it replaces, which read as the wrong category for the work.
.post-body a { overflow-wrap: anywhere; } .post-body a { overflow-wrap: anywhere; }
/* Struck prose is the author retracting a line for effect. Muted as well as
struck, because a line through body-coloured text on a dark surface reads
as a rendering fault rather than as a deliberate aside. */
.post-body s { color: var(--text-muted); }
/* The reset drops list markers globally, and everywhere else on the site
that is right the nav, the card grids and the chain list are lists for
semantics, not for bullets. A post body is the one place that wants them
back: these are the author's numbered mini-guides, and without a marker an
ordered list is indistinguishable from an unordered one and the `start=`
the parser goes out of its way to emit is invisible. */
.post-body__list { padding-left: var(--s1); display: grid; gap: var(--s-2); } .post-body__list { padding-left: var(--s1); display: grid; gap: var(--s-2); }
ul.post-body__list { list-style: disc outside; }
ol.post-body__list { list-style: decimal outside; }
.post-body__list li { padding-left: var(--s-3); } .post-body__list li { padding-left: var(--s-3); }
.post-body__quote { .post-body__quote {
@ -567,6 +581,49 @@ treatment it replaces, which read as the wrong category for the work.
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
/* Pipe tables from a post body. Bordered rather than boxed like the code
block: a table already has structure, and a frame around a grid of lines
reads as two competing ones. */
.post-body__table {
/* The wrapper is what scrolls. A <table> refuses to be narrower than its
content, so without a scroll container around it a nine-column price
comparison scrolls the whole PAGE sideways see the code block. */
overflow-x: auto;
}
.post-body__table table {
border-collapse: collapse;
font-size: var(--step--1);
/* No width:100% on purpose the table is sized to its content. Stretched
to the column, a two-column table puts a hand-span of nothing between a
label and its value. */
}
.post-body__table :is(th, td) {
/* Left in both, overriding the browser's centred <th>. The delimiter row is
the only thing that moves a column, and it says so with a class. */
text-align: left;
padding: var(--s-2) var(--s-1);
border-bottom: 1px solid var(--border);
/* Cells wrap rather than forcing a scroll for every table, but not below
the width of a short word: nine columns on a phone would otherwise
shred into one character per line. Past that the container scrolls,
which is what it is for. */
min-width: 6ch;
}
.post-body__table th {
background: var(--surface-1);
border-bottom: 1px solid var(--border-strong);
/* Vertical-align matters here and nowhere else: a wrapped two-line heading
next to a one-line one should share a baseline at the bottom, where the
data starts. */
vertical-align: bottom;
}
.post-body__table tbody tr:last-child :is(th, td) { border-bottom: 0; }
/* Scoped under the wrapper for specificity, not for scoping: `:is(th, td)`
contributes a type selector, so the bare class would LOSE to the
text-align:left above and an aligned column would silently not be one. */
.post-body__table .post-body__cell--center { text-align: center; }
.post-body__table .post-body__cell--right { text-align: right; }
/* An image inside a sentence still gets its own line these are /* An image inside a sentence still gets its own line these are
screenshots being pointed at, not icons sitting in the text. The <picture> screenshots being pointed at, not icons sitting in the text. The <picture>
is what sits in the paragraph now (the reset already makes it a block); is what sits in the paragraph now (the reset already makes it a block);

View file

@ -117,21 +117,66 @@ int main() {
// Zones still classify US and CA (the arithmetic is destination-blind, and // Zones still classify US and CA (the arithmetic is destination-blind, and
// keeping it that way means one policy switch, not two); the sale is what // keeping it that way means one policy switch, not two); the sale is what
// stops, in SellsTo. // stops, in SellsTo.
// Two hard gates, and the shipping one is an ALLOW-list. The assertion that
// matters most is therefore the DEFAULT: a country nobody has cleared
// refuses. This is the test that fails if the gate is ever "simplified" back
// into a deny-list, which would silently reopen most of the world.
Check(SellsTo("NL") && SellsTo("CH") && SellsTo("AU")
&& SellsTo("HK") && SellsTo("SG")
&& SellsTo("RS") && SellsTo("ME") && SellsTo("AL") && SellsTo("XK")
&& SellsTo("GE"),
"policy: every cleared destination sells");
// Both were cleared once and refuted on verification. JP because using a
// non-giteki handset is a Radio Act offence for the BUYER; NZ because its
// radio regulator's supplier duties expressly reach a website seller and an
// overseas company cannot register to comply. Asserted by name so a future
// "these look fine, add them back" cannot pass silently.
Check(!SellsTo("JP") && !SellsTo("NZ"),
"policy: refuted destinations stay refuted");
Check(!SellsTo("DE") && !SellsTo("FR") && !SellsTo("BE"),
"policy: uncleared member states refuse by default");
Check(!SellsTo("KR") && !SellsTo("MX") && !SellsTo("ZA") && !SellsTo("XX"),
"policy: uncleared and unknown codes refuse by default");
Check(!SellsTo("NO") && !SellsTo("IS"),
"policy: the EEA inherits the EU's distance-seller duties, so it waits");
Check(!SellsTo("GB"), "policy: GB waits on its EA small-producer entries");
Check(!SellsTo("TR") && !SellsTo("IN") && !SellsTo("BR"),
"policy: IMEI and type-approval destinations refuse");
// North America is refused for regulatory reasons now, not insurance ones —
// it simply is not on the list, and there is no separate category for it.
Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused"); Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused");
Check(SellsTo("NL") && SellsTo("DE"), "policy: EU sells"); // Matching is on the normalised code, as everywhere else here — but note the
Check(SellsTo("GB") && SellsTo("CH") && SellsTo("AU"), // direction the allow-list fails in. Under the old deny-list, "us" missed the
"policy: the rest of the world still sells"); // blocked entry and SOLD; now an unnormalised code is simply absent from the
Check(SellsTo("us"), "policy: matched on the normalised code, like membership"); // list and refuses. Callers still uppercase first (ValidateCheckout does),
// but the consequence of forgetting is a lost sale rather than a shipment to
// a country the shop cannot serve.
Check(!SellsTo("us") && !SellsTo("nl"),
"policy: an unnormalised code fails closed, not open");
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 Check(ShipsTo("NL") && !ShipsTo("US") && !ShipsTo("DE"),
// stay distinguishable — the checkout error names the law for one and the "policy: ShipsTo is the allow-list on its own");
// shop's own choice for the other. // Form::kShipsToMessage names these countries in prose for the buy page, and
// prose cannot be generated from ISO codes. If this fails because a country
// was opened, update that sentence too — the two must not drift.
Check(ShippableCountries().size() == 10,
"policy: opening a country means updating Form::kShipsToMessage as well");
// Sanctioned destinations refuse through the same gate, but stay
// distinguishable — the checkout error names the law for one and pending
// paperwork for the other, and conflating them would tell a Russian buyer
// to email and ask.
Check(!SellsTo("RU") && !SellsTo("BY") && !SellsTo("KP"), Check(!SellsTo("RU") && !SellsTo("BY") && !SellsTo("KP"),
"sanctions: RU, BY and KP refused"); "sanctions: RU, BY and KP refused");
Check(IsSanctioned("RU") && IsSanctioned("BY") && IsSanctioned("KP"), Check(IsSanctioned("RU") && IsSanctioned("BY") && IsSanctioned("KP"),
"sanctions: the list knows its members"); "sanctions: the list knows its members");
Check(!IsSanctioned("US") && !IsSanctioned("NL"), Check(!IsSanctioned("US") && !IsSanctioned("DE") && !IsSanctioned("NL"),
"sanctions: the insurance refusal is not a sanctions refusal"); "sanctions: an uncleared country is not a sanctioned one");
// Belt and braces: a sanctioned code must never reach the allow-list, or the
// wrong sentence would be shown for a criminal-law refusal.
for (const std::string_view cc : SanctionedCountries()) {
Check(!ShipsTo(cc), "sanctions: never on the shipping list", cc);
}
// ── 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 /
@ -264,14 +309,20 @@ int main() {
// A country the shop refuses gets no localised price either — the two // A country the shop refuses gets no localised price either — the two
// tables are kept consistent on purpose, so this is a real invariant and // tables are kept consistent on purpose, so this is a real invariant and
// not a coincidence of the current list. // not a coincidence of the current list.
for (const std::string_view cc : NoSaleCountries()) {
Check(!CurrencyFor(cc).has_value(),
"fx: refused destinations have no display currency", cc);
}
for (const std::string_view cc : SanctionedCountries()) { for (const std::string_view cc : SanctionedCountries()) {
Check(!CurrencyFor(cc).has_value(), Check(!CurrencyFor(cc).has_value(),
"fx: sanctioned destinations have no display currency", cc); "fx: sanctioned destinations have no display currency", cc);
} }
Check(!CurrencyFor("US").has_value() && !CurrencyFor("CA").has_value(),
"fx: north america has no display currency either");
// The invariant covers only refusals nothing will lift. The table otherwise
// runs AHEAD of the shipping list on purpose — GB keeps its GBP row while its
// e-waste registrations are pending, because "what would this cost me" stays
// a fair question for a country one small registration from opening, and
// deleting the row to restore it weeks later would be churn. The refusal that
// must not be quoted around is enforced in SellsTo, not here.
Check(CurrencyFor("GB").has_value(),
"fx: a temporarily-closed destination keeps its display currency");
// ── rates loader ────────────────────────────────────────────────── // ── rates loader ──────────────────────────────────────────────────
const Rates r = LoadRates( const Rates r = LoadRates(

View file

@ -107,9 +107,13 @@ int main(int argc, char** argv) {
srv.BodyHas("/shop/fp6-pmos", "\"brand\":{\"@type\":\"Brand\",\"name\":\"Fairphone\"}", srv.BodyHas("/shop/fp6-pmos", "\"brand\":{\"@type\":\"Brand\",\"name\":\"Fairphone\"}",
"product carries the hardware brand"); "product carries the hardware brand");
// One entry per (transit tier, price) the carrier table produces — three, // One entry per (transit tier, price) the carrier table produces, for the
// for the fixture's NL/DE/GB. Not a fixed property of the code any more: // destinations the shop actually sells to — two, since the fixture prices
// it is whatever the carrier prices, which is the point. // NL/DE/GB/CH and checkout refuses DE and GB pending their producer
// registrations. Not a fixed property of the code: it is whatever the
// carrier prices INTERSECTED with where the shop sells, and publishing a
// rate to a country checkout would decline is an offer that cannot be
// accepted.
const Json::Value* shipping = nullptr; const Json::Value* shipping = nullptr;
if (group) { if (group) {
if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray() if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray()
@ -119,27 +123,33 @@ int main(int argc, char** argv) {
} }
} }
} }
Check(shipping && shipping->IsArray() && shipping->array.size() == 3, Check(shipping && shipping->IsArray() && shipping->array.size() == 2,
"shipping details group the carrier's rates"); "shipping details group the carrier's rates");
// The advertised rate IS the carrier's single-unit price, and a // The advertised rate IS the carrier's single-unit price. Neither a
// destination the table does not cover is never advertised. // destination the table does not cover (AU) nor one the policy refuses
// (DE, priced at €25 in the fixture) is ever advertised.
if (shipping && shipping->IsArray()) { if (shipping && shipping->IsArray()) {
std::vector<std::string> rates; std::vector<std::string> rates;
bool au = false; bool au = false;
bool refused = false;
for (const Json::Value& detail : shipping->array) { for (const Json::Value& detail : shipping->array) {
if (const Json::Value* rate = detail.Find("shippingRate")) { if (const Json::Value* rate = detail.Find("shippingRate")) {
rates.emplace_back(rate->Str("value")); rates.emplace_back(rate->Str("value"));
} }
if (const Json::Value* dest = detail.Find("shippingDestination")) { if (const Json::Value* dest = detail.Find("shippingDestination")) {
if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) { if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) {
for (const Json::Value& c : cc->array) au = au || c.string == "AU"; for (const Json::Value& c : cc->array) {
au = au || c.string == "AU";
refused = refused || c.string == "DE" || c.string == "GB";
}
} }
} }
} }
std::ranges::sort(rates); std::ranges::sort(rates);
Check(rates == std::vector<std::string>{ "15.00", "25.00", "55.00" }, Check(rates == std::vector<std::string>{ "15.00", "55.00" },
"published shipping rates come from the carrier table"); "published shipping rates come from the carrier table");
Check(!au, "an uncovered destination is not advertised"); Check(!au, "an uncovered destination is not advertised");
Check(!refused, "a destination the policy refuses is not advertised");
} }
if (srv.ShopOpen()) { if (srv.ShopOpen()) {

View file

@ -166,20 +166,21 @@ void OpenShopLifecycle(TestServer& srv) {
} }
// A non-EU order: ex-VAT goods, world shipping, and the indicative // A non-EU order: ex-VAT goods, world shipping, and the indicative
// national currency line sourced from the build-time ECB rates. GB rather // national currency line sourced from the build-time ECB rates. CH because
// than a North American destination because those are refused outright. // it is the export destination that actually sells — North America is
const std::string tokenGb = TokenOf(srv.Post("/shop/fp6-pmos", // refused on insurance, and GB waits on its e-waste registrations.
"email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB")); const std::string tokenCh = TokenOf(srv.Post("/shop/fp6-pmos",
Check(!tokenGb.empty(), "GB checkout issues an order"); "email=ch%40example.org&name=Heidi&street=1%20Bahnhofstrasse&postal=8001&city=Zurich&country=CH"));
if (!tokenGb.empty()) { Check(!tokenCh.empty(), "CH checkout issues an order");
const std::string page = srv.Body(std::format("/order/{}", tokenGb)); if (!tokenCh.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenCh));
// €474.21 goods (green net) + €55 world shipping = €529.21 // €474.21 goods (green net) + €55 world shipping = €529.21
Check(page.find("€529.21") != std::string::npos, Check(page.find("€529.21") != std::string::npos,
"export order total is ex-VAT + world shipping"); "export order total is ex-VAT + world shipping");
Check(page.find("Zero-rated export") != std::string::npos, Check(page.find("Zero-rated export") != std::string::npos,
"export order states the VAT treatment"); "export order states the VAT treatment");
Check(std::regex_search(page, std::regex("£[0-9]+")), Check(std::regex_search(page, std::regex("CHF [0-9]+")),
"export order shows the indicative GBP amount"); "export order shows the indicative CHF amount");
Check(page.find("indicative") != std::string::npos, Check(page.find("indicative") != std::string::npos,
"conversion is labelled indicative"); "conversion is labelled indicative");
} }
@ -187,7 +188,7 @@ void OpenShopLifecycle(TestServer& srv) {
// A two-unit white export order: unit €665.38, line €1330.76, net from the // A two-unit white export order: unit €665.38, line €1330.76, net from the
// LINE total (not per unit) = €1099.80, plus €55 world shipping = €1154.80. // LINE total (not per unit) = €1099.80, plus €55 world shipping = €1154.80.
const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos", const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos",
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=GB&color=white&quantity=2")); "email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CH&color=white&quantity=2"));
Check(!tokenWhite.empty(), "white ×2 checkout issues an order"); Check(!tokenWhite.empty(), "white ×2 checkout issues an order");
if (!tokenWhite.empty()) { if (!tokenWhite.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenWhite)); const std::string page = srv.Body(std::format("/order/{}", tokenWhite));
@ -357,15 +358,15 @@ void OpenShopLifecycle(TestServer& srv) {
// The NL order's message, found by its own order link (the same email // The NL order's message, found by its own order link (the same email
// address placed two orders, so the address alone would be ambiguous). // address placed two orders, so the address alone would be ambiguous).
std::string nlMail; std::string nlMail;
std::string gbMail; std::string chMail;
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) { for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
const std::string name = entry.path().filename().string(); const std::string name = entry.path().filename().string();
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue; if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
const std::string mail = ReadFile(entry.path()); const std::string mail = ReadFile(entry.path());
if (mail.find(std::format("/order/{}", token)) != std::string::npos) nlMail = mail; if (mail.find(std::format("/order/{}", token)) != std::string::npos) nlMail = mail;
if (!tokenGb.empty() if (!tokenCh.empty()
&& mail.find(std::format("/order/{}", tokenGb)) != std::string::npos) { && mail.find(std::format("/order/{}", tokenCh)) != std::string::npos) {
gbMail = mail; chMail = mail;
} }
} }
Check(!nlMail.empty(), "a confirmation email links the NL order"); Check(!nlMail.empty(), "a confirmation email links the NL order");
@ -392,7 +393,7 @@ void OpenShopLifecycle(TestServer& srv) {
} }
} }
// The export order's message states the VAT treatment its invoice carries. // The export order's message states the VAT treatment its invoice carries.
Check(!gbMail.empty() && gbMail.find("zero-rated export") != std::string::npos, Check(!chMail.empty() && chMail.find("zero-rated export") != std::string::npos,
"export confirmation states the zero-rated treatment"); "export confirmation states the zero-rated treatment");
// Idempotency comes from the ledger's notified event, not from luck in // Idempotency comes from the ledger's notified event, not from luck in
@ -467,12 +468,12 @@ void AlwaysOnValidation(TestServer& srv) {
"email=a%40b.example&country=NL"); // missing address "email=a%40b.example&country=NL"); // missing address
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&website=spam")); // honeypot srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&website=spam")); // honeypot
// Destinations the shop refuses (Money::NoSaleCountries). Well-formed, // Destinations the shop refuses (Money::SellsTo). Well-formed, real
// real addresses: the refusal is policy, not a shape check, so it has to // addresses: the refusal is policy, not a shape check, so it has to hold for
// hold for every spelling the form accepts. Deliberately outside the // every spelling the form accepts. Deliberately outside the shop-open gate —
// shop-open gate — validation runs before the coming-soon check, so this // validation runs before the coming-soon check, so this must answer 422
// must answer 422 whether the shop is open or not, and it is the // whether the shop is open or not, and it is the assertion that would catch
// assertion that would catch the block being lost in a refactor. // the gate being lost in a refactor.
const std::size_t before = LedgerLines(srv).size(); const std::size_t before = LedgerLines(srv).size();
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");
@ -489,6 +490,31 @@ void AlwaysOnValidation(TestServer& srv) {
"email=by%40example.org&name=Vanya&street=1%20Kastrychnitskaya&postal=220030&city=Minsk&country=BY"); "email=by%40example.org&name=Vanya&street=1%20Kastrychnitskaya&postal=220030&city=Minsk&country=BY");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=ru"); "email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=ru");
// Destinations off the shipping allow-list (Money::ShipsTo). DE and GB are
// the pointed cases: the fixture PRICES both, so a rate exists and the parcel
// is postable — the refusal is entirely the policy's, which is the whole
// reason to assert these rather than a country the carrier never covered.
//
// Status only, like every other refusal in this function. The exact sentence
// is asserted in ShouldValidateForms, where the validator is called directly:
// this function runs in the coming-soon state too, and a closed shop renders
// no order form for a field error to land in. What is worth proving over real
// HTTP is that the refusal holds at all, in both states — which is what 422
// says here.
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=de%40example.org&name=Klaus&street=1%20Hauptstr&postal=10115&city=Berlin&country=DE");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=no%40example.org&name=Kari&street=1%20Karl%20Johans&postal=0154&city=Oslo&country=NO");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=tr%40example.org&name=Emre&street=1%20Istiklal&postal=34430&city=Istanbul&country=TR");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=br%40example.org&name=Ana&street=1%20Paulista&postal=01310&city=Sao%20Paulo&country=BR");
// And the default that makes an allow-list worth having: a well-formed code
// nobody ever considered is refused without appearing on any list.
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=zz%40example.org&name=Sam&street=1%20Main&postal=0000&city=Nowhere&country=ZZ");
// 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");

View file

@ -95,10 +95,94 @@ int main() {
Check(md("```\nx").View().find("<code>x") != std::string_view::npos, Check(md("```\nx").View().find("<code>x") != std::string_view::npos,
"md: unterminated fence still renders its content"); "md: unterminated fence still renders its content");
// ── tables ────────────────────────────────────────────────────────
CheckEq(md("| a | b |\n|---|---|\n| 1 | 2 |"),
R"(<div class="post-body__table"><table>)"
R"(<thead><tr><th>a</th><th>b</th></tr></thead>)"
R"(<tbody><tr><td>1</td><td>2</td></tr></tbody>)"
R"(</table></div>)",
"md: pipe table");
// The shape the carrier table in the camera post is actually written in:
// leading pipe, no trailing one. Requiring both would leave it as prose.
CheckEq(md("| Device | OS\n|---|---|\n| FP6 | pmOS |"),
R"(<div class="post-body__table"><table>)"
R"(<thead><tr><th>Device</th><th>OS</th></tr></thead>)"
R"(<tbody><tr><td>FP6</td><td>pmOS</td></tr></tbody>)"
R"(</table></div>)",
"md: outer pipes are optional independently");
// The whole reason recognition needs two lines. These posts paste pipelines
// and or-lists into prose constantly; none of them is a table.
CheckEq(md("dmesg | grep ufs"), "<p>dmesg | grep ufs</p>",
"md: a pipe in prose is not a table");
// A mismatched column count is far more likely to be prose with pipes in it
// than a table whose author miscounted — so it stays prose, visibly odd.
Check(md("| a | b |\n|---|").View().starts_with("<p>"),
"md: delimiter row must agree about the column count",
md("| a | b |\n|---|").View());
// `---` is a rule, as it is everywhere else in this file. A one-column
// table has to write the pipe.
CheckEq(md("| Device\n---"), "<p>| Device</p><hr>",
"md: a bare dash row is still a thematic break");
CheckEq(md("| a | b | c |\n|:---|:---:|---:|\n| 1 | 2 | 3 |"),
R"(<div class="post-body__table"><table>)"
R"(<thead><tr><th>a</th>)"
R"(<th class="post-body__cell--center">b</th>)"
R"(<th class="post-body__cell--right">c</th></tr></thead>)"
R"(<tbody><tr><td>1</td>)"
R"(<td class="post-body__cell--center">2</td>)"
R"(<td class="post-body__cell--right">3</td></tr></tbody>)"
R"(</table></div>)",
"md: alignment row moves the column, left needs no class");
// A short row is padded so the grid stays rectangular.
Check(md("| a | b |\n|---|---|\n| 1 |").View().find("<td>1</td><td></td>")
!= std::string_view::npos,
"md: a short row is padded to the header width",
md("| a | b |\n|---|---|\n| 1 |").View());
// Cells are prose, so everything inline works in them — and everything
// inline is still escaped in them.
Check(md("| [x](https://e.example) | `<b>` |\n|---|---|").View().find(
R"(<th><a href="https://e.example" rel="noopener">x</a></th>)"
R"(<th><code>&lt;b&gt;</code></th>)") != std::string_view::npos,
"md: cells render inline markup and stay escaped",
md("| [x](https://e.example) | `<b>` |\n|---|---|").View());
// `\|` is how a cell contains a pipe.
Check(md("| a \\| b | c |\n|---|---|").View().find("<th>a | b</th><th>c</th>")
!= std::string_view::npos,
"md: an escaped pipe is cell content, not a cell boundary",
md("| a \\| b | c |\n|---|---|").View());
// The GPU comparison in one of these posts is a table inside a quote.
Check(md("> | a | b |\n> |---|---|\n> | 1 | 2 |").View().starts_with(
R"(<blockquote class="post-body__quote"><div class="post-body__table">)"),
"md: a table inside a blockquote is still a table",
md("> | a | b |\n> |---|---|\n> | 1 | 2 |").View());
// A table under a prose line with no blank line between is still a table:
// absorbing it into the paragraph is the exact failure this all fixes.
Check(md("text\n| a | b |\n|---|---|").View().starts_with("<p>text</p><div"),
"md: a table interrupts a paragraph",
md("text\n| a | b |\n|---|---|").View());
// Prose resumes after the table rather than being eaten as a one-cell row.
Check(md("| a | b |\n|---|---|\n| 1 | 2 |\nafter").View().ends_with("<p>after</p>"),
"md: a line with no pipe ends the table",
md("| a | b |\n|---|---|\n| 1 | 2 |\nafter").View());
// A list marker wins over a table: a line that opens one is a list item.
Check(md("- a | b\n|---|---|").View().starts_with("<ul"),
"md: a list item is not a table header",
md("- a | b\n|---|---|").View());
// ── inline ──────────────────────────────────────────────────────── // ── inline ────────────────────────────────────────────────────────
CheckEq(md("**bold**"), "<p><strong>bold</strong></p>", "md: strong"); CheckEq(md("**bold**"), "<p><strong>bold</strong></p>", "md: strong");
CheckEq(md("*em*"), "<p><em>em</em></p>", "md: emphasis"); CheckEq(md("*em*"), "<p><em>em</em></p>", "md: emphasis");
CheckEq(md("2 * 3 * 4"), "<p>2 * 3 * 4</p>", "md: spaced asterisks stay literal"); CheckEq(md("2 * 3 * 4"), "<p>2 * 3 * 4</p>", "md: spaced asterisks stay literal");
// <s>, not GFM's <del>: nothing was removed from this document, and the one
// body that uses this is striking a joke through for effect.
CheckEq(md("~~struck~~"), "<p><s>struck</s></p>", "md: strikethrough");
CheckEq(md("~~**both**~~"), "<p><s><strong>both</strong></s></p>",
"md: strikethrough nests");
// A lone tilde is a home directory or an approximation, never a delimiter.
CheckEq(md("~/.local and ~5 minutes"), "<p>~/.local and ~5 minutes</p>",
"md: a single tilde strikes nothing");
CheckEq(md("a ~~ b ~~ c"), "<p>a ~~ b ~~ c</p>",
"md: spaced tildes stay literal");
// Underscores are deliberately inert: these posts paste kernel symbol // Underscores are deliberately inert: these posts paste kernel symbol
// names into prose, and italicising half of one is worse than not // names into prose, and italicising half of one is worse than not
// italicising a word that used the underscore form. // italicising a word that used the underscore form.
@ -172,7 +256,11 @@ int main() {
Check(!md("[unclosed](").View().empty(), "md: unclosed link terminates"); Check(!md("[unclosed](").View().empty(), "md: unclosed link terminates");
Check(!md("![](").View().empty(), "md: unclosed image terminates"); Check(!md("![](").View().empty(), "md: unclosed image terminates");
Check(!md("`unclosed").View().empty(), "md: unclosed code span terminates"); Check(!md("`unclosed").View().empty(), "md: unclosed code span terminates");
Check(!md("~~unclosed").View().empty(), "md: unclosed strikethrough terminates");
Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates"); Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates");
Check(!md("|||\n|||").View().empty(), "md: degenerate table terminates");
Check(!md("|---|---|").View().empty(), "md: a lone delimiter row terminates");
Check(!md("| a |\n| - |\n|").View().empty(), "md: a ragged table terminates");
if (failures != 0) { if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures); std::println(std::cerr, "{} check(s) failed", failures);

View file

@ -100,13 +100,16 @@ void CatalogueContract() {
// checkout charges. // checkout charges.
{ {
// The listing's shipping block is now carrier data, so the // The listing's shipping block is now carrier data, so the
// render needs a table. US is priced here on purpose: the // render needs a table. Three of these four are priced on purpose
// carrier will happily quote it and the shop still must not // and must still not be advertised: US is refused on insurance, DE
// advertise it. // and GB on their missing producer registrations. The carrier will
// happily quote all three, which is exactly why the filter is worth
// asserting — CH is the only one here besides home that sells.
const std::vector<ShipRates> feedTable{ const std::vector<ShipRates> feedTable{
{ "NL", { { 2000, 895 } } }, { "NL", { { 2000, 895 } } },
{ "CH", { { 2000, 2450 } } },
{ "DE", { { 2000, 995 } } }, { "DE", { { 2000, 995 } } },
{ "GB", { { 2000, 2450 } } }, { "GB", { { 2000, 3300 } } },
{ "US", { { 2000, 1794 } } }, { "US", { { 2000, 1794 } } },
}; };
auto pp = Views::RenderProduct(pr, Rates{}, feedTable); auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
@ -137,6 +140,13 @@ void CatalogueContract() {
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos, && pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
"schema: shipping rates come from the carrier table"); "schema: shipping rates come from the carrier table");
// And nothing the shop refuses is advertised, whatever the carrier
// quotes for it — by rate, so a filter that dropped the country code
// but kept the price would still be caught.
Check(pp.meta.jsonLd.find("\"9.95\"") == std::string::npos
&& pp.meta.jsonLd.find("\"33.00\"") == std::string::npos
&& pp.meta.jsonLd.find("\"17.94\"") == std::string::npos,
"schema: refused destinations are never advertised");
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos, && pp.meta.jsonLd.find("\"US\"") == std::string::npos,
"schema: a refused destination is never advertised, priced or not"); "schema: a refused destination is never advertised, priced or not");
@ -378,11 +388,23 @@ void CheckoutPreviewData() {
// The blob is a JSON document inside an HTML attribute, so every quote // The blob is a JSON document inside an HTML attribute, so every quote
// arrives escaped — matching the escaped form is matching what the // arrives escaped — matching the escaped form is matching what the
// browser actually parses back out. // browser actually parses back out.
Check(html.find("&quot;x&quot;:[&quot;US&quot;,&quot;CA&quot;]") != std::string_view::npos,
"checkout: the preview carries the no-sale list verbatim");
Check(html.find("&quot;s&quot;:[&quot;RU&quot;,&quot;BY&quot;,&quot;KP&quot;]") Check(html.find("&quot;s&quot;:[&quot;RU&quot;,&quot;BY&quot;,&quot;KP&quot;]")
!= std::string_view::npos, != std::string_view::npos,
"checkout: the preview carries the sanctions list verbatim"); "checkout: the preview carries the sanctions list verbatim");
// `w` is the shipping ALLOW-list: the preview refuses a country by its
// ABSENCE here, which is why the payload stays five codes long instead of
// enumerating the two hundred that are closed.
Check(html.find("&quot;w&quot;:[&quot;NL&quot;,&quot;CH&quot;,&quot;AU&quot;,"
"&quot;HK&quot;,&quot;SG&quot;,&quot;RS&quot;,&quot;ME&quot;,"
"&quot;AL&quot;,&quot;XK&quot;,&quot;GE&quot;]") != std::string_view::npos,
"checkout: the preview carries the shipping allow-list verbatim");
Check(html.find("&quot;US&quot;") == std::string_view::npos,
"checkout: no deny-list survives in the preview payload");
// Both refusals ship their own sentence, or the preview would word a decline
// differently from the submit that follows it.
Check(html.find("&quot;sm&quot;") != std::string_view::npos
&& html.find("&quot;rm&quot;") != std::string_view::npos,
"checkout: the preview carries a message for each refusal");
// The unit weight, which is what selects a bracket out of the carrier // The unit weight, which is what selects a bracket out of the carrier
// table the same blob carries. // table the same blob carries.
Check(html.find("&quot;g&quot;:700") != std::string_view::npos, Check(html.find("&quot;g&quot;:700") != std::string_view::npos,

View file

@ -253,19 +253,43 @@ int main() {
return validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y&country=" return validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y&country="
+ std::string(cc)); + std::string(cc));
}; };
Check(withCountry("NL").Ok(), "checkout: the home market sells");
Check(withCountry("CH").Ok() && withCountry("AU").Ok()
&& withCountry("HK").Ok() && withCountry("SG").Ok()
&& withCountry("RS").Ok() && withCountry("ME").Ok()
&& withCountry("AL").Ok() && withCountry("XK").Ok()
&& withCountry("GE").Ok(),
"checkout: every cleared destination sells");
Check(!withCountry("JP").Ok() && !withCountry("NZ").Ok(),
"checkout: destinations refuted on verification stay refused");
// The default is the point of an allow-list: uncleared, unknown and
// never-considered codes all refuse without anyone listing them.
Check(!withCountry("US").Ok(), "checkout: US refused"); Check(!withCountry("US").Ok(), "checkout: US refused");
Check(!withCountry("CA").Ok(), "checkout: CA refused"); Check(!withCountry("CA").Ok(), "checkout: CA refused");
Check(!withCountry("us").Ok(), "checkout: lowercase US refused too"); Check(!withCountry("us").Ok(), "checkout: lowercase US refused too");
Check(withCountry("GB").Ok(), "checkout: other non-EU destinations still sell"); Check(!withCountry("DE").Ok() && !withCountry("GB").Ok()
Check(withCountry("NL").Ok(), "checkout: EU unaffected"); && !withCountry("NO").Ok() && !withCountry("TR").Ok(),
"checkout: uncleared destinations refuse");
Check(!withCountry("XX").Ok(),
"checkout: a syntactically valid code nobody has cleared refuses");
{ {
auto us = withCountry("US"); auto us = withCountry("US");
Check(us.errors.size() == 1 && us.errors[0].field == "country", Check(us.errors.size() == 1 && us.errors[0].field == "country",
"checkout: refusal is a country error, nothing else"); "checkout: refusal is a country error, nothing else");
Check(us.errors[0].message == Catcrafts::Form::kNoSaleMessage, Check(us.errors[0].message == Catcrafts::Form::kRegulatoryMessage,
"checkout: refusal says where the shop does not sell"); "checkout: refusal gives the regulatory reason");
Check(us.value.country == "US", "checkout: refused country echoed back"); Check(us.value.country == "US", "checkout: refused country echoed back");
} }
{
// One sentence for every non-sanctions refusal, whatever the underlying
// reason was — the buyer cannot act on the difference.
auto de = withCountry("DE");
auto tr = withCountry("TR");
Check(de.errors.size() == 1 && tr.errors.size() == 1
&& de.errors[0].message == Catcrafts::Form::kRegulatoryMessage
&& tr.errors[0].message == Catcrafts::Form::kRegulatoryMessage,
"checkout: every uncleared destination gets the same sentence");
}
// Sanctioned destinations: same gate, different sentence. The message has // Sanctioned destinations: same gate, different sentence. The message has
// to name the law rather than shop policy — a buyer told "Catcrafts does // to name the law rather than shop policy — a buyer told "Catcrafts does
@ -284,6 +308,11 @@ int main() {
Check(ru.value.country == "RU", "checkout: sanctioned country echoed back"); Check(ru.value.country == "RU", "checkout: sanctioned country echoed back");
} }
// Sanctions keep their own sentence: a buyer told the generic "email us and
// ask" would waste their time on something no amount of paperwork can lift.
Check(withCountry("RU").errors[0].message != Catcrafts::Form::kRegulatoryMessage,
"checkout: sanctions do not collapse into the regulatory sentence");
// Parameter pollution against the order gate. Every refusal above reads its // Parameter pollution against the order gate. Every refusal above reads its
// field through Fields::Get, which takes the FIRST of a repeated pair, so // field through Fields::Get, which takes the FIRST of a repeated pair, so
// appending a second value cannot reopen a destination the first one closed // appending a second value cannot reopen a destination the first one closed

View file

@ -84,6 +84,12 @@ inline std::size_t CountOccurrences(std::string_view haystack, std::string_view
// The single-unit rates are the €15 / €25 / €55 the totals assert. The second // The single-unit rates are the €15 / €25 / €55 the totals assert. The second
// band exists so the too-heavy refusal has a real ceiling to hit: // band exists so the too-heavy refusal has a real ceiling to hit:
// 10 kg / 700 g per unit = 14 units per parcel. // 10 kg / 700 g per unit = 14 units per parcel.
//
// CH carries the €55 world rate because it is the export destination the shop
// actually sells to; DE and GB keep their rows even though checkout now refuses
// both (unregistered producer schemes) precisely BECAUSE it refuses them — a
// destination the carrier prices and the policy declines is the case worth
// having a fixture for, and the suites assert that the policy wins.
inline constexpr std::string_view kShippingFixture = inline constexpr std::string_view kShippingFixture =
R"({"method":"e2e fixture","fetched_at":"2026-01-01T00:00:00Z","per_country":{)" R"({"method":"e2e fixture","fetched_at":"2026-01-01T00:00:00Z","per_country":{)"
"\n" "\n"
@ -91,7 +97,9 @@ inline constexpr std::string_view kShippingFixture =
"\n" "\n"
R"("DE":[[2000,2500],[10000,4200]],)" R"("DE":[[2000,2500],[10000,4200]],)"
"\n" "\n"
R"("GB":[[2000,5500],[10000,7900]]}})" R"("GB":[[2000,5500],[10000,7900]],)"
"\n"
R"("CH":[[2000,5500],[10000,7900]]}})"
"\n"; "\n";
struct ServerOptions { struct ServerOptions {