donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-17 11:04:03 +02:00
commit abbd616b40
23 changed files with 2898 additions and 209 deletions

View file

@ -230,6 +230,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("ShouldGuardRequestProvenance").Dependencies({ core, shared }); cfg.AddTest("ShouldGuardRequestProvenance").Dependencies({ core, shared });
cfg.AddTest("ShouldBuildInvoices").Dependencies({ core, shared }); cfg.AddTest("ShouldBuildInvoices").Dependencies({ core, shared });
cfg.AddTest("ShouldPublishFinancials").Dependencies({ core, shared }); cfg.AddTest("ShouldPublishFinancials").Dependencies({ core, shared });
cfg.AddTest("ShouldFoldTheOrderLedger").Dependencies({ core, shared });
cfg.AddTest("ShouldIssueEurcAddresses").Dependencies({ core, shared });
// ── black-box suites (the tools/e2e.sh port) ────────────────── // ── black-box suites (the tools/e2e.sh port) ──────────────────
// Each spawns the REAL binary — depending on &cfg is what builds it // Each spawns the REAL binary — depending on &cfg is what builds it

View file

@ -17,7 +17,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
// //
// Why EURC and not a coin: EURC is euro-denominated at par, so there is no rate // Why EURC and not a coin: EURC is euro-denominated at par, so there is no rate
// to quote, no quote to expire, no revaluation at year end, and no exchange-rate // to quote, no quote to expire, no revaluation at year end, and no exchange-rate
// line in the books. €563.30 owed is 563300000 EURC base units owed, forever. // line in the books. €573.80 owed is 573800000 EURC base units owed, forever.
// That collapses the entire pricing problem to integer arithmetic, which is the // That collapses the entire pricing problem to integer arithmetic, which is the
// same arithmetic every other amount in this codebase already uses. // same arithmetic every other amount in this codebase already uses.
// //

View file

@ -187,7 +187,11 @@ HTTPResponse RenderPage(std::string_view target) {
if (route.kind == RouteKind::Invoice) { if (route.kind == RouteKind::Invoice) {
HTTPResponse res; HTTPResponse res;
std::optional<OrderRecord> order = FindOrder(route.slug); std::optional<OrderRecord> order = FindOrder(route.slug);
if (!order || (order->status != "paid" && order->status != "shipped")) { // A donation has no invoice — nothing was supplied — so its token
// answers the same 404 an unknown one does rather than minting a
// number for a document that must not exist.
if (!order || order->donation
|| (order->status != "paid" && order->status != "shipped")) {
res.status = "404"; res.status = "404";
ApplyPageHeaders(res, "text/plain; charset=utf-8", false, true); ApplyPageHeaders(res, "text/plain; charset=utf-8", false, true);
res.body = "Not found\n"; res.body = "Not found\n";
@ -290,6 +294,7 @@ HTTPResponse RenderPage(std::string_view target) {
view.shippingMinor = order->shippingMinor; view.shippingMinor = order->shippingMinor;
view.totalMinor = order->totalMinor; view.totalMinor = order->totalMinor;
view.vatIncluded = order->vatIncluded; view.vatIncluded = order->vatIncluded;
view.donation = order->donation;
view.quantity = order->quantity; view.quantity = order->quantity;
view.unitMinor = order->unitMinor; view.unitMinor = order->unitMinor;
if (const Product* p = gContent.FindProduct(order->product)) { if (const Product* p = gContent.FindProduct(order->product)) {
@ -361,8 +366,11 @@ HTTPResponse RenderPage(std::string_view target) {
const std::vector<OrderRecord> orders = ListOrders(); const std::vector<OrderRecord> orders = ListOrders();
const SalesSummary sales = SummarizeSales(orders); const SalesSummary sales = SummarizeSales(orders);
const Financials fin = CurrentFinancials(); const Financials fin = CurrentFinancials();
// Shop donations ride along from the same fold: they are live like
// sales, and the renderer joins them with the bank-side donations.
const Views::RenderedPage page = const Views::RenderedPage page =
Views::RenderFinancials(sales.count, sales.totalMinor, fin); Views::RenderFinancials(sales.count, sales.totalMinor, fin,
sales.donationCount, sales.donationsMinor);
HTTPResponse res; HTTPResponse res;
res.status = std::to_string(page.status); res.status = std::to_string(page.status);
ApplyPageHeaders(res, "text/html; charset=utf-8", /*cacheable=*/false, ApplyPageHeaders(res, "text/html; charset=utf-8", /*cacheable=*/false,
@ -595,7 +603,12 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
req.body.size() > Form::kMaxBodyBytes ? "413" : "400"); req.body.size() > Form::kMaxBodyBytes ? "413" : "400");
} }
Form::CheckoutResult parsed = Form::ValidateCheckout(*fields); // A donation validates by its own rules: an amount instead of a price,
// no address because nothing ships. Everything after validation — rails,
// rate limit, storage, redirect — is shared.
Form::CheckoutResult parsed = product->donation
? Form::ValidateDonation(*fields)
: Form::ValidateCheckout(*fields);
if (!parsed.Ok()) { if (!parsed.Ok()) {
return reject(parsed.errors, parsed.value, "422"); return reject(parsed.errors, parsed.value, "422");
} }
@ -640,57 +653,76 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
parsed.value, "429"); parsed.value, "429");
} }
// The variant: submitted slug against the catalogue, defaulting to the std::int64_t unitMinor = 0;
// cheapest (which is what the page advertises). A slug we never listed is Money::Totals totals;
// a 422, not a guess — a tampered value must not buy an unpriced colour. if (product->donation) {
const Variant* variant = nullptr; // THE amount, donation case: the validated buyer-named amount — the
if (!product->variants.empty()) { // one figure that legitimately arrives from the client, and
variant = parsed.value.color.empty() // ValidateDonation has already bounded it. Nothing ships and no VAT
? product->CheapestVariant() // is charged: a gift with nothing supplied in return is not a
: product->FindVariant(parsed.value.color); // taxable supply, so the whole shipping-and-VAT computation below
if (!variant) { // simply does not apply.
return reject({{ "color", "That is not one of the colours." }}, unitMinor = parsed.value.amountMinor;
parsed.value, "422"); totals.goods = unitMinor;
totals.total = unitMinor;
totals.shipping = 0;
totals.vatCharged = 0;
totals.vatIncluded = false;
} else {
// The variant: submitted slug against the catalogue, defaulting to the
// cheapest (which is what the page advertises). A slug we never listed
// is a 422, not a guess — a tampered value must not buy an unpriced
// colour.
const Variant* variant = nullptr;
if (!product->variants.empty()) {
variant = parsed.value.color.empty()
? product->CheapestVariant()
: product->FindVariant(parsed.value.color);
if (!variant) {
return reject({{ "color", "That is not one of the colours." }},
parsed.value, "422");
}
parsed.value.color = variant->slug;
} }
parsed.value.color = variant->slug; unitMinor = variant ? variant->priceInclMinor : product->priceInclMinor;
}
const std::int64_t unitMinor =
variant ? variant->priceInclMinor : product->priceInclMinor;
// THE amount. Computed here from the catalogue, the validated country and // THE amount. Computed here from the catalogue, the validated country
// the live shipping table; nothing about money ever arrives from the // and the live shipping table; nothing about money ever arrives from
// client. Shipping is per order, not per unit — one parcel — so the weight // the client. Shipping is per order, not per unit — one parcel — so
// that picks the carrier bracket is the whole order's. // the weight that picks the carrier bracket is the whole order's.
if (product->shipWeightGrams <= 0) { if (product->shipWeightGrams <= 0) {
// A catalogue bug, not a buyer problem: without a weight no bracket can // A catalogue bug, not a buyer problem: without a weight no
// be selected. Refuse rather than fall through to the cheapest rate, // bracket can be selected. Refuse rather than fall through to the
// and say so in the log where it can be fixed. // cheapest rate, and say so in the log where it can be fixed.
std::println(std::cerr, "checkout: product '{}' has no shipping weight", std::println(std::cerr, "checkout: product '{}' has no shipping weight",
product->slug); product->slug);
return reject({{ "", "Shipping for this product can't be priced right now — " return reject({{ "", "Shipping for this product can't be priced right now — "
"nothing was charged." }}, parsed.value, "503"); "nothing was charged." }}, parsed.value, "503");
} }
const std::int64_t parcelGrams = product->shipWeightGrams * parsed.value.quantity; const std::int64_t parcelGrams =
const std::optional<std::int64_t> shippingMinor = product->shipWeightGrams * parsed.value.quantity;
ShipCostFor(parsed.value.country, parcelGrams); const std::optional<std::int64_t> shippingMinor =
if (!shippingMinor) { ShipCostFor(parsed.value.country, parcelGrams);
// No rate covers this parcel, so there is no price to charge. Which of if (!shippingMinor) {
// the two refusals it is decides what the buyer can do about it: an // No rate covers this parcel, so there is no price to charge.
// uncovered country is ours to fix, a too-heavy parcel has a quantity // Which of the two refusals it is decides what the buyer can do
// that would work. The error hangs off the field the buyer would // about it: an uncovered country is ours to fix, a too-heavy
// change in each case. // parcel has a quantity that would work. The error hangs off the
const std::int64_t fits = // field the buyer would change in each case.
shipTable.MaxUnits(parsed.value.country, product->shipWeightGrams); const std::int64_t fits =
if (fits <= 0 && parsed.value.quantity == 1) { shipTable.MaxUnits(parsed.value.country, product->shipWeightGrams);
return reject({{ "country", Form::NoShippingMessage(parsed.value.country) }}, if (fits <= 0 && parsed.value.quantity == 1) {
return reject({{ "country",
Form::NoShippingMessage(parsed.value.country) }},
parsed.value, "422");
}
return reject({{ "quantity",
Form::TooHeavyMessage(parsed.value.country, fits) }},
parsed.value, "422"); parsed.value, "422");
} }
return reject({{ "quantity", totals = Money::ComputeTotals(
Form::TooHeavyMessage(parsed.value.country, fits) }}, unitMinor, parsed.value.quantity, *shippingMinor, parsed.value.country);
parsed.value, "422");
} }
const Money::Totals totals = Money::ComputeTotals(
unitMinor, parsed.value.quantity, *shippingMinor, parsed.value.country);
OrderRecord order; OrderRecord order;
order.token = NewOrderToken(); order.token = NewOrderToken();
@ -705,6 +737,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
order.shippingMinor = totals.shipping; order.shippingMinor = totals.shipping;
order.totalMinor = totals.total; order.totalMinor = totals.total;
order.vatIncluded = totals.vatIncluded; order.vatIncluded = totals.vatIncluded;
order.donation = product->donation;
// Normalised, not echoed: the record must name the rail that issued the // Normalised, not echoed: the record must name the rail that issued the
// link, and an empty submitted choice took the bank rail above. // link, and an empty submitted choice took the bank rail above.
order.payChoice = std::string(wantsCrypto ? Form::kPayCrypto : Form::kPayBank); order.payChoice = std::string(wantsCrypto ? Form::kPayCrypto : Form::kPayBank);
@ -805,8 +838,11 @@ std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order) {
if (paid->state == PayState::Paid) { if (paid->state == PayState::Paid) {
if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) { if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) {
// The invoice number exists from the moment the money does — // The invoice number exists from the moment the money does —
// sequential by payment order, which is what the bookkeeping wants. // sequential by payment order, which is what the bookkeeping
AssignInvoiceNumber(order.token, NowIso8601()); // wants. Never for a donation: no supply, no invoice, and a
// number burned on one would leave a gap-shaped question in a
// customer's series.
if (!order.donation) AssignInvoiceNumber(order.token, NowIso8601());
std::println(std::cerr, "order {} paid ({}, via {})", order.reference, std::println(std::cerr, "order {} paid ({}, via {})", order.reference,
Money::FormatMinor(order.totalMinor), Money::FormatMinor(order.totalMinor),
paid->method.empty() ? "?" : paid->method); paid->method.empty() ? "?" : paid->method);
@ -969,9 +1005,11 @@ void ReconcilerLoop(const std::stop_token& stop) {
bool SendConfirmationEmail(const OrderRecord& order) { bool SendConfirmationEmail(const OrderRecord& order) {
// The invoice rides along, so its number must exist. It normally does // The invoice rides along, so its number must exist. It normally does
// from the paid transition; an order paid before invoicing existed gets // from the paid transition; an order paid before invoicing existed gets
// its number here, exactly as the download route grants one. // its number here, exactly as the download route grants one. Donations
// skip all of it: no supply, no invoice — their confirmation is a
// thank-you with nothing attached.
OrderRecord o = order; OrderRecord o = order;
if (o.invoiceNumber.empty()) { if (!o.donation && o.invoiceNumber.empty()) {
if (!AssignInvoiceNumber(o.token, NowIso8601())) return false; if (!AssignInvoiceNumber(o.token, NowIso8601())) return false;
const auto reread = FindOrder(o.token); const auto reread = FindOrder(o.token);
if (!reread || reread->invoiceNumber.empty()) return false; if (!reread || reread->invoiceNumber.empty()) return false;
@ -987,18 +1025,22 @@ bool SendConfirmationEmail(const OrderRecord& order) {
// Same signature rule as the download: with a key configured, a signing // Same signature rule as the download: with a key configured, a signing
// failure means no email now (retry later), never an unsigned invoice. // failure means no email now (retry later), never an unsigned invoice.
std::string invoice = BuildInvoiceMarkdown(o, productName, colorLabel); std::string invoice;
if (InvoiceSigningConfigured()) { if (!o.donation) {
const auto signedText = ClearsignInvoice(invoice); invoice = BuildInvoiceMarkdown(o, productName, colorLabel);
if (!signedText) { if (InvoiceSigningConfigured()) {
std::println(std::cerr, "mail: invoice signing failed for {} — retrying later", const auto signedText = ClearsignInvoice(invoice);
o.reference); if (!signedText) {
return false; std::println(std::cerr,
"mail: invoice signing failed for {} — retrying later",
o.reference);
return false;
}
invoice = *signedText;
} else {
invoice = "UNSIGNED — development copy; production invoices are "
"GPG-clearsigned.\n\n" + invoice;
} }
invoice = *signedText;
} else {
invoice = "UNSIGNED — development copy; production invoices are "
"GPG-clearsigned.\n\n" + invoice;
} }
const std::string message = BuildOrderConfirmationEmail( const std::string message = BuildOrderConfirmationEmail(
@ -1043,6 +1085,13 @@ void MailerLoop(const std::stop_token& stop) {
for (const OrderRecord& order : ListOrders()) { for (const OrderRecord& order : ListOrders()) {
if (order.status != "paid" && order.status != "shipped") continue; if (order.status != "paid" && order.status != "shipped") continue;
// A donation without an email address asked for no confirmation:
// there is nothing to send and nobody to send it to, which is a
// settled state, not a retryable failure. Goods orders always
// have an address (checkout requires one), so an empty one there
// can only be a hand-edited ledger — skipping is still righter
// than retrying an unmailable message forever.
if (order.buyer.email.empty()) continue;
if (!order.confirmationSentAt.empty()) { if (!order.confirmationSentAt.empty()) {
attempts.erase(order.token); attempts.erase(order.token);
continue; continue;

View file

@ -82,14 +82,23 @@ std::string BuildInvoiceMarkdown(const OrderRecord& o,
md += "## Amounts\n\n"; md += "## Amounts\n\n";
md += "| Description | Qty | Amount |\n|---|---|---|\n"; md += "| Description | Qty | Amount |\n|---|---|---|\n";
if (o.vatIncluded) { if (o.vatIncluded) {
// EU supply: net amounts per line, VAT once over the taxable total — // EU supply: VAT once over the taxable total — the same rounding the
// the same line-total rounding the checkout charged with. // checkout charged with — so the subtotal and VAT rows are fixed.
// Only the goods line rounds on its own; shipping is printed as the
// REMAINDER of the subtotal, never a third independent rounding.
// Three half-up roundings need not sum (about a quarter of the real
// price grid lands a cent apart), and a signed tax document whose
// columns disagree with themselves invites exactly the scrutiny it
// exists to settle. Shipping absorbs the cent rather than the device
// because it is the ancillary line: worst case it prints one cent
// off the carrier's ex-VAT rate, a number no buyer sees elsewhere.
const std::int64_t net = Money::NetFromGross(o.totalMinor); const std::int64_t net = Money::NetFromGross(o.totalMinor);
const std::int64_t vat = o.totalMinor - net; const std::int64_t vat = o.totalMinor - net;
const std::int64_t goodsNet = Money::NetFromGross(o.goodsMinor);
md += std::format("| {} | {} | {} |\n", item, o.quantity, md += std::format("| {} | {} | {} |\n", item, o.quantity,
Money::FormatEuro(Money::NetFromGross(o.goodsMinor))); Money::FormatEuro(goodsNet));
md += std::format("| Shipping | 1 | {} |\n", md += std::format("| Shipping | 1 | {} |\n",
Money::FormatEuro(Money::NetFromGross(o.shippingMinor))); Money::FormatEuro(net - goodsNet));
md += std::format("| Subtotal (ex VAT) | | {} |\n", Money::FormatEuro(net)); md += std::format("| Subtotal (ex VAT) | | {} |\n", Money::FormatEuro(net));
md += std::format("| VAT 21% (NL) | | {} |\n", Money::FormatEuro(vat)); md += std::format("| VAT 21% (NL) | | {} |\n", Money::FormatEuro(vat));
md += std::format("| **Total (incl. VAT)** | | **{}** |\n", md += std::format("| **Total (incl. VAT)** | | **{}** |\n",

View file

@ -75,6 +75,38 @@ std::string BuildOrderConfirmationEmail(const OrderRecord& o,
// CR/LF would become an extra recipient, so the check repeats here. // CR/LF would become an extra recipient, so the check repeats here.
if (!Form::LooksLikeEmail(o.buyer.email)) return {}; if (!Form::LooksLikeEmail(o.buyer.email)) return {};
// A donation's confirmation is a different message: a thank-you, no
// invoice attached (none exists), no dispatch promise (nothing ships).
// Single-part plain text — multipart with one part is just noise.
if (o.donation) {
std::string m;
m.reserve(1024);
m += std::format("From: {}\n", from);
m += std::format("To: {}\n", o.buyer.email);
m += std::format("Subject: Catcrafts donation {} received\n", o.reference);
m += std::format("Date: {}\n", dateRfc2822);
m += std::format("Message-ID: <{}@{}>\n", o.token, kSellerSite);
m += "MIME-Version: 1.0\n";
m += "Content-Type: text/plain; charset=utf-8\n";
m += "Content-Transfer-Encoding: 8bit\n\n";
m += std::format("Thank you — your donation {} of {} has arrived.\n\n",
o.reference, Money::FormatEuro(o.totalMinor));
if (!o.paidVia.empty()) {
m += std::format("* Paid via: {}\n\n", o.paidVia);
}
m += "It funds the open-source work directly and will appear in the "
"running total on https://catcrafts.net/financials — as an "
"aggregate, never individually. No VAT applies to a donation and "
"no invoice is issued; this email and the donation page are the "
"receipt:\n\n";
m += std::format(" {}\n\n", orderUrl);
m += "Questions? Reply to this email.\n\n";
m += std::format("{} · {} · {}\n", kSellerName, kSellerStreet, kSellerCity);
m += std::format("KVK {} · VAT {} · https://{}\n", kSellerKvk, kSellerVat,
kSellerSite);
return m;
}
const std::string item = colorLabel.empty() const std::string item = colorLabel.empty()
? std::string(productName) ? std::string(productName)
: std::format("{} — {}", productName, colorLabel); : std::format("{} — {}", productName, colorLabel);

View file

@ -120,6 +120,9 @@ std::vector<OrderRecord> FoldLocked() {
r.shippingMinor = doc->Int("shipping_minor"); r.shippingMinor = doc->Int("shipping_minor");
r.totalMinor = doc->Int("total_minor"); r.totalMinor = doc->Int("total_minor");
r.vatIncluded = doc->Bool("vat_included"); r.vatIncluded = doc->Bool("vat_included");
// Additive key: absent (every pre-donations line) reads false,
// so an old ledger's orders all stay sales.
r.donation = doc->Bool("donation");
r.status = std::string(doc->Str("status", "awaiting_payment")); r.status = std::string(doc->Str("status", "awaiting_payment"));
// Read as written, with no default applied here: the ledger // Read as written, with no default applied here: the ledger
// should keep saying exactly what it recorded, and resolving an // should keep saying exactly what it recorded, and resolving an
@ -175,11 +178,14 @@ void SetOrdersPath(const std::filesystem::path& p) {
bool CreateOrder(const OrderRecord& o) { bool CreateOrder(const OrderRecord& o) {
std::lock_guard lock(gOrdersMutex); std::lock_guard lock(gOrdersMutex);
// "donation" is written only when true, matching the status writer's
// omit-rather-than-empty rule: absent-means-false is what lets a ledger
// from before donations existed keep reading correctly.
return AppendLine(std::format( return AppendLine(std::format(
R"({{"type":"order","at":"{}","id":"{}","ref":"{}","product":"{}",)" R"({{"type":"order","at":"{}","id":"{}","ref":"{}","product":"{}",)"
R"("color":"{}","quantity":{},"unit_minor":{},)" R"("color":"{}","quantity":{},"unit_minor":{},)"
R"("email":"{}","name":"{}","street":"{}","postal":"{}","city":"{}","country":"{}",)" R"("email":"{}","name":"{}","street":"{}","postal":"{}","city":"{}","country":"{}",)"
R"("goods_minor":{},"shipping_minor":{},"total_minor":{},"vat_included":{},)" R"("goods_minor":{},"shipping_minor":{},"total_minor":{},"vat_included":{},{})"
R"("status":"{}","pay_choice":"{}","pay_url":"{}","pay_id":"{}"}})", R"("status":"{}","pay_choice":"{}","pay_url":"{}","pay_id":"{}"}})",
JsonEscape(o.createdAt), JsonEscape(o.token), JsonEscape(o.reference), JsonEscape(o.createdAt), JsonEscape(o.token), JsonEscape(o.reference),
JsonEscape(o.product), JsonEscape(o.product),
@ -187,6 +193,7 @@ bool CreateOrder(const OrderRecord& o) {
JsonEscape(o.buyer.email), JsonEscape(o.buyer.name), JsonEscape(o.buyer.street), JsonEscape(o.buyer.email), JsonEscape(o.buyer.name), JsonEscape(o.buyer.street),
JsonEscape(o.buyer.postal), JsonEscape(o.buyer.city), JsonEscape(o.buyer.country), JsonEscape(o.buyer.postal), JsonEscape(o.buyer.city), JsonEscape(o.buyer.country),
o.goodsMinor, o.shippingMinor, o.totalMinor, o.vatIncluded, o.goodsMinor, o.shippingMinor, o.totalMinor, o.vatIncluded,
o.donation ? R"("donation":true,)" : "",
JsonEscape(o.status), JsonEscape(o.payChoice), JsonEscape(o.status), JsonEscape(o.payChoice),
JsonEscape(o.payUrl), JsonEscape(o.payId))); JsonEscape(o.payUrl), JsonEscape(o.payId)));
} }
@ -311,6 +318,13 @@ SalesSummary SummarizeSales(std::span<const OrderRecord> orders) {
// being written — the same paid-or-shipped idiom the invoice // being written — the same paid-or-shipped idiom the invoice
// download uses. // download uses.
if (r.paidAt.empty() && r.status != "paid" && r.status != "shipped") continue; if (r.paidAt.empty() && r.status != "paid" && r.status != "shipped") continue;
// A paid donation is income but not a sale: /financials shows it in
// the donations row, and counting it here too would double-book it.
if (r.donation) {
++out.donationCount;
out.donationsMinor += r.totalMinor;
continue;
}
++out.count; ++out.count;
out.totalMinor += r.totalMinor; out.totalMinor += r.totalMinor;
} }

View file

@ -64,6 +64,13 @@ export namespace Catcrafts::Server {
std::int64_t shippingMinor = 0; std::int64_t shippingMinor = 0;
std::int64_t totalMinor = 0; std::int64_t totalMinor = 0;
bool vatIncluded = false; bool vatIncluded = false;
// A donation: buyer-named amount, nothing ships, no VAT, and — the
// consequences downstream — no invoice number is ever assigned, the
// mailer sends a thank-you without an attachment (or nothing, when no
// email was given), and /financials counts it under donations rather
// than sales. Additive ledger key: absent reads false, so every order
// written before donations existed stays a sale.
bool donation = false;
std::string status = "awaiting_payment"; // -> paid -> shipped | cancelled std::string status = "awaiting_payment"; // -> paid -> shipped | cancelled
std::string payChoice; // Form::kPayBank | Form::kPayCrypto; which std::string payChoice; // Form::kPayBank | Form::kPayCrypto; which
// rail issued the link, and so which one // rail issued the link, and so which one
@ -118,11 +125,15 @@ export namespace Catcrafts::Server {
// //
// /financials shows lifetime sales as two integers: how many orders were // /financials shows lifetime sales as two integers: how many orders were
// ever paid, and what they summed to. Ever-paid on purpose — a refund is // ever paid, and what they summed to. Ever-paid on purpose — a refund is
// an expense on that page, it does not un-happen the sale. Pure and // an expense on that page, it does not un-happen the sale. Donations paid
// exported for the self-test. // through the shop land in the same ledger but are NOT sales — they fold
// into their own pair here and join the bank-side donations on the page.
// Pure and exported for the self-test.
struct SalesSummary { struct SalesSummary {
std::int64_t count = 0; std::int64_t count = 0;
std::int64_t totalMinor = 0; std::int64_t totalMinor = 0;
std::int64_t donationCount = 0;
std::int64_t donationsMinor = 0;
}; };
SalesSummary SummarizeSales(std::span<const OrderRecord> orders); SalesSummary SummarizeSales(std::span<const OrderRecord> orders);

View file

@ -23,18 +23,36 @@ No permission is granted to copy, modify, distribute, or create derivative works
// them at build time (fediverse fetch, ECB rates), and shell writes JSON, // them at build time (fediverse fetch, ECB rates), and shell writes JSON,
// not C++. // not C++.
// //
// PRICING RULE (the user's): retail = supplier price + markup, exactly. // PRICING RULE (the user's): retail = supplier price + markup, exactly, and
// Supplier prices are what the retailer currently charges (incl VAT); // the markup is what Catcrafts walks away with AFTER shipping and VAT — €50
// change one number when the supplier moves and the margin stays put. // net, with Mollie's transaction fee as the only accepted leak. Supplier
// prices are what the retailer currently charges (incl VAT); change one
// number when the supplier moves and the margin stays put.
export module Catcrafts.Shared:Content; export module Catcrafts.Shared:Content;
import std; import std;
import :Model; import :Model;
import :Money;
namespace Catcrafts::Content { namespace Catcrafts::Content {
// The flat markup on every variant — "whatever it costs me + 50". // The flat markup on every variant — "whatever it costs me + 50", where the
inline constexpr std::int64_t kMarkupMinor = 5000; // €50 is what Catcrafts KEEPS. Retail is VAT-inclusive, so a markup added to
// it is taxed with the rest of the price: a flat +5000 would net only €41.32
// once the VAT on it is remitted. Grossed up once here (€60.50 on the
// sticker), the walk-away margin is exactly €50 per unit — on EU sales and
// on exports alike, since the export price is the net of the same retail.
inline constexpr std::int64_t kMarkupNetMinor = 5000;
inline constexpr std::int64_t kMarkupMinor = Money::GrossFromNet(kMarkupNetMinor);
// Exactness is not luck: NetFromGross strips a gross amount additively only
// when markup × 10000 divides evenly by the VAT denominator, and 6050 does
// (6050 × 10000 = 12100 × 5000). That makes NetFromGross(supplier + markup)
// equal NetFromGross(supplier) + 5000 for EVERY supplier price — no variant
// can round the margin away.
static_assert(kMarkupMinor * 10000 % (10000 + Money::kVatRateBp) == 0,
"gross markup must net back exactly for every supplier price");
static_assert(kMarkupMinor * 10000 / (10000 + Money::kVatRateBp) == kMarkupNetMinor,
"the exact net of the markup must be the €50 the rule promises");
export const std::vector<Product>& Products() { export const std::vector<Product>& Products() {
static const std::vector<Product> products = [] { static const std::vector<Product> products = [] {
@ -89,7 +107,25 @@ export const std::vector<Product>& Products() {
if (const Variant* cheapest = p.CheapestVariant()) { if (const Variant* cheapest = p.CheapestVariant()) {
p.priceInclMinor = cheapest->priceInclMinor; p.priceInclMinor = cheapest->priceInclMinor;
} }
return std::vector<Product>{ std::move(p) };
// The donation item: the shop's soft opening. It is "available" while
// the phone above stays "coming-soon" on purpose — the two rails, the
// ledger, the reconciler and the mailer all run for real money before
// the first phone order depends on them. The buyer names the amount
// (Form::ValidateDonation bounds it); nothing ships and no VAT is
// charged, because a gift with nothing supplied in return is not a
// taxable supply — which is also why a donation never gets an invoice.
// fp6-pmos stays FIRST in this vector: it is the shop's headline, and
// tests address Products()[0] as the priced product.
Product d;
d.slug = "donation";
d.name = "Donation";
d.tagline = "Fund the open-source work directly: any amount, no goods, "
"no VAT.";
d.status = "available";
d.donation = true;
d.summary = "Thank you very much for considering to donate! Your donation will contribute to the maintenance of the image and R&D for new devices.";
return std::vector<Product>{ std::move(p), std::move(d) };
}(); }();
return products; return products;
} }
@ -206,12 +242,12 @@ export const LegalPage& FinancialsPage() {
static const LegalPage page{ static const LegalPage page{
.slug = "financials", .slug = "financials",
.title = "Financials", .title = "Financials",
.updated = "2026-08-14", .updated = "2026-08-17",
.lede = "Catcrafts believes in openness, that's why its financials are open as well. As a supporter you deserve to know where your money is going.", .lede = "Catcrafts believes in openness, that's why its financials are open as well. As a supporter you deserve to know where your money is going.",
.sections = { .sections = {
{ "How this page works", { "How this page works",
{ {
"Sales come straight from the shop's order ledger and update the moment an order is paid. Donations and expenses are aggregated from the business bank account by category and carry the date they were last brought up to date. Anything the categoriser does not recognise is held back until it has been classified, never published as a guess.", "Sales, and donations made through the shop, come straight from the shop's order ledger and update the moment they are paid. Donations to the bank account and expenses are aggregated from the business bank account by category and carry the date they were last brought up to date. Anything the categoriser does not recognise is held back until it has been classified, never published as a guess.",
"Everything is a running total in euros on a cash basis: money counts when it moves, not when an invoice says it should. Amounts include VAT where VAT was charged.", "Everything is a running total in euros on a cash basis: money counts when it moves, not when an invoice says it should. Amounts include VAT where VAT was charged.",
} }, } },
{ "What is never published", { "What is never published",
@ -291,7 +327,7 @@ export const std::vector<LegalPage>& LegalPages() {
{ {
.slug = "terms", .slug = "terms",
.title = "Terms", .title = "Terms",
.updated = "2026-08-15", .updated = "2026-08-17",
.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",
@ -302,6 +338,12 @@ export const std::vector<LegalPage>& LegalPages() {
"Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. Payment is accepted in EURC only, which is denominated in euros: the amount to send is exactly the euro price, with no exchange rate involved, and it must arrive in full on one of the networks the order page lists — a payment split across several networks cannot be accepted automatically. Every refund under the sections below is likewise owed in euros and is paid in EURC, to a wallet address you give at the time, since there is nothing to send it back to otherwise.", "Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. Payment is accepted in EURC only, which is denominated in euros: the amount to send is exactly the euro price, with no exchange rate involved, and it must arrive in full on one of the networks the order page lists — a payment split across several networks cannot be accepted automatically. Every refund under the sections below is likewise owed in euros and is paid in EURC, to a wallet address you give at the time, since there is nothing to send it back to otherwise.",
"For support related to orders please contact orders@catcrafts.net" "For support related to orders please contact orders@catcrafts.net"
} }, } },
{ "Donations",
{
"The shop also takes donations: you name the amount, and the same payment methods apply — Mollie for bank and card, EURC for cryptocurrency. A donation is a gift that funds the open-source work; nothing is supplied in return, so no VAT is charged and no invoice is issued. The order page for a donation is its receipt.",
"Because nothing ships, no name or address is asked for. An email address is optional and is used only to send the confirmation. Donations appear on the financials page as an aggregate running total, never individually.",
"A donation is not a purchase, so the returns section below does not apply to it. A mistaken donation — a typo in the amount, a double payment — is refunded on request: email info@catcrafts.net.",
} },
{ "Fulfilment", { "Fulfilment",
{ {
"Devices are sourced, flashed and tested to order. There is no warehouse. Allow up to a week between payment and dispatch; the order page and email updates track it. If sourcing falls through, you get the money back, promptly and in full.", "Devices are sourced, flashed and tested to order. There is no warehouse. Allow up to a week between payment and dispatch; the order page and email updates track it. If sourcing falls through, you get the money back, promptly and in full.",

View file

@ -193,6 +193,12 @@ export struct Checkout {
std::string color; // variant slug; whether it EXISTS is the handler's std::string color; // variant slug; whether it EXISTS is the handler's
// check against the catalogue, not a shape check // check against the catalogue, not a shape check
std::int64_t quantity = 1; std::int64_t quantity = 1;
// The donation amount in cents, set only by ValidateDonation. This is the
// ONE amount that ever arrives from the client — a donation has no
// catalogue price to compute from — and it is bounded here and re-derived
// nowhere, so the handler charges exactly what was validated. Zero for
// every goods checkout, where money still never comes from the client.
std::int64_t amountMinor = 0;
std::string payChoice; // kPayBank | kPayCrypto; empty means the form did std::string payChoice; // kPayBank | kPayCrypto; empty means the form did
// not offer a choice, which the handler reads as // not offer a choice, which the handler reads as
// bank. Whether the chosen rail is CONFIGURED is // bank. Whether the chosen rail is CONFIGURED is
@ -414,4 +420,100 @@ export CheckoutResult ValidateCheckout(const Fields& f) {
return r; return r;
} }
// ── donations ─────────────────────────────────────────────────────────
// The bounds on a donation, in cents. The floor keeps the amount above the
// payment rails' own minimums and the fees that would eat a smaller gift; the
// ceiling is an anti-fat-finger and anti-abuse bound — anyone genuinely
// wanting to give more is an email conversation, not a form post.
export inline constexpr std::int64_t kMinDonationMinor = 100; // €1
export inline constexpr std::int64_t kMaxDonationMinor = 1'000'000; // €10,000
// Exact decimal-euros-to-cents parsing: "25" -> 2500, "12.50" -> 1250, and a
// comma decimal mark is accepted because half the donors here will type one.
// Anything else — sign, exponent, a third decimal, stray text — is nullopt
// rather than a guess. Integer arithmetic throughout; like every money path
// in this codebase, no float ever touches the amount.
export std::optional<std::int64_t> ParseEuroAmountToMinor(std::string_view s) {
if (s.empty() || s.size() > 10) return std::nullopt;
std::size_t mark = std::string_view::npos;
for (std::size_t i = 0; i < s.size(); ++i) {
if (s[i] == '.' || s[i] == ',') {
if (mark != std::string_view::npos) return std::nullopt;
mark = i;
} else if (s[i] < '0' || s[i] > '9') {
return std::nullopt;
}
}
const std::string_view whole = s.substr(0, mark);
const std::string_view frac =
mark == std::string_view::npos ? std::string_view{} : s.substr(mark + 1);
if (whole.empty() || frac.size() > 2) return std::nullopt;
std::int64_t euros = 0;
auto [p, ec] = std::from_chars(whole.data(), whole.data() + whole.size(), euros);
if (ec != std::errc{} || p != whole.data() + whole.size()) return std::nullopt;
std::int64_t cents = 0;
if (!frac.empty()) {
auto [fp, fec] = std::from_chars(frac.data(), frac.data() + frac.size(), cents);
if (fec != std::errc{} || fp != frac.data() + frac.size()) return std::nullopt;
if (frac.size() == 1) cents *= 10; // "2.5" is €2.50, not €2.05
}
return euros * 100 + cents;
}
// Validate a submitted donation. Deliberately NOT ValidateCheckout with
// fields waived: a donation ships nothing, so no name or address is even
// asked for — collecting them would break the privacy notice's "what
// fulfilling it requires" rule, not just pad the form.
//
// Email is OPTIONAL, the one shape difference worth a comment: the order
// page's capability URL is already the receipt, so identity is only needed
// if the donor wants the confirmation emailed. An empty email means no email,
// never an error.
export CheckoutResult ValidateDonation(const Fields& f) {
CheckoutResult r;
r.value.quantity = 1; // a donation is one line, always
// The same honeypot as checkout, reported just as namelessly.
if (!Trim(f.Get("website")).empty()) {
r.errors.push_back({ "", "Submission rejected." });
return r;
}
const std::string_view email = Trim(f.Get("email"));
r.value.email = std::string(email);
if (!email.empty() && !LooksLikeEmail(email)) {
r.errors.push_back({ "email", "That doesn't look like an email address." });
}
// The amount: present, parseable, in bounds. Out of range is rejected
// rather than clamped — silently moving someone's gift is worse than
// asking again, same rule as checkout's quantity.
const std::string_view amount = Trim(f.Get("amount"));
if (amount.empty()) {
r.errors.push_back({ "amount", "Name an amount — any euro amount you like." });
} else if (const auto minor = ParseEuroAmountToMinor(amount); !minor) {
r.errors.push_back({ "amount", "That doesn't look like a euro amount." });
} else if (*minor < kMinDonationMinor || *minor > kMaxDonationMinor) {
r.errors.push_back({ "amount",
std::format("Donations are accepted from {} to {} — for more, "
"email info@catcrafts.net.",
Money::FormatEuro(kMinDonationMinor),
Money::FormatEuro(kMaxDonationMinor)) });
} else {
r.value.amountMinor = *minor;
}
// The payment choice, exactly as checkout reads it: absent means the form
// offered no choice and the handler takes the bank rail; an unrecognised
// word is a tampered post or a drifted form, and both are refused.
const std::string_view pay = Trim(f.Get("pay"));
r.value.payChoice = std::string(pay);
if (!pay.empty() && pay != kPayBank && pay != kPayCrypto) {
r.errors.push_back({ "pay", "Pick one of the payment methods." });
}
return r;
}
} // namespace Catcrafts::Form } // namespace Catcrafts::Form

View file

@ -200,6 +200,13 @@ export struct Product {
// gap, price swing). In both closed states the page stays up, the buy // gap, price swing). In both closed states the page stays up, the buy
// form does not, and the checkout POST is refused server-side. // form does not, and the checkout POST is refused server-side.
std::string status; std::string status;
// A donation rather than goods: the buyer names the amount, nothing ships,
// and no VAT is charged (a gift with nothing supplied in return is not a
// taxable supply). This flag is what routes a checkout POST through the
// donation validator instead of the address-and-shipping one, so setting
// it on a priced product would let orders skip shipping — it belongs on
// exactly one kind of catalogue entry.
bool donation = false;
// The EU consumer price, VAT-inclusive, in cents. With variants present // The EU consumer price, VAT-inclusive, in cents. With variants present
// this is the FROM price (cheapest variant) and is kept in sync by the // this is the FROM price (cheapest variant) and is kept in sync by the
// loader; without variants it is simply the price. // loader; without variants it is simply the price.
@ -226,7 +233,11 @@ export struct Product {
std::string safetyNote; std::string safetyNote;
std::vector<Spec> specs; std::vector<Spec> specs;
bool Buyable() const { return status == "available" && priceInclMinor > 0; } // A donation has no fixed price by definition, so the price check applies
// only to goods — where a zero price is a content bug that must not sell.
bool Buyable() const {
return status == "available" && (donation || priceInclMinor > 0);
}
bool ComingSoon() const { return status == "coming-soon"; } bool ComingSoon() const { return status == "coming-soon"; }
// nullptr for a colour we never listed — the checkout rejects rather than // nullptr for a colour we never listed — the checkout rejects rather than
@ -381,6 +392,10 @@ export struct OrderView {
std::int64_t shippingMinor = 0; std::int64_t shippingMinor = 0;
std::int64_t totalMinor = 0; std::int64_t totalMinor = 0;
bool vatIncluded = false; bool vatIncluded = false;
// A donation order: one amount, nothing ships, no VAT, no invoice. The
// renderer folds the money table to a single row and swaps the paid-state
// copy from "your device ships" to a thank-you.
bool donation = false;
// Present only for an awaiting order on a rail without a hosted checkout; // Present only for an awaiting order on a rail without a hosted checkout;
// the page then renders instructions instead of a "resume payment" button. // the page then renders instructions instead of a "resume payment" button.
std::optional<OrderCryptoPay> cryptoPay; std::optional<OrderCryptoPay> cryptoPay;

View file

@ -610,7 +610,11 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
R"(</article>)", R"(</article>)",
thumb, thumb,
Url("href", "/shop/" + p.slug), Escape(p.name), Escape(p.tagline), Url("href", "/shop/" + p.slug), Escape(p.name), Escape(p.tagline),
p.Buyable() ? RenderCardPrice(p, rates) // A donation has no price to quote — the card says so instead of
// rendering a €0 that the checkout would never charge.
p.donation && p.Buyable()
? Raw(R"(<p class="price price--card">any amount</p>)")
: p.Buyable() ? RenderCardPrice(p, rates)
: p.ComingSoon() : p.ComingSoon()
? Format(R"({}<p class="product-card__status"><span class="badge badge--experiment">coming soon</span></p>)", ? Format(R"({}<p class="product-card__status"><span class="badge badge--experiment">coming soon</span></p>)",
RenderCardPrice(p, rates)) RenderCardPrice(p, rates))
@ -650,7 +654,8 @@ export RenderedPage RenderShop(std::span<const Product> products, const Rates& r
R"(<h1 class="page-header__title">Shop</h1>)" R"(<h1 class="page-header__title">Shop</h1>)"
R"(<p class="page-header__lede">Hardware that runs the software from the )" R"(<p class="page-header__lede">Hardware that runs the software from the )"
R"(projects page. Assembled to order and flashed. Please allow up to a )" R"(projects page. Assembled to order and flashed. Please allow up to a )"
R"(week before dispatch. Payment is handled by Mollie.)" R"(week before dispatch. Or fund the work directly: the donation item )"
R"(takes any amount.)"
R"(</header>)" R"(</header>)"
R"(<div class="product-grid">{}</div>)", R"(<div class="product-grid">{}</div>)",
cards.empty() ? Raw(R"(<p class="empty">No products listed.</p>)") : Join(cards)); cards.empty() ? Raw(R"(<p class="empty">No products listed.</p>)") : Join(cards));
@ -674,6 +679,40 @@ SafeHtml CustomsNote() {
R"(estimate them bindingly, and is not a party to them.</p>)"); R"(estimate them bindingly, and is not a party to them.</p>)");
} }
// The payment choice, shared by the checkout form and the donation form so
// the two can never describe the same rails differently. A radio group rather
// than a <select> because both options carry a sentence the buyer should read
// BEFORE choosing — one settles in euro from their bank, the other locks a
// euro price against a coin — and a collapsed dropdown hides exactly that. It
// also needs no JavaScript, like everything else in these forms.
//
// Bank is pre-selected: it is what nearly every buyer wants, and an
// unselected group would let a distracted submit land on neither.
SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError) {
const bool wantsCrypto = prev.payChoice == Form::kPayCrypto;
return Format(
R"(<fieldset class="field field--pay">)"
R"(<legend>How you want to pay</legend>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Bank or card</strong> &mdash; iDEAL, card, or a plain )"
R"(bank transfer. Handled by Mollie.</span></label>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Cryptocurrency</strong> &mdash; EURC, a euro )"
R"(stablecoin, paid from your own wallet. The amount to send is the )"
R"(euro total exactly, no exchange rate; the receiving address and )"
R"(the networks it takes appear on the order page, and stay reserved )"
R"(for about a day.</span></label>)"
R"({})"
R"(</fieldset>)",
Attr("value", std::string(Form::kPayBank)),
wantsCrypto ? SafeHtml{} : Raw(" checked"),
Attr("value", std::string(Form::kPayCrypto)),
wantsCrypto ? Raw(" checked") : SafeHtml{},
payError);
}
// The checkout form. // The checkout form.
// //
// A real <form method="post">, not a JavaScript submit handler. It works with // A real <form method="post">, not a JavaScript submit handler. It works with
@ -790,39 +829,8 @@ SafeHtml RenderCheckoutForm(const Product& product,
} }
cc += std::format(R"(],"sm":{}}})", JsonStr(Form::kSanctionsMessage)); cc += std::format(R"(],"sm":{}}})", JsonStr(Form::kSanctionsMessage));
// The payment choice. A radio group rather than a <select> because both const SafeHtml payFieldset =
// options carry a sentence the buyer should read BEFORE choosing — one offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{};
// settles in euro from their bank, the other locks a euro price against a
// coin — and a collapsed dropdown hides exactly that. It also needs no
// JavaScript, like everything else in this form.
//
// Bank is pre-selected: it is what nearly every buyer wants, and an
// unselected group would let a distracted submit land on neither.
SafeHtml payFieldset;
if (offerCrypto) {
const bool wantsCrypto = prev.payChoice == Form::kPayCrypto;
payFieldset = Format(
R"(<fieldset class="field field--pay">)"
R"(<legend>How you want to pay</legend>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Bank or card</strong> &mdash; iDEAL, card, or a plain )"
R"(bank transfer. Handled by Mollie.</span></label>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Cryptocurrency</strong> &mdash; EURC, a euro )"
R"(stablecoin, paid from your own wallet. The amount to send is the )"
R"(euro total exactly, no exchange rate; the receiving address and )"
R"(the networks it takes appear on the order page, and stay reserved )"
R"(for about a day.</span></label>)"
R"({})"
R"(</fieldset>)",
Attr("value", std::string(Form::kPayBank)),
wantsCrypto ? SafeHtml{} : Raw(" checked"),
Attr("value", std::string(Form::kPayCrypto)),
wantsCrypto ? Raw(" checked") : SafeHtml{},
errorFor("pay"));
}
return Format( return Format(
R"(<section class="checkout" id="buy">)" R"(<section class="checkout" id="buy">)"
@ -931,6 +939,76 @@ SafeHtml RenderCheckoutForm(const Product& product,
payFieldset); payFieldset);
} }
// The donation form: the checkout form's small sibling. An amount instead of
// a price, an OPTIONAL email instead of a shipping address — nothing ships,
// so nothing more is asked for (the privacy notice's "what fulfilling it
// requires" rule, applied to a gift). Same POST target, same honeypot, same
// payment fieldset, same no-JavaScript guarantee.
SafeHtml RenderDonationForm(const Product& product,
std::span<const Form::FieldError> errors,
const Form::Checkout& prev,
bool offerCrypto) {
auto errorFor = [&](std::string_view field) -> SafeHtml {
for (const Form::FieldError& e : errors) {
if (e.field == field) {
return Format(R"(<p class="field__error">{}</p>)", Escape(e.message));
}
}
return SafeHtml{};
};
SafeHtml formError;
for (const Form::FieldError& e : errors) {
if (e.field.empty()) {
formError = Format(R"(<p class="notice notice--error">{}</p>)", Escape(e.message));
break;
}
}
return Format(
R"(<section class="checkout" id="buy">)"
R"(<h2 class="section__title">Donate</h2>)"
R"(<p class="checkout__lede">Pick any amount{}. Submitting creates the )"
R"(donation and takes you straight to the payment page. Nothing is owed )"
R"(until you actually pay; an unpaid donation just lapses.</p>)"
R"(<p class="checkout__shipnote">No VAT is charged on a donation and no )"
R"(invoice is issued &mdash; the donation page is its receipt. Donations )"
R"(appear on the financials page as an aggregate total, never )"
R"(individually.</p>)"
R"({})"
R"(<form class="form" method="post"{} novalidate>)"
R"(<div class="field">)"
R"(<label for="f-amount">Amount in euros <span class="field__req">required</span></label>)"
R"(<input id="f-amount" name="amount" type="number" inputmode="decimal" )"
R"(min="1" max="10000" step="0.01" required{}>)"
R"({})"
R"(</div>)"
R"(<div class="field">)"
R"(<label for="f-email">Email</label>)"
R"(<input id="f-email" name="email" type="email" autocomplete="email"{}>)"
R"(<p class="field__hint">Optional &mdash; only used to send the )"
R"(confirmation. Leave it empty and the donation page is your receipt.</p>)"
R"({})"
R"(</div>)"
R"({})"
R"(<div class="honeypot" aria-hidden="true">)"
R"(<label for="f-website">Leave this empty</label>)"
R"(<input id="f-website" name="website" type="text" tabindex="-1" autocomplete="off">)"
R"(</div>)"
R"(<button class="btn btn--primary" type="submit">Donate &mdash; continue to payment</button>)"
R"(</form>)"
R"(</section>)",
offerCrypto ? SafeHtml{}
: Raw(", paid by iDEAL, card, or a plain bank transfer, "
"handled by Mollie"),
formError,
Url("action", "/shop/" + product.slug + "#buy"),
prev.amountMinor > 0
? Attr("value", Money::FormatMinor(prev.amountMinor)) : SafeHtml{},
errorFor("amount"),
Attr("value", prev.email), errorFor("email"),
offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{});
}
// `offerCrypto` reaches the checkout form; see RenderCheckoutForm for why it // `offerCrypto` reaches the checkout form; see RenderCheckoutForm for why it
// defaults to false. Only the native server passes it true, because only the // defaults to false. Only the native server passes it true, because only the
// server knows whether the crypto rail is configured. // server knows whether the crypto rail is configured.
@ -952,7 +1030,9 @@ export RenderedPage RenderProduct(const Product& product,
page.meta.canonical = "/shop/" + product.slug; page.meta.canonical = "/shop/" + product.slug;
page.meta.ogType = "product"; page.meta.ogType = "product";
page.meta.ogImage = product.image; page.meta.ogImage = product.image;
page.meta.geoPriceHint = true; // The donation page shows no converted prices — there is no price — so it
// ships no price-hint script either, keeping it entirely script-free.
page.meta.geoPriceHint = !product.donation;
// The commercial record: a ProductGroup with one variant Product per // The commercial record: a ProductGroup with one variant Product per
// colour, each carrying its ONE offer, prices from the same integers the // colour, each carrying its ONE offer, prices from the same integers the
@ -964,6 +1044,10 @@ export RenderedPage RenderProduct(const Product& product,
// the status field, so launch day flips PreOrder to InStock with no edit // the status field, so launch day flips PreOrder to InStock with no edit
// here. // here.
// //
// A donation emits none of it: it has no price, no shipping and no return
// policy, and a Product record whose offer names no amount is a claim
// shopping crawlers can only misread.
//
// Merchant-grade: each offer also carries shippingDetails and a return // Merchant-grade: each offer also carries shippingDetails and a return
// policy, which is what Google Merchant Center's website-crawl feed needs // policy, which is what Google Merchant Center's website-crawl feed needs
// to list the product without a CSV in sight — productGroupID is what it // to list the product without a CSV in sight — productGroupID is what it
@ -971,7 +1055,7 @@ export RenderedPage RenderProduct(const Product& product,
// at single-unit weight, the same integers checkout charges, so the listing // at single-unit weight, the same integers checkout charges, so the listing
// and the till cannot disagree; a destination with no carrier rate is // and the till cannot disagree; a destination with no carrier rate is
// simply not advertised, because it is not for sale. // simply not advertised, because it is not for sale.
{ if (!product.donation) {
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug; const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
std::string_view availability = std::string_view availability =
product.Buyable() ? "https://schema.org/InStock" product.Buyable() ? "https://schema.org/InStock"
@ -1154,7 +1238,9 @@ export RenderedPage RenderProduct(const Product& product,
Escape(product.safetyNote)); Escape(product.safetyNote));
SafeHtml buy; SafeHtml buy;
if (product.Buyable()) { if (product.donation && product.Buyable()) {
buy = RenderDonationForm(product, errors, prev, offerCrypto);
} else if (product.Buyable()) {
buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto); buy = RenderCheckoutForm(product, liveShipping, errors, prev, offerCrypto);
} else if (product.ComingSoon()) { } else if (product.ComingSoon()) {
// The launch prices are already public, per colour, with the same // The launch prices are already public, per colour, with the same
@ -1181,6 +1267,26 @@ export RenderedPage RenderProduct(const Product& product,
R"(is in flux. Check back, or watch the posts.</p></section>)"); R"(is in flux. Check back, or watch the posts.</p></section>)");
} }
// The spec and warranty sections exist only where the content does: a
// donation has neither a spec sheet nor a warranty, and an empty table
// under a Fairphone-specific lede would be nonsense on its page.
const SafeHtml specsSection = product.specs.empty() ? SafeHtml{} : Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Specifications</h2>)"
R"(<p class="section__lede">The hardware is a stock Fairphone )"
R"((Gen. 6), unmodified. Fairphone's spec sheet is this product's spec sheet, )"
R"(and all of it works under postmarketOS. The one caveat is the )"
R"(emergency-calling warning above.</p>)"
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
R"(</section>)",
Join(specRows));
const SafeHtml warrantySection = product.warranty.empty() ? SafeHtml{} : Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Warranty</h2>)"
R"(<p>{}</p>)"
R"(</section>)",
Escape(product.warranty));
page.main = Format( page.main = Format(
R"(<header class="page-header">)" R"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)" R"(<h1 class="page-header__title">{}</h1>)"
@ -1190,23 +1296,15 @@ export RenderedPage RenderProduct(const Product& product,
R"({})" R"({})"
R"(<p class="product__summary">{}</p>)" R"(<p class="product__summary">{}</p>)"
R"({})" R"({})"
R"(<section class="section">)" R"({})"
R"(<h2 class="section__title">Specifications</h2>)" R"({})"
R"(<p class="section__lede">The hardware is a stock Fairphone )"
R"((Gen. 6), unmodified. Fairphone's spec sheet is this product's spec sheet, )"
R"(and all of it works under postmarketOS. The one caveat is the )"
R"(emergency-calling warning above.</p>)"
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
R"(</section>)"
R"(<section class="section">)"
R"(<h2 class="section__title">Warranty</h2>)"
R"(<p>{}</p>)"
R"(</section>)"
R"({})", R"({})",
Escape(product.name), Escape(product.tagline), Escape(product.name), Escape(product.tagline),
media, RenderPriceLine(product, rates), Escape(product.summary), media,
safety, Join(specRows), product.donation ? SafeHtml{} : RenderPriceLine(product, rates),
Escape(product.warranty), Escape(product.summary),
safety, specsSection,
warrantySection,
buy); buy);
return page; return page;
} }
@ -1346,15 +1444,25 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
Escape(o.reference), Escape(o.reference),
Raw("A payment left uncompleted simply lapses the order.")); Raw("A payment left uncompleted simply lapses the order."));
} else if (o.status == "paid") { } else if (o.status == "paid") {
payBlock = Format( // A donation ships nothing and gets no invoice — a gift with nothing
R"(<section class="section"><h2 class="section__title">What happens now</h2>)" // supplied in return is not a taxable supply — so its paid state is a
R"(<p>The device is ordered, flashed and tested, then shipped. Allow up )" // thank-you, not a dispatch promise with a download button.
R"(to a week before dispatch. Updates land in your email.</p>)" payBlock = o.donation
R"(<p><a class="btn btn--primary"{} download>Download invoice (.md)</a></p>)" ? Raw(R"(<section class="section"><h2 class="section__title">Thank you</h2>)"
R"(<p class="order__note">GPG-clearsigned markdown. It verifies with )" R"(<p>Your donation funds the open-source work directly. It will )"
R"(gpg --verify, independent of this site.</p>)" R"(appear in the running total on the financials page &mdash; as )"
R"(</section>)", R"(an aggregate, never individually. This page is your receipt; )"
Url("href", "/order/" + o.token + "/invoice.md")); R"(no invoice is issued for a donation.</p>)"
R"(</section>)")
: Format(
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
R"(<p>The device is ordered, flashed and tested, then shipped. Allow up )"
R"(to a week before dispatch. Updates land in your email.</p>)"
R"(<p><a class="btn btn--primary"{} download>Download invoice (.md)</a></p>)"
R"(<p class="order__note">GPG-clearsigned markdown. It verifies with )"
R"(gpg --verify, independent of this site.</p>)"
R"(</section>)",
Url("href", "/order/" + o.token + "/invoice.md"));
} }
// The paid state IS the success page — say so before the receipt table. // The paid state IS the success page — say so before the receipt table.
@ -1363,6 +1471,18 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
R"(confirmed. This page is your receipt.</p>)") R"(confirmed. This page is your receipt.</p>)")
: SafeHtml{}; : SafeHtml{};
// A donation's money is one line — an amount with nothing shipped adds no
// shipping row and needs no separate total. Goods orders keep the full
// breakdown.
const SafeHtml moneyRows = o.donation
? MoneyRow("Donation", o.totalMinor)
: Format(R"({}{}{})",
MoneyRow(o.quantity > 1
? std::format("Device × {}", o.quantity)
: std::string("Device"), o.goodsMinor),
MoneyRow("Shipping", o.shippingMinor),
MoneyRow("Total", o.totalMinor));
page.main = Format( page.main = Format(
R"(<header class="page-header">)" R"(<header class="page-header">)"
R"(<h1 class="page-header__title">Order {}</h1>)" R"(<h1 class="page-header__title">Order {}</h1>)"
@ -1374,14 +1494,12 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
R"(<h2 class="section__title">Total</h2>)" R"(<h2 class="section__title">Total</h2>)"
R"(<table class="spec-table"><tbody>)" R"(<table class="spec-table"><tbody>)"
R"({})" R"({})"
R"({})"
R"({})"
R"(</tbody></table>)" R"(</tbody></table>)"
R"(<p class="order__vat">{}</p>)" R"(<p class="order__vat">{}</p>)"
R"(</section>)" R"(</section>)"
R"({})" R"({})"
R"(<p class="order__keep">There is no account; this link is the access. )" R"(<p class="order__keep">There is no account; this link is the access. )"
R"(Download the invoice and keep it. This page is not archived )" R"({} This page is not archived )"
R"(forever.</p>)", R"(forever.</p>)",
Escape(o.reference), Escape(o.reference),
Escape(o.colorLabel.empty() Escape(o.colorLabel.empty()
@ -1390,19 +1508,22 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
Escape(o.createdAt), Escape(o.createdAt),
statusLine, statusLine,
confirmation, confirmation,
MoneyRow(o.quantity > 1 moneyRows,
? std::format("Device × {}", o.quantity) o.donation
: std::string("Device"), o.goodsMinor), // The user's rule, stated plainly: 0% — a donation is a gift, not
MoneyRow("Shipping", o.shippingMinor), // a supply, so no VAT arises and neither export wording applies.
MoneyRow("Total", o.totalMinor), ? Raw("VAT 0%: no VAT is charged on a donation — nothing is "
o.vatIncluded "supplied in return.")
? Raw("Includes 21% Dutch VAT.") : o.vatIncluded
: Raw("Zero-rated export: no EU VAT charged. Import duty, import " ? Raw("Includes 21% Dutch VAT.")
"VAT, tariffs and any carrier handling fee are charged on arrival " : Raw("Zero-rated export: no EU VAT charged. Import duty, import "
"and are solely between you, the courier and your customs " "VAT, tariffs and any carrier handling fee are charged on arrival "
"authority; Catcrafts does not collect them and is not a party " "and are solely between you, the courier and your customs "
"to them."), "authority; Catcrafts does not collect them and is not a party "
payBlock); "to them."),
payBlock,
o.donation ? Raw("Keep it if you want the receipt.")
: Raw("Download the invoice and keep it."));
// A plain <meta refresh> is the no-JavaScript way to make the page track // A plain <meta refresh> is the no-JavaScript way to make the page track
// the payment: the browser refetches, the server re-reads the order. Only // the payment: the browser refetches, the server re-reads the order. Only
// while awaiting — a paid page has nothing to poll for. // while awaiting — a paid page has nothing to poll for.
@ -1528,9 +1649,18 @@ export RenderedPage RenderAbout(const LegalPage& about) {
// The data-fin-* attributes are the machine-readable copy of the figures — // The data-fin-* attributes are the machine-readable copy of the figures —
// what the e2e suite asserts against, and what anyone scraping the page in // what the e2e suite asserts against, and what anyone scraping the page in
// good faith should read instead of parsing euro signs. // good faith should read instead of parsing euro signs.
//
// Donations arrive from TWO ledgers: the bank aggregates in `fin`, and the
// shop's own order ledger (`shopDonationCount`/`shopDonationsMinor`) — the
// donation item is paid through the same rails as a sale, so its money never
// touches the bank categoriser. The page shows one Donations row summing
// both; splitting them by collection channel would be bookkeeping trivia the
// reader has no use for.
export RenderedPage RenderFinancials(std::int64_t salesCount, export RenderedPage RenderFinancials(std::int64_t salesCount,
std::int64_t salesTotalMinor, std::int64_t salesTotalMinor,
const Financials& fin) { const Financials& fin,
std::int64_t shopDonationCount = 0,
std::int64_t shopDonationsMinor = 0) {
const LegalPage& notes = Content::FinancialsPage(); const LegalPage& notes = Content::FinancialsPage();
// A total row is ruled off from the rows it sums, the way a ledger is. // A total row is ruled off from the rows it sums, the way a ledger is.
@ -1540,20 +1670,25 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
Escape(label), Escape(Money::FormatEuro(minor))); Escape(label), Escape(Money::FormatEuro(minor)));
}; };
// Income. Sales are always live; the donation row exists only once the // Income. Sales are always live; the donation row exists once EITHER
// bank figures do — a €0 the page cannot yet know would be a lie, and so // source has figures — the bank aggregates, or a donation paid through
// would an income total missing half its inputs. // the shop (live from the order ledger, like sales). Before both, a €0
// the page cannot yet know would be a lie, and so would an income total
// missing half its inputs.
const std::int64_t donationCount = fin.donationCount + shopDonationCount;
const std::int64_t donationsMinor = fin.donationsMinor + shopDonationsMinor;
const bool showDonations = fin.Loaded() || shopDonationCount > 0;
std::vector<SafeHtml> incomeRows; std::vector<SafeHtml> incomeRows;
if (fin.Loaded()) { if (showDonations) {
incomeRows.push_back(MoneyRow( incomeRows.push_back(MoneyRow(
std::format("Donations ({})", fin.donationCount), std::format("Donations ({})", donationCount),
fin.donationsMinor)); donationsMinor));
} }
incomeRows.push_back(MoneyRow( incomeRows.push_back(MoneyRow(
std::format("Sales ({})", salesCount), std::format("Sales ({})", salesCount),
salesTotalMinor)); salesTotalMinor));
if (fin.Loaded()) { if (fin.Loaded()) {
incomeRows.push_back(totalRow("Income", fin.donationsMinor + salesTotalMinor)); incomeRows.push_back(totalRow("Income", donationsMinor + salesTotalMinor));
} }
// Expenses: one flat table. No recurring/one-off grouping — see the note // Expenses: one flat table. No recurring/one-off grouping — see the note
@ -1583,7 +1718,7 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
// support. FormatEuro renders a negative as "€-12.34", which is the // support. FormatEuro renders a negative as "€-12.34", which is the
// honest thing to show in a month that bought inventory. // honest thing to show in a month that bought inventory.
const std::int64_t netMinor = const std::int64_t netMinor =
fin.donationsMinor + salesTotalMinor - fin.ExpensesMinor(); donationsMinor + salesTotalMinor - fin.ExpensesMinor();
SafeHtml netBlock; SafeHtml netBlock;
if (fin.Loaded()) { if (fin.Loaded()) {
netBlock = Format( netBlock = Format(
@ -1601,10 +1736,12 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
// The freshness line keeps the page honest about its two cadences. // The freshness line keeps the page honest about its two cadences.
const SafeHtml freshness = fin.Loaded() const SafeHtml freshness = fin.Loaded()
? Format(R"(<p class="legal__updated">Sales are live from the order ledger &middot; )" ? Format(R"(<p class="legal__updated">Sales and shop donations are live )"
R"(from the order ledger &middot; )"
R"(bank figures as of <time{}>{}</time></p>)", R"(bank figures as of <time{}>{}</time></p>)",
Attr("datetime", fin.asOf), Escape(fin.asOf)) Attr("datetime", fin.asOf), Escape(fin.asOf))
: Raw(R"(<p class="legal__updated">Sales are live from the order ledger</p>)"); : Raw(R"(<p class="legal__updated">Sales and shop donations are live )"
R"(from the order ledger</p>)");
// The methodology prose, in the legal pages' section shape and CSS. // The methodology prose, in the legal pages' section shape and CSS.
std::vector<SafeHtml> sections; std::vector<SafeHtml> sections;
@ -1639,10 +1776,10 @@ export RenderedPage RenderFinancials(std::int64_t salesCount,
Escape(notes.title), Escape(notes.lede), freshness, Escape(notes.title), Escape(notes.lede), freshness,
Attr("data-fin-sales-count", std::to_string(salesCount)), Attr("data-fin-sales-count", std::to_string(salesCount)),
Attr("data-fin-sales-minor", std::to_string(salesTotalMinor)), Attr("data-fin-sales-minor", std::to_string(salesTotalMinor)),
fin.Loaded() ? Attr("data-fin-donations-count", std::to_string(fin.donationCount)) showDonations ? Attr("data-fin-donations-count", std::to_string(donationCount))
: SafeHtml{}, : SafeHtml{},
fin.Loaded() ? Attr("data-fin-donations-minor", std::to_string(fin.donationsMinor)) showDonations ? Attr("data-fin-donations-minor", std::to_string(donationsMinor))
: SafeHtml{}, : SafeHtml{},
fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor())) fin.Loaded() ? Attr("data-fin-expenses-minor", std::to_string(fin.ExpensesMinor()))
: SafeHtml{}, : SafeHtml{},
fin.Loaded() ? Attr("data-fin-net-minor", std::to_string(netMinor)) : SafeHtml{}, fin.Loaded() ? Attr("data-fin-net-minor", std::to_string(netMinor)) : SafeHtml{},

View file

@ -27,6 +27,27 @@ void Check(bool ok, std::string_view what, std::string_view got = {}) {
got.empty() ? "" : " got: ", got); got.empty() ? "" : " got: ", got);
} }
// The euro amount on the table row starting with `prefix`, in minor units, or
// -1 when the row is missing. Reads the rendered document, not the builder's
// internals: FormatEuro prints "€938.21", or "€580" when the cents are zero.
std::int64_t RowMinor(const std::string& md, std::string_view prefix) {
const std::size_t at = md.find(prefix);
if (at == std::string::npos) return -1;
std::size_t i = at + prefix.size();
std::int64_t euros = 0;
bool any = false;
for (; i < md.size() && md[i] >= '0' && md[i] <= '9'; ++i) {
euros = euros * 10 + (md[i] - '0');
any = true;
}
if (!any) return -1;
std::int64_t cents = 0;
if (i + 2 < md.size() && md[i] == '.') {
cents = (md[i + 1] - '0') * 10 + (md[i + 2] - '0');
}
return euros * 100 + cents;
}
} // namespace } // namespace
int main() { int main() {
@ -65,6 +86,77 @@ int main() {
Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total"); Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total");
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export"); Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
// Every cent of the amounts table, pinned. This is the document a Dutch
// buyer, an accountant and the Belastingdienst read, so a rounding change
// in Money::NetFromGross must break a test rather than ship a wrong VAT
// figure. Derived by hand from net = (gross*10000 + 6050) / 12100:
// goods 112660 -> (1'126'600'000 + 6050) / 12100 = 93107 -> €931.07
// total 113523 -> (1'135'230'000 + 6050) / 12100 = 93821 -> €938.21
// VAT = 113523 - 93821 = 19702 -> €197.02
// shipping = 93821 - 93107 = 714 -> €7.14
// The shipping line is the REMAINDER of the subtotal, not a rounding of
// its own — that is what makes the columns add up. Rounded independently
// it would print €7.13 ((8'630'000 + 6050) / 12100 = 713) and sit a cent
// below the subtotal, which is why the remainder rule exists: shipping
// absorbs the cent so a signed tax document cannot disagree with itself.
Check(eu.find("| Fairphone 6 — Forest Green | 2 | €931.07 |\n") != std::string::npos,
"invoice: EU item line is net, not the gross the buyer paid");
Check(eu.find("| Shipping | 1 | €7.14 |\n") != std::string::npos,
"invoice: EU shipping line is the subtotal remainder");
Check(eu.find("| Subtotal (ex VAT) | | €938.21 |\n") != std::string::npos,
"invoice: EU subtotal is the net of the gross total");
Check(eu.find("| VAT 21% (NL) | | €197.02 |\n") != std::string::npos,
"invoice: EU VAT line is the amount actually remitted");
Check(eu.find("| **Total (incl. VAT)** | | **€1135.23** |\n") != std::string::npos,
"invoice: EU gross total is what was charged");
// The property the pinned cents above are one instance of, swept across
// the realistic price grid: the three retail prices × every quantity a
// parcel can carry × the range a shipping rate lives in. Before the
// remainder rule, roughly a quarter of these combinations printed lines
// one cent apart from their own subtotal (three independent half-up
// roundings; two errors uniform on [-½,½) cross a boundary with
// probability ¼). Rendered and re-parsed rather than recomputed, so what
// is being held is the document itself:
// item + shipping == subtotal (the remainder rule, by construction)
// subtotal + VAT == total (what the buyer paid, to the cent)
// |shipping - NetFromGross(shipping gross)| <= 1 (the cent stops here)
{
Server::OrderRecord s = o;
std::string broke;
for (const std::int64_t unit : { 57380, 57980, 66538 }) {
for (std::int64_t qty = 1; qty <= 28; ++qty) {
for (std::int64_t ship = 400; ship <= 6000; ship += 97) {
s.quantity = qty;
s.unitMinor = unit;
s.goodsMinor = unit * qty;
s.shippingMinor = ship;
s.totalMinor = s.goodsMinor + ship;
const std::string md = Server::BuildInvoiceMarkdown(s, "P", "");
const std::int64_t item =
RowMinor(md, std::format("| P | {} | €", qty));
const std::int64_t shipping = RowMinor(md, "| Shipping | 1 | €");
const std::int64_t sub = RowMinor(md, "| Subtotal (ex VAT) | | €");
const std::int64_t vat = RowMinor(md, "| VAT 21% (NL) | | €");
const std::int64_t total =
RowMinor(md, "| **Total (incl. VAT)** | | **€");
const bool ok = item >= 0 && shipping >= 0 && sub >= 0
&& vat >= 0 && total == s.totalMinor
&& item + shipping == sub
&& sub + vat == total
&& shipping - Money::NetFromGross(ship) <= 1
&& Money::NetFromGross(ship) - shipping <= 1;
if (!ok && broke.empty()) {
broke = std::format("unit {} qty {} ship {}: {} + {} vs {}",
unit, qty, ship, item, shipping, sub);
}
}
}
}
Check(broke.empty(),
"invoice: EU columns add up across the whole price grid", broke);
}
o.vatIncluded = false; o.vatIncluded = false;
o.buyer.country = "GB"; o.buyer.country = "GB";
o.goodsMinor = 93107; o.goodsMinor = 93107;
@ -75,6 +167,18 @@ int main() {
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis"); Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
Check(ex.find("€955.02") != std::string::npos, "invoice: export total"); Check(ex.find("€955.02") != std::string::npos, "invoice: export total");
// The mirror image of the EU table: a zero-rated export carries no VAT to
// strip, so every line is the gross that was charged and NetFromGross must
// never touch it. 93107 stays €931.07 (netting it again would print
// €769.48) and 2395 stays €23.95 (€19.79 netted) — the two branches
// swapping their treatment is exactly the accident these pin down.
Check(ex.find("| Fairphone 6 — Forest Green | 2 | €931.07 |\n") != std::string::npos,
"invoice: export item line stays gross");
Check(ex.find("| Shipping | 1 | €23.95 |\n") != std::string::npos,
"invoice: export shipping line stays gross");
Check(ex.find("| **Total** | | **€955.02** |\n") != std::string::npos,
"invoice: export total carries no VAT label");
// ── the order confirmation email ────────────────────────────────── // ── the order confirmation email ──────────────────────────────────
// Same order, EU shape again; the attachment stands in for the // Same order, EU shape again; the attachment stands in for the
// clearsigned invoice — the builder must carry it verbatim. // clearsigned invoice — the builder must carry it verbatim.
@ -141,6 +245,88 @@ int main() {
o, "F", "", "x", "u", "S", "D").empty(), o, "F", "", "x", "u", "S", "D").empty(),
"email: header-injecting address yields no message"); "email: header-injecting address yields no message");
// The bare newline is only the loudest of the shapes that would widen the
// envelope. Under `msmtp -t` the To: header IS the recipient list, so
// every address Form::LooksLikeEmail rejects must yield NO message —
// a comma is the cheapest extra-recipient smuggle of the lot, and it is
// barred only because that shared form validator happens to bar it.
// Pinning the coupling here means a future loosening of LooksLikeEmail
// (a legitimate-looking change to a form helper) cannot quietly re-open
// the envelope, and each of these carries a buyer's name and address.
// "…, evil@…" comma, plus a second '@'
// "…> , <evil@…" angle brackets, comma, second '@'
// "…\rBcc: …" bare CR — a header break on its own under CRLF
// "a@b" no dot in the domain
// "" empty, below the minimum length
for (const std::string_view addr : { "a@b.example, evil@x.example",
"a@b.example> , <evil@x.example",
"a@b.example\rBcc: x@y.example",
"a@b",
"" }) {
o.buyer.email = std::string(addr);
Check(Server::BuildOrderConfirmationEmail(
o, "F", "", "x", "u", "S", "D").empty(),
"email: address the envelope check rejects yields no message", addr);
}
// ── the GPG key id alphabet ───────────────────────────────────────
// gGpgKeyId is interpolated straight into a std::system() command line
// between single quotes, so a single accepted quote character is remote
// code execution as the shop user. The alphabet check in
// ConfigureInvoicing is the entire defence. None of this reaches gpg:
// a refused id leaves signing unconfigured, which is what we assert.
Check(!Server::InvoiceSigningConfigured(), "invoice: signing starts unconfigured");
for (const std::string_view bad : { "abc'; touch /tmp/pwned; '",
"0xDEADBEEF BEEF",
"0xDEADBEEF`id`",
"0xDEADBEEF$(id)",
"0xDEADBEEF\nBEEF" }) {
Server::ConfigureInvoicing(std::string(bad));
Check(!Server::InvoiceSigningConfigured(),
"invoice: key id outside the safe alphabet is refused", bad);
}
// The other half of the same contract, which the caller leans on: with no
// signer installed the answer is refusal, never the plaintext. Returning
// the markdown here would serve an UNSIGNED invoice through the path that
// promises a signed one — and the caller cannot tell the difference.
Check(!Server::ClearsignInvoice("# x").has_value(),
"invoice: unconfigured signing yields nullopt, not the plaintext");
// What a fingerprint or a uid email actually needs: alnum plus @ . _ - +.
Server::ConfigureInvoicing("0xDEADBEEF@catcrafts.net");
Check(Server::InvoiceSigningConfigured(),
"invoice: a key id inside the safe alphabet is accepted");
// Put the process back the way we found it — nothing after this line
// should be able to shell out to gpg.
Server::ConfigureInvoicing("");
Check(!Server::InvoiceSigningConfigured(),
"invoice: an empty key id means no signing");
// ── MAIL_FROM is a header, and is guarded like one ────────────────
// MAIL_FROM is written verbatim into the From: header of a message
// delivered with `msmtp -t`, where the headers ARE the envelope: one
// smuggled newline adds a recipient to EVERY order confirmation, and each
// of those carries the buyer's name and full postal address.
Check(!Server::MailConfigured(), "mail: starts unconfigured");
Check(Server::MailFrom().empty(), "mail: no From before configuration");
Server::ConfigureMail(Server::MailConfig{
"true", "Catcrafts <info@catcrafts.net>\nBcc: leak@evil.example" });
// Refused WHOLE, not sanitised: the guard returns before gMail is
// assigned, so the command does not install either. A half-applied config
// would be the dangerous outcome — a mailer that runs with a bad From.
Check(!Server::MailConfigured(),
"mail: a From with a line break rejects the whole config");
Check(Server::MailFrom().empty(),
"mail: a rejected From is never installed", Server::MailFrom());
// kSellerName + kSellerSite, so an operator who sets MAIL_COMMAND and
// forgets MAIL_FROM still sends from an address that exists.
Server::ConfigureMail(Server::MailConfig{ "true", "" });
Check(Server::MailConfigured(), "mail: a clean config installs the command");
Check(Server::MailFrom() == "Catcrafts <info@catcrafts.net>",
"mail: empty MAIL_FROM defaults to the shop inbox", Server::MailFrom());
if (failures != 0) { if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures); std::println(std::cerr, "{} check(s) failed", failures);
return 1; return 1;

View file

@ -53,11 +53,60 @@ int main() {
Check(NetFromGross(GrossFromNet(713)) == 713, "vat: gross-up round-trips"); Check(NetFromGross(GrossFromNet(713)) == 713, "vat: gross-up round-trips");
Check(GrossFromNet(100) == 121, "vat: €1.00 -> €1.21 exactly"); Check(GrossFromNet(100) == 121, "vat: €1.00 -> €1.21 exactly");
Check(GrossFromNet(0) == 0, "vat: gross-up zero"); Check(GrossFromNet(0) == 0, "vat: gross-up zero");
// The half-up term (+5000) caught at sub-cent scale, once each way:
// 3 × 1.21 = 3.63 must land on 4, 2 × 1.21 = 2.42 must land on 2. Plain
// truncation would give 3 for the first, so this is what pins the term.
Check(GrossFromNet(3) == 4, "vat: gross-up rounds .63 up");
Check(GrossFromNet(2) == 2, "vat: gross-up rounds .42 down");
// The round-trip is the property that makes "cost plus, eat nothing" true,
// and it is applied to every EU carrier bracket — so it sets the shipping
// cents on every EU order. If either rounding constant drifted, the shop
// would remit VAT on a grossed-up rate that no longer nets back to the
// carrier's own cost, losing or pocketing a cent on every parcel. A single
// case cannot catch that; sweep the range a shipping rate lives in.
//
// Why it must hold for every n: GrossFromNet(n) is 1.21n rounded half up,
// so it sits within 0.5 of 1.21n. Dividing back by 1.21 therefore lands
// within 0.5/1.21 ≈ 0.413 of n — never far enough to reach the next
// half-up boundary, so NetFromGross returns n exactly.
{
std::string broke;
for (std::int64_t n = 0; n <= 2000; ++n) {
if (NetFromGross(GrossFromNet(n)) != n && broke.empty()) {
broke = std::format("net {} -> gross {} -> net {}", n,
GrossFromNet(n), NetFromGross(GrossFromNet(n)));
}
}
Check(broke.empty(),
"vat: gross-up round-trips for every net from €0.00 to €20.00", broke);
}
// ── zones and membership ────────────────────────────────────────── // ── zones and membership ──────────────────────────────────────────
Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members"); Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members");
// The whole roster, restated here rather than borrowed from EuCountries()
// — a list that checks itself proves nothing. IsEuCountry is the single
// switch in ComputeTotals between charging the VAT-inclusive price and
// charging a zero-rated export net, so a member quietly lost to a rebase,
// or typed as EL instead of GR, bills that country's buyers ~17% under the
// order's worth while the shop still owes NL OSS VAT on the sale. Money
// out the door, per order, with nothing else in the repo watching.
constexpr std::array<std::string_view, 27> members{
"AT", "BE", "BG", "HR", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
"FR", "GR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL",
"PT", "RO", "SE", "SI", "SK",
};
Check(EuCountries().size() == 27, "eu: 27 member states, no more and no fewer");
for (const std::string_view cc : members) {
Check(IsEuCountry(cc), "eu: member state recognised", cc);
}
Check(!IsEuCountry("GB"), "eu: UK left"); Check(!IsEuCountry("GB"), "eu: UK left");
Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU"); Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU");
// Three of the easiest false positives: IS shares the single market
// through the EEA, UA and TR are candidates (TR is even in the customs
// union). None of that is membership, and none of it makes a sale
// domestic for VAT.
Check(!IsEuCountry("IS"), "eu: EEA membership is not EU membership");
Check(!IsEuCountry("UA") && !IsEuCountry("TR"), "eu: candidates are not members");
Check(!IsEuCountry("CA") && !IsEuCountry("US"), "eu: north america"); Check(!IsEuCountry("CA") && !IsEuCountry("US"), "eu: north america");
Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)"); Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)");
Check(ZoneFor("NL") == Zone::Nl, "zone: home"); Check(ZoneFor("NL") == Zone::Nl, "zone: home");
@ -113,6 +162,25 @@ int main() {
Check(LadderFor(table, "BR").empty(), "table: unlisted country is empty"); Check(LadderFor(table, "BR").empty(), "table: unlisted country is empty");
} }
// A zero-priced bracket, which is what a Sendcloud row with a missing or
// unparseable price field becomes by the time it reaches here. RateFor
// spends `best == 0` as its "nothing covers this weight" sentinel, so a
// zero-cent band can never win — and that collision is doing real work: it
// makes the lookup fail CLOSED (ShippingTable::Find returns 0, checkout
// answers 422 and refuses) instead of shipping a €580 parcel worldwide for
// nothing. It is an accident of the sentinel choice rather than a stated
// rule, which is exactly why it needs a test standing over it: "prefer the
// cheapest band" is a tempting simplification that would give the parcel
// away.
{
const std::vector<ShipBracket> onlyFree{ { 2000, 0 } };
Check(RateFor(onlyFree, 700) == 0,
"brackets: a zero-priced band reads as no price, never as free");
const std::vector<ShipBracket> withFree{ { 2000, 0 }, { 10000, 1650 } };
Check(RateFor(withFree, 700) == 1650,
"brackets: a real price wins over a zero-priced band that also carries it");
}
// ── order totals ────────────────────────────────────────────────── // ── order totals ──────────────────────────────────────────────────
// NL: gross + shipping, VAT included in both. // NL: gross + shipping, VAT included in both.
@ -141,10 +209,51 @@ int main() {
auto nl2 = ComputeTotals(57500, 3, 1500, "NL"); auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross"); Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
// VAT is derived ONCE, from the taxable total — never from a sum of line
// nets. ComputeTotals says so above (nl.vatCharged comes off goods +
// shipping as one number); these four make the reason a test rather than a
// comment, because NetFromGross is NOT additive across lines. Each division
// rounds half up on its own remainder, and the remainders do not have to
// agree:
//
// NetFromGross(56330) = (563'300'000 + 6050) / 12100 = 46554 rem 2650
// NetFromGross( 400) = ( 4'000'000 + 6050) / 12100 = 331 rem 950
// NetFromGross(56730) = (567'300'000 + 6050) / 12100 = 46884 rem 9650
//
// 56330 + 400 is 56730, but 46554 + 331 is 46885 — one cent ABOVE the net
// the combined total yields. That gap is not exotic: across the real price
// grid (three variants × 1..28 units × the shipping ladder) roughly a
// quarter of the combinations hit it.
//
// It matters because the invoice prints a "Subtotal (ex VAT)" that is
// NetFromGross of the whole total, with the VAT line derived from that
// subtotal — and non-additivity is exactly why its shipping line is the
// REMAINDER of that subtotal after the goods net, never NetFromGross of
// the shipping on its own. The day someone "tidies" the remainder into a
// third independent rounding, a GPG-signed tax document starts
// disagreeing with itself by a cent on a quarter of the price grid.
Check(NetFromGross(56330) == 46554, "vat: net of a goods line");
Check(NetFromGross(400) == 331, "vat: net of a shipping line");
Check(NetFromGross(56730) == 46884, "vat: net of the two taken together");
Check(NetFromGross(56330) + NetFromGross(400) != NetFromGross(56730),
"vat: line nets do not sum to the total net — derive VAT once, from the total");
// ── indicative conversion ───────────────────────────────────────── // ── indicative conversion ─────────────────────────────────────────
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units. // €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units"); Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate"); Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
// Both cases above land far from the rounding boundary and would pass
// under plain truncation too, which leaves the +50'000'000 term — the only
// thing making this round rather than truncate — entirely unpinned. Drop
// it and every quoted foreign price shifts DOWN by up to a whole unit, on
// the number a non-euro buyer reads before deciding to order. So take the
// boundary head-on: €1.00 at a rate of exactly 1.5 is 1.5 units.
Check(ConvertIndicative(100, 1'500'000) == 2, "fx: exactly half rounds up");
Check(ConvertIndicative(100, 1'499'999) == 1,
"fx: one millionth below half rounds down");
Check(ConvertIndicative(100, 1'400'000) == 1, "fx: .4 of a unit rounds down");
// The term must not conjure a unit out of nothing, either.
Check(ConvertIndicative(0, 1'083'400) == 0, "fx: zero converts to zero");
auto gbp = CurrencyFor("GB"); auto gbp = CurrencyFor("GB");
Check(gbp.has_value() && gbp->code == "GBP", "fx: GB -> GBP"); Check(gbp.has_value() && gbp->code == "GBP", "fx: GB -> GBP");
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion"); Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
@ -172,6 +281,32 @@ int main() {
Check(r.Find("XXX") == 0, "rates: absent is zero"); Check(r.Find("XXX") == 0, "rates: absent is zero");
Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none"); Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none");
// Above is the whole-document failure; this is the per-ENTRY one, which is
// the case CI actually produces. LoadRates admits a rate only when it is a
// JSON number AND strictly positive, and that guard is the single thing
// standing between a bad rates.json and a printed price: the views and the
// order handler only re-check `rate > 0` before formatting, so a negative
// that slipped through here would render "≈ £-628" on every shop card.
// One entry per rejected shape — zero, negative, a number sent as a
// string, and null.
const Rates bad = LoadRates(
R"({"date":"2026-08-04","micro_per_eur":{"USD":0,"GBP":-860000,)"
R"("CHF":"940000","SEK":null}})");
Check(bad.date == "2026-08-04", "rates: a readable date survives unusable entries");
Check(bad.microPerEur.empty(),
"rates: zero, negative, string and null entries are all refused");
Check(bad.Find("USD") == 0 && bad.Find("GBP") == 0 && bad.Find("CHF") == 0 &&
bad.Find("SEK") == 0,
"rates: a refused entry is indistinguishable from an absent one");
// Refusal is per entry, not per document — one unusable rate must not take
// its healthy siblings down with it, or a single ECB hiccup blanks every
// localised price on the site instead of just the one currency's.
const Rates partial = LoadRates(
R"({"date":"2026-08-04","micro_per_eur":{"GBP":-860000,"NOK":11700000}})");
Check(partial.microPerEur.size() == 1, "rates: only the bad entry is dropped");
Check(partial.Find("NOK") == 11'700'000, "rates: the valid sibling still loads");
Check(partial.Find("GBP") == 0, "rates: the negative sibling does not");
if (failures != 0) { if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures); std::println(std::cerr, "{} check(s) failed", failures);
return 1; return 1;

View file

@ -96,7 +96,7 @@ int main(int argc, char** argv) {
} }
Check(listOk, "shop index carries an ItemList of the product pages"); Check(listOk, "shop index carries an ItemList of the product pages");
} }
srv.BodyHas("/shop/fp6-pmos", "\"price\":\"563.30\"", "schema price is the checkout integer"); srv.BodyHas("/shop/fp6-pmos", "\"price\":\"573.80\"", "schema price is the checkout integer");
// Merchant-grade offer fields: what Merchant Center's website-crawl feed // Merchant-grade offer fields: what Merchant Center's website-crawl feed
// reads. Shipping is published from the live carrier table at one unit's // reads. Shipping is published from the live carrier table at one unit's
// weight — the same integers checkout charges, so the listing cannot // weight — the same integers checkout charges, so the listing cannot

View file

@ -69,8 +69,25 @@ int main() {
// ── Url ─────────────────────────────────────────────────────────── // ── Url ───────────────────────────────────────────────────────────
CheckEq(Url("href", "/shop/thing"), " href=\"/shop/thing\"", "url: site-relative"); CheckEq(Url("href", "/shop/thing"), " href=\"/shop/thing\"", "url: site-relative");
CheckEq(Url("href", "https://a.example/x"), " href=\"https://a.example/x\"", "url: https"); CheckEq(Url("href", "https://a.example/x"), " href=\"https://a.example/x\"", "url: https");
// Plain http is on the allowlist too. Not merely tolerated: a link the
// author wrote as http must survive as http rather than turn into an
// inert "#", because a silently dead link is worse than an insecure one.
CheckEq(Url("href", "http://x.example/a"), " href=\"http://x.example/a\"", "url: http");
CheckEq(Url("href", "mailto:a@b.example"), " href=\"mailto:a@b.example\"", "url: mailto"); CheckEq(Url("href", "mailto:a@b.example"), " href=\"mailto:a@b.example\"", "url: mailto");
CheckEq(Url("href", "#reviews"), " href=\"#reviews\"", "url: fragment"); CheckEq(Url("href", "#reviews"), " href=\"#reviews\"", "url: fragment");
// The EIP-681 pay link the crypto rail hands the buyer. Eurc builds it as
// ethereum:{contract}@{chainId}/transfer?address={to}&uint256={units}
// and the order page emits it through Url(). Two things must hold at once:
// ethereum: stays on the allowlist (drop it and every crypto pay button
// becomes href="#", i.e. nobody on that rail can pay), and the query
// separator is still escaped to &amp; like any other attribute value.
CheckEq(Url("href", "ethereum:0xAbC@8453/transfer?address=0xDeF&uint256=1000000"),
" href=\"ethereum:0xAbC@8453/transfer?address=0xDeF&amp;uint256=1000000\"",
"url: ethereum: EIP-681 kept verbatim, ampersand escaped");
// Empty href fails every allowlist branch — including the site-relative
// one, which needs at least one character — so it lands on "#" rather
// than emitting a link that resolves to the current page.
CheckEq(Url("href", ""), " href=\"#\"", "url: empty falls back to #");
// Escaping alone would NOT make these safe: they contain no character // Escaping alone would NOT make these safe: they contain no character
// that needs escaping, so only a scheme allowlist stops them. // that needs escaping, so only a scheme allowlist stops them.
CheckEq(Url("href", "javascript:alert(1)"), " href=\"#\"", "url: javascript: neutralised"); CheckEq(Url("href", "javascript:alert(1)"), " href=\"#\"", "url: javascript: neutralised");
@ -99,6 +116,65 @@ int main() {
CheckEq(Join(std::span<const Html::SafeHtml>{}), "", "join: empty"); CheckEq(Join(std::span<const Html::SafeHtml>{}), "", "join: empty");
CheckEq(Escape("a") + Escape("<"), "a&lt;", "operator+: escapes preserved"); CheckEq(Escape("a") + Escape("<"), "a&lt;", "operator+: escapes preserved");
// ── Autolink: escaping ────────────────────────────────────────────
// Autolink, not Escape, is what every prose paragraph on /legal/*, /about
// and /financials goes through (Views), and what Markdown hands its
// paragraph bodies. So it is the real escaper on those pages: if it ever
// stops escaping, that is stored XSS on the policy text.
CheckEq(Autolink("<b>a & b</b>"), "&lt;b&gt;a &amp; b&lt;/b&gt;",
"autolink: escapes text with no URL in it");
// With a URL present the non-URL runs still go through Escape. The '<'
// also doubles as a URL terminator here, which is why the anchor stops
// before "</b>" instead of swallowing it into the href.
CheckEq(Autolink("<b>https://x.example</b>"),
"&lt;b&gt;<a href=\"https://x.example\">https://x.example</a>&lt;/b&gt;",
"autolink: markup around a URL stays escaped");
// The URL text is escaped on BOTH sides of the anchor — attribute and
// text node — because href goes through Url() (which calls Attr, which
// escapes) and the label goes through Escape(). A '&' in a query string
// is the everyday case that proves it.
CheckEq(Autolink("https://x.example/?a=1&b=2"),
"<a href=\"https://x.example/?a=1&amp;b=2\">https://x.example/?a=1&amp;b=2</a>",
"autolink: ampersand escaped in href and in anchor text");
// An explicit http/https scheme is required, so nothing else becomes a
// link — least of all a scheme Url() would have had to neutralise.
CheckEq(Autolink("ftp://x.example/a"), "ftp://x.example/a",
"autolink: non-http scheme is not linked");
CheckEq(Autolink("javascript:alert(1)"), "javascript:alert(1)",
"autolink: javascript: is text, never an anchor");
// ── Autolink: URL boundaries ──────────────────────────────────────
// The privacy notice ends a sentence with a bare address. A period pulled
// into the href is a 404 for every reader who clicks it, so the trailing
// sentence punctuation is trimmed back out of the URL and re-emitted as
// escaped text after the </a>.
CheckEq(Autolink("See https://catcrafts.net/analytics."),
"See <a href=\"https://catcrafts.net/analytics\">"
"https://catcrafts.net/analytics</a>.",
"autolink: trailing period stays outside the anchor");
// Same rule for a closing bracket the URL did not open: "(see .../y)" has
// zero '(' inside the matched run and one ')', so the ')' is given back.
CheckEq(Autolink("(see https://x.example/y)"),
"(see <a href=\"https://x.example/y\">https://x.example/y</a>)",
"autolink: unmatched closing paren stays outside the anchor");
// But a bracket the URL DID open is part of it — one '(' and one ')' in
// the run, so the count test holds and the paren is kept in both href and
// text. Wikipedia disambiguation links are the reason this rule exists.
CheckEq(Autolink("https://en.wikipedia.org/wiki/Foo_(bar) end"),
"<a href=\"https://en.wikipedia.org/wiki/Foo_(bar)\">"
"https://en.wikipedia.org/wiki/Foo_(bar)</a> end",
"autolink: balanced paren kept inside the anchor");
// A scheme has to start a word. The "https://" here begins at index 1
// with a letter before it, so it is the tail of a longer token, not a
// link — the guard that stops two run-together URLs linking the second.
CheckEq(Autolink("shttps://x.example"), "shttps://x.example",
"autolink: scheme mid-word is not a link");
// A scheme with no host after it: find("//") lands at 6 and 6+2 is not
// less than the 8-char run, so the degenerate case emits escaped text and
// advances pos past it. That advance is the loop-stall guard.
CheckEq(Autolink("https://"), "https://",
"autolink: bare scheme emits text and cannot stall the loop");
if (failures != 0) { if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures); std::println(std::cerr, "{} check(s) failed", failures);
return 1; return 1;

View file

@ -0,0 +1,592 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The order ledger: the append-only JSON-lines log and the left fold that turns
// it back into orders. Everything downstream — the buyer's order page, the
// invoice, the reconciler, the mailer, the public sales total on /financials —
// reads whatever this fold says, and nothing else. So the properties pinned
// here are the ones that decide whether an order exists, what it is worth, and
// when it was paid.
//
// This suite drives the REAL storage functions against a scratch ledger rather
// than constructing OrderRecords by hand: the interesting behaviour is in the
// formatter and the fold, not in the struct.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
namespace fs = std::filesystem;
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
fs::path gWork;
// The ledger path is process-global state, so every scenario gets its own file
// rather than inheriting the previous one's history.
fs::path FreshLedger(std::string_view name) {
const fs::path p = gWork / std::format("{}.jsonl", name);
std::error_code ec;
fs::remove(p, ec);
Server::SetOrdersPath(p);
return p;
}
// Hand-written ledgers: the fold's real input, byte for byte. Several
// properties here (a truncated line, a replayed event, a line an older build
// wrote) cannot be produced through CreateOrder at all.
void WriteLedger(const fs::path& p, std::initializer_list<std::string_view> lines) {
std::ofstream out(p, std::ios::trunc | std::ios::binary);
for (const std::string_view line : lines) out << line << '\n';
}
void AppendRaw(const fs::path& p, std::string_view line) {
std::ofstream out(p, std::ios::app | std::ios::binary);
out << line << '\n';
}
std::string ReadAll(const fs::path& p) {
std::ifstream in(p, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(in),
std::istreambuf_iterator<char>());
}
std::size_t CountOf(std::string_view hay, std::string_view needle) {
std::size_t n = 0;
for (std::size_t i = hay.find(needle); i != std::string_view::npos;
i = hay.find(needle, i + needle.size())) {
++n;
}
return n;
}
// The single ledger line containing `needle`, without its terminator; empty
// when no line has it.
std::string LineContaining(std::string_view text, std::string_view needle) {
std::size_t start = 0;
while (start <= text.size()) {
const std::size_t nl = text.find('\n', start);
const std::size_t end = (nl == std::string_view::npos) ? text.size() : nl;
const std::string_view line = text.substr(start, end - start);
if (line.find(needle) != std::string_view::npos) return std::string(line);
if (nl == std::string_view::npos) break;
start = nl + 1;
}
return {};
}
// A stored order with every field populated, so a scenario only has to say
// what it cares about.
Server::OrderRecord Sample(std::string token, std::string email) {
Server::OrderRecord o;
o.token = std::move(token);
o.reference = Server::ReferenceFromToken(o.token);
o.product = "fairphone-6";
o.color = "green";
o.quantity = 1;
o.unitMinor = 56330;
o.createdAt = "2026-08-15T09:00:00Z";
o.buyer = { std::move(email), "Ada Lovelace", "Main St 1", "1234AB",
"Delft", "NL" };
o.goodsMinor = 56330;
o.shippingMinor = 1500;
o.totalMinor = 57830; // 56330 + 1500
o.vatIncluded = true;
o.payChoice = std::string(Form::kPayBank);
o.payUrl = "https://pay.example.org/tr_test";
o.payId = "tr_test";
return o;
}
// ── buyer free text cannot leave its field ────────────────────────────
//
// Form::ValidateCheckout length-limits name/street/postal/city and nothing
// more, so quotes, backslashes and newlines reach CreateOrder's formatter
// exactly as they were typed. JsonEscape is the only thing between them and
// the record format.
void BuyerTextStaysInItsField() {
FreshLedger("escaping");
Server::OrderRecord o = Sample("1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a", "ada@example.org");
// Shaped to close the name string and open a "total_minor":1 of its own.
// Json::Value::Find returns the FIRST match for a key, so an unescaped
// quote here would make this €578.30 order worth one cent — and
// total_minor is what the invoice bills and what /financials publishes.
o.buyer.name = R"(Ada ","total_minor":1,"x":")";
Check(!Server::FindOrder(o.token).has_value(),
"ledger: a file that does not exist yet holds no orders");
Check(Server::CreateOrder(o), "escaping: the order is written");
const std::optional<Server::OrderRecord> back = Server::FindOrder(o.token);
Check(back.has_value(), "escaping: an injected name still parses as one record");
if (back) {
Check(back->buyer.name == o.buyer.name,
"escaping: the name round-trips byte for byte", back->buyer.name);
Check(back->totalMinor == 57830,
"escaping: a buyer cannot mint their own total_minor",
std::format("{}", back->totalMinor));
}
// A backslash and a raw newline, on their own ledger so the line count
// below means what it says.
const fs::path solo = FreshLedger("escaping-newline");
Server::OrderRecord n = Sample("2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b", "ada@example.org");
n.buyer.street = R"(Main \ St 1)";
n.buyer.city = "Delft\nNL";
Check(Server::CreateOrder(n), "escaping: the order carrying a newline is written");
const std::optional<Server::OrderRecord> back2 = Server::FindOrder(n.token);
Check(back2.has_value(), "escaping: a newline in the address does not lose the record");
if (back2) {
Check(back2->buyer.street == R"(Main \ St 1)",
"escaping: a backslash survives the round trip", back2->buyer.street);
Check(back2->buyer.city == "Delft\nNL",
"escaping: an embedded newline survives the round trip");
}
// One record is one LINE. An unescaped newline would split this order in
// two: the reader would keep the head and silently drop the address, the
// total and the payment id.
const std::string text = ReadAll(solo);
Check(CountOf(text, "\n") == 1, "escaping: one order is exactly one line",
std::format("{} line terminator(s)", CountOf(text, "\n")));
Check(Server::ListOrders().size() == 1,
"escaping: and the file folds back to exactly one order");
}
// ── the sale is the FIRST paid event ──────────────────────────────────
//
// The join between the append-only log and the public sales figure. A refund
// folds the status onward but never un-happens the payment, and a replayed
// paid line must not be able to move the recorded moment of sale — the
// timestamp both the bookkeeping and the invoice sequence hang off.
void TheSaleIsTheFirstPaidEvent() {
const fs::path led = FreshLedger("paid-then-cancelled");
WriteLedger(led, {
R"({"type":"order","at":"2025-12-31T23:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
R"("ref":"CC-A1B2C3","product":"fairphone-6","email":"ada@example.org",)"
R"("total_minor":57830})",
R"({"type":"status","at":"2026-01-01T00:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
R"("status":"paid","via":"ideal"})",
// The same paid event again — a redelivered provider callback, or a
// reconciler sweep that ran twice.
R"({"type":"status","at":"2026-06-06T00:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
R"("status":"paid","via":"ideal"})",
// Six months later the sale is refunded.
R"({"type":"status","at":"2026-07-07T00:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
R"("status":"cancelled"})",
});
const std::optional<Server::OrderRecord> o =
Server::FindOrder("a1b2c3d4e5f60718293a4b5c6d7e8f90");
Check(o.has_value(), "fold: the hand-written order resolves");
if (o) {
Check(o->paidAt == "2026-01-01T00:00:00Z",
"fold: paidAt is the first paid event, not the replayed one", o->paidAt);
Check(o->status == "cancelled", "fold: the latest status wins", o->status);
Check(o->updatedAt == "2026-07-07T00:00:00Z",
"fold: updatedAt follows the newest event folded in", o->updatedAt);
// The refund carries no `via`, and losing the method here would erase
// exactly which orders were settled with reversible money.
Check(o->paidVia == "ideal", "fold: the settlement method outlives the refund",
o->paidVia);
}
// …and the refunded order is still a sale: /financials counts ever-paid.
const std::vector<Server::OrderRecord> all = Server::ListOrders();
const Server::SalesSummary sum = Server::SummarizeSales(all);
Check(sum.count == 1 && sum.totalMinor == 57830,
"fold: a refunded order still counts as a sale",
std::format("count {} total {}", sum.count, sum.totalMinor));
}
// ── invoice numbers are a sequence, per customer ──────────────────────
//
// Art. 226(2) permits "one or more series"; this is one series per customer,
// keyed on the case-normalised email. Both the invoice download route and the
// confirmation mailer call this on orders that may already carry a number, so
// idempotency is not an optimisation — a second number for one sale means the
// attached invoice stops matching the ledger.
void InvoiceNumbersAreASequence() {
const fs::path led = FreshLedger("invoice-idempotent");
const Server::OrderRecord o = Sample("3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c", "ada@example.org");
Check(Server::CreateOrder(o), "invoice: the order is written");
const std::optional<std::string> first =
Server::AssignInvoiceNumber(o.token, "2026-08-15T10:00:00Z");
Check(first.has_value(), "invoice: a stored order gets a number");
if (first) {
// "<uuid(36)>-<seq>": 36 + 1 + 1 for a customer's first invoice.
Check(first->size() == 38, "invoice: <uuid>-<seq> shape", *first);
Check((*first)[36] == '-', "invoice: the sequence hangs off a 36-char customer number",
*first);
Check(first->ends_with("-1"), "invoice: a new customer's series starts at 1", *first);
}
const std::optional<std::string> again =
Server::AssignInvoiceNumber(o.token, "2026-08-15T10:05:00Z");
Check(again == first, "invoice: re-assigning returns the number already issued",
again.value_or("<none>"));
Check(CountOf(ReadAll(led), R"("type":"invoice")") == 1,
"invoice: and appends no second invoice event");
// One customer who typed their address differently the second time is
// still one customer, and so one series.
FreshLedger("invoice-series");
const Server::OrderRecord a = Sample("4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d", "Ada@Example.org");
const Server::OrderRecord b = Sample("5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e", "ada@example.org");
Check(Server::CreateOrder(a) && Server::CreateOrder(b),
"invoice: both of the customer's orders are written");
const std::optional<std::string> na =
Server::AssignInvoiceNumber(a.token, "2026-08-15T10:00:00Z");
const std::optional<std::string> nb =
Server::AssignInvoiceNumber(b.token, "2026-08-16T10:00:00Z");
Check(na.has_value() && nb.has_value(), "invoice: both orders get numbers");
if (na && nb) {
Check(na->substr(0, 36) == nb->substr(0, 36),
"invoice: a differently-cased email is the same customer number",
std::format("{} vs {}", *na, *nb));
Check(na->ends_with("-1") && nb->ends_with("-2"),
"invoice: the second sale continues the series rather than starting one",
std::format("{} then {}", *na, *nb));
}
// A token no order line names. Numbering an order that does not exist
// would burn a member of the sequence on nothing.
Check(!Server::AssignInvoiceNumber("deadbeefdeadbeefdeadbeefdeadbeef",
"2026-08-15T10:00:00Z").has_value(),
"invoice: no order, no number");
}
// ── one bad line is only one bad line ─────────────────────────────────
//
// The design explicitly accepts a truncated last line from a crash mid-append.
// If the fold aborted on a parse failure instead of skipping, one interrupted
// write would 404 every order before it: buyers lose their status page, the
// mailer stops, and /financials silently drops to zero.
void OneBadLineIsOnlyOneBadLine() {
const fs::path led = FreshLedger("corrupt");
WriteLedger(led, {
R"({"type":"order","at":"2026-08-01T09:00:00Z","id":"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",)"
R"("ref":"CC-0A0A0A","product":"fairphone-6","email":"a@example.org",)"
R"("total_minor":57830})",
"not json at all",
"",
// A crash between the write and the newline.
R"({"type":"order","id":"bbbb)",
R"({"type":"order","at":"2026-08-02T09:00:00Z","id":"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",)"
R"("ref":"CC-0B0B0B","product":"fairphone-6","email":"b@example.org",)"
R"("total_minor":11111})",
});
const std::vector<Server::OrderRecord> all = Server::ListOrders();
Check(all.size() == 2, "ledger: three unreadable lines cost three lines and no more",
std::format("{} record(s)", all.size()));
if (all.size() == 2) {
Check(all[0].token == "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a"
&& all[1].token == "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
"ledger: both good orders survive, in file order");
Check(all[1].totalMinor == 11111,
"ledger: the record after the truncated line is complete");
}
Check(Server::FindOrder("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b").has_value(),
"ledger: and it still resolves by token");
// An order event with no id names no order. Folding it in as a tokenless
// record would give every later id-less event something to match.
AppendRaw(led,
R"({"type":"order","at":"2026-08-03T09:00:00Z","ref":"CC-NOID00",)"
R"("product":"fairphone-6","email":"c@example.org","total_minor":100})");
const std::vector<Server::OrderRecord> after = Server::ListOrders();
Check(after.size() == 2, "ledger: an order event with no id is dropped",
std::format("{} record(s)", after.size()));
bool tokenless = false;
for (const Server::OrderRecord& r : after) tokenless = tokenless || r.token.empty();
Check(!tokenless, "ledger: no tokenless record is ever folded in");
}
// ── history is not rewritten ──────────────────────────────────────────
//
// The file is append-only precisely so an amount cannot be changed after the
// fact; first-order-event-wins is the enforcement. A later line that
// overwrote the total would change what the invoice bills, what the
// reconciler matches against the provider, and what /financials reports —
// leaving no trace, since the original line still sits in the file.
void HistoryIsNotRewritten() {
const fs::path led = FreshLedger("duplicate-order");
WriteLedger(led, {
R"({"type":"order","at":"2026-08-01T09:00:00Z","id":"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",)"
R"("ref":"CC-0D0D0D","product":"fairphone-6","email":"first@example.org",)"
R"("total_minor":57830})",
R"({"type":"order","at":"2026-08-01T09:05:00Z","id":"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",)"
R"("ref":"CC-0D0D0D","product":"fairphone-6","email":"second@example.org",)"
R"("total_minor":1})",
});
const std::vector<Server::OrderRecord> all = Server::ListOrders();
Check(all.size() == 1, "ledger: a duplicate order event yields one record, not two",
std::format("{} record(s)", all.size()));
if (all.size() == 1) {
Check(all[0].totalMinor == 57830,
"ledger: the first order event fixes the amount",
std::format("{}", all[0].totalMinor));
Check(all[0].buyer.email == "first@example.org",
"ledger: and the buyer it was sold to", all[0].buyer.email);
Check(all[0].createdAt == "2026-08-01T09:00:00Z",
"ledger: and when the sale happened", all[0].createdAt);
}
}
// ── a ledger written by an older build still reads ────────────────────
//
// The module states this as a design guarantee, and it is the reason nothing
// is ever rewritten in place: every key added since must default to something
// an old line can live with.
void AnOlderLedgerStillReads() {
const fs::path led = FreshLedger("old-build");
WriteLedger(led, {
R"({"type":"order","at":"2026-02-02T08:00:00Z","id":"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",)"
R"("ref":"CC-0E0E0E","product":"fairphone-6","email":"old@example.org",)"
R"("total_minor":56330})",
});
const std::optional<Server::OrderRecord> o =
Server::FindOrder("0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e");
Check(o.has_value(), "old ledger: a pre-variants order line still resolves");
if (o) {
// Int("quantity", 1) is the only thing standing between an old line
// and a "× 0" on the buyer's page — and a zero-quantity line on an
// invoice that is a legal document.
Check(o->quantity == 1, "old ledger: an absent quantity reads as one, never zero",
std::format("{}", o->quantity));
// An empty status would make the order invisible to the reconciler
// (not awaiting_payment) and to the mailer (not paid) at once.
Check(o->status == "awaiting_payment",
"old ledger: an absent status reads as awaiting payment", o->status);
Check(o->totalMinor == 56330, "old ledger: what it does say is read");
Check(o->color.empty(), "old ledger: pre-variants orders have no colour");
Check(o->payChoice.empty(),
"old ledger: the rail is read as written, with no default invented");
Check(!o->vatIncluded, "old ledger: an absent vat_included is false");
// Every order written before donations existed is a sale, or the
// public sales total would quietly shrink under a newer build.
Check(!o->donation, "old ledger: an absent donation flag reads as a sale");
Check(o->unitMinor == 0 && o->goodsMinor == 0 && o->shippingMinor == 0,
"old ledger: absent amounts are zero, not garbage");
Check(o->createdAt == "2026-02-02T08:00:00Z" && o->updatedAt == o->createdAt,
"old ledger: an order with no later event was last updated when it was made");
Check(o->paidAt.empty() && o->invoiceNumber.empty() && o->confirmationSentAt.empty(),
"old ledger: never paid, never invoiced, never emailed");
}
}
// ── only a confirmation marks the confirmation sent ───────────────────
//
// confirmationSentAt is the only thing that stops MailerLoop re-sending, and
// the only thing that makes it send at all. "what" exists so the future
// shipped-notice can share this event type; if the fold matched any "what",
// that notice would mark the order as already notified and a buyer who paid
// would get neither confirmation nor invoice.
void OnlyAConfirmationMarksTheEmailSent() {
const fs::path led = FreshLedger("notified");
const Server::OrderRecord a = Sample("1111111111111111aaaaaaaaaaaaaaaa", "a@example.org");
const Server::OrderRecord b = Sample("2222222222222222bbbbbbbbbbbbbbbb", "b@example.org");
Check(Server::CreateOrder(a) && Server::CreateOrder(b),
"notified: both orders are written");
Check(Server::AppendOrderNotified(a.token, "2026-08-15T10:00:00Z"),
"notified: the event is appended");
Check(CountOf(ReadAll(led), R"("what":"confirmation")") == 1,
"notified: the writer names the message it sent");
const std::optional<Server::OrderRecord> ra = Server::FindOrder(a.token);
Check(ra && ra->confirmationSentAt == "2026-08-15T10:00:00Z",
"notified: the confirmation timestamp folds in",
ra ? ra->confirmationSentAt : std::string("<no order>"));
// A different message about a different order.
AppendRaw(led,
R"({"type":"notified","at":"2026-08-15T11:00:00Z",)"
R"("id":"2222222222222222bbbbbbbbbbbbbbbb","what":"shipped"})");
const std::optional<Server::OrderRecord> rb = Server::FindOrder(b.token);
Check(rb.has_value(), "notified: the second order still resolves");
Check(rb && rb->confirmationSentAt.empty(),
"notified: a shipped notice does not claim the confirmation was sent",
rb ? rb->confirmationSentAt : std::string("<no order>"));
const std::optional<Server::OrderRecord> ra2 = Server::FindOrder(a.token);
Check(ra2 && ra2->confirmationSentAt == "2026-08-15T10:00:00Z",
"notified: and it does not disturb the order that was confirmed");
}
// ── a donation is income, never a sale ────────────────────────────────
//
// The donation flag decides three downstream behaviours at once (no invoice,
// the thank-you email, and WHICH /financials row the money lands in), so what
// is pinned here is the ledger's half: the flag round-trips through the
// writer and the fold, an old line without the key stays a sale, and
// SummarizeSales books each paid euro in exactly one row.
void ADonationIsIncomeNeverASale() {
const fs::path led = FreshLedger("donation");
Server::OrderRecord sale = Sample("6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f", "ada@example.org");
Server::OrderRecord gift = Sample("7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a", "");
gift.product = "donation";
gift.donation = true;
gift.color.clear();
gift.buyer = { "", "", "", "", "", "" }; // nothing ships, nothing stored
gift.unitMinor = 2500;
gift.goodsMinor = 2500;
gift.shippingMinor = 0;
gift.totalMinor = 2500;
gift.vatIncluded = false;
Check(Server::CreateOrder(sale) && Server::CreateOrder(gift),
"donation: both records are written");
// The writer follows the omit-rather-than-empty rule: the key exists only
// on the donation's line, which is what keeps absent-means-false safe.
const std::string text = ReadAll(led);
Check(CountOf(text, R"("donation":true)") == 1,
"donation: the flag is written once, on the donation's line");
const std::string saleLine = LineContaining(text, sale.token);
Check(!saleLine.empty() && saleLine.find(R"("donation")") == std::string::npos,
"donation: a sale's line carries no donation key at all");
const std::optional<Server::OrderRecord> back = Server::FindOrder(gift.token);
Check(back.has_value() && back->donation,
"donation: the flag folds back in");
const std::optional<Server::OrderRecord> saleBack = Server::FindOrder(sale.token);
Check(saleBack.has_value() && !saleBack->donation,
"donation: a sale folds back as one");
// Both paid: each euro lands in exactly one summary row.
Check(Server::AppendOrderStatus(sale.token, "paid", "2026-08-17T10:00:00Z", "ideal")
&& Server::AppendOrderStatus(gift.token, "paid", "2026-08-17T10:01:00Z",
"eurc-base"),
"donation: both paid transitions append");
const Server::SalesSummary sum = Server::SummarizeSales(Server::ListOrders());
Check(sum.count == 1 && sum.totalMinor == 57830,
"donation: the sale row counts only the sale",
std::format("count {} total {}", sum.count, sum.totalMinor));
Check(sum.donationCount == 1 && sum.donationsMinor == 2500,
"donation: the donation row counts only the donation",
std::format("count {} total {}", sum.donationCount, sum.donationsMinor));
// An UNPAID donation is nothing yet — same ever-paid rule as sales.
const fs::path led2 = FreshLedger("donation-unpaid");
Server::OrderRecord pending = gift;
pending.token = "8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b";
Check(Server::CreateOrder(pending), "donation: the unpaid donation is written");
const Server::SalesSummary none = Server::SummarizeSales(Server::ListOrders());
Check(none.donationCount == 0 && none.donationsMinor == 0,
"donation: an unpaid donation counts nothing");
}
// ── a transition keeps what it does not name ──────────────────────────
//
// paidVia is how the ledger shows at a glance which orders carry reversible
// card money for months — the stated reason `via` exists at all. Losing it on
// the next transition would erase that flag exactly when an order ships.
void ATransitionKeepsWhatItDoesNotName() {
const fs::path led = FreshLedger("status");
const Server::OrderRecord o = Sample("f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", "ada@example.org");
Check(Server::CreateOrder(o), "status: the order is written");
Check(Server::AppendOrderStatus(o.token, "paid", "2026-08-15T09:00:00Z", "creditcard"),
"status: the paid transition is appended");
Check(Server::AppendOrderStatus(o.token, "shipped", "2026-08-15T11:00:00Z"),
"status: the shipped transition is appended");
const std::optional<Server::OrderRecord> r = Server::FindOrder(o.token);
Check(r.has_value(), "status: the order resolves");
if (r) {
Check(r->status == "shipped", "status: the latest transition wins", r->status);
Check(r->paidVia == "creditcard",
"status: the settlement method survives the next transition", r->paidVia);
Check(r->paidAt == "2026-08-15T09:00:00Z",
"status: shipping does not restate when it was paid", r->paidAt);
Check(r->updatedAt == "2026-08-15T11:00:00Z",
"status: updatedAt follows the transition", r->updatedAt);
}
// The writer omits the key entirely rather than writing an empty one:
// that is what makes "absent means unchanged" safe to rely on in the fold.
const std::string text = ReadAll(led);
const std::string shipped = LineContaining(text, R"("status":"shipped")");
Check(!shipped.empty(), "status: the shipped transition is on the file");
Check(shipped.find(R"("via")") == std::string::npos,
"status: a transition with no method writes no via key at all", shipped);
// An empty status is not a state. Applying it would leave the order
// invisible to the reconciler, the mailer and the invoice route at once.
AppendRaw(led,
R"({"type":"status","at":"2026-08-15T12:00:00Z",)"
R"("id":"f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0","status":""})");
const std::optional<Server::OrderRecord> blanked = Server::FindOrder(o.token);
Check(blanked && blanked->status == "shipped",
"status: an empty status is skipped, not applied",
blanked ? blanked->status : std::string("<no order>"));
Check(blanked && blanked->updatedAt == "2026-08-15T11:00:00Z",
"status: and it does not even move updatedAt",
blanked ? blanked->updatedAt : std::string("<no order>"));
// A transition naming an order that does not exist.
AppendRaw(led,
R"({"type":"status","at":"2026-08-15T13:00:00Z",)"
R"("id":"deadbeefdeadbeefdeadbeefdeadbeef","status":"cancelled"})");
const std::vector<Server::OrderRecord> all = Server::ListOrders();
Check(all.size() == 1, "status: an event for an unknown order conjures no record",
std::format("{} record(s)", all.size()));
if (all.size() == 1) {
Check(all[0].status == "shipped" && all[0].updatedAt == "2026-08-15T11:00:00Z",
"status: and leaves the order that does exist alone");
}
}
} // namespace
int main() {
std::error_code ec;
// The suites run in parallel, so the scratch directory has to be unique
// per run rather than merely per suite.
gWork = fs::temp_directory_path(ec)
/ std::format("catcrafts-ledger-{}", Server::NewOrderToken());
fs::create_directories(gWork, ec);
if (ec) {
std::println(std::cerr, "could not create {}: {}", gWork.string(), ec.message());
return 1;
}
BuyerTextStaysInItsField();
TheSaleIsTheFirstPaidEvent();
InvoiceNumbersAreASequence();
OneBadLineIsOnlyOneBadLine();
HistoryIsNotRewritten();
AnOlderLedgerStillReads();
ADonationIsIncomeNeverASale();
OnlyAConfirmationMarksTheEmailSent();
ATransitionKeepsWhatItDoesNotName();
fs::remove_all(gWork, ec);
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,425 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The EURC rail's address pool: which address a stranger is told to send money
// to, and how many times that address may be told to anybody.
//
// There is no processor here to notice a mistake. Handing one address to two
// orders means the second buyer's EURC settles the FIRST buyer's order while
// the second lapses unpaid — real money at an address we control, and a
// support ticket only a human can close. So the burn-before-handing-out order,
// the persisted cursor, the resume after a restart, and every pool the rail
// refuses to start with are all pinned here, alongside the EIP-681 amount the
// buyer's wallet actually pre-fills.
//
// Nothing in this suite touches the network. The chains fixture points at
// unreachable endpoints on purpose, and CheckPaid is exercised ONLY on payIds
// that fail to split — the one branch that answers before an RPC is dialed.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
void WriteFile(const std::filesystem::path& p, std::string_view content) {
std::ofstream(p, std::ios::binary) << content;
}
// "0x" + 40 hex digits, with a short tail naming the line it belongs to. Built
// rather than typed out: a 40-character literal is exactly where a miscount
// hides, and a pool line one digit short would exercise the refusal path
// instead of whatever the assertion meant to prove.
std::string Addr(std::string_view tail) {
std::string s = "0x";
s.append(40 - tail.size(), '0');
s.append(tail);
return s;
}
// The rail parks its high-water mark beside the pool, as "<pool>.cursor".
std::filesystem::path CursorOf(const std::filesystem::path& pool) {
return std::filesystem::path(pool.string() + ".cursor");
}
std::optional<std::size_t> CursorValue(const std::filesystem::path& pool) {
std::ifstream in(CursorOf(pool), std::ios::binary);
std::size_t v = 0;
if (!(in >> v)) return std::nullopt;
return v;
}
std::string Show(const std::optional<std::size_t>& v) {
return v ? std::to_string(*v) : std::string("(no cursor)");
}
// The payId is "<address>@<unix-deadline>"; both halves are asserted
// separately because they fail for different reasons.
std::string AddressOf(std::string_view payId) {
const std::size_t at = payId.rfind('@');
if (at == std::string_view::npos) return {};
return std::string(payId.substr(0, at));
}
std::int64_t DeadlineOf(std::string_view payId) {
const std::size_t at = payId.rfind('@');
if (at == std::string_view::npos) return 0;
const std::string_view digits = payId.substr(at + 1);
std::int64_t v = 0;
const auto [ptr, ec] =
std::from_chars(digits.data(), digits.data() + digits.size(), v);
if (ec != std::errc{} || ptr != digits.data() + digits.size()) return 0;
return v;
}
std::int64_t NowUnix() {
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
Server::RailConfig Config(const std::filesystem::path& chains,
const std::filesystem::path& pool,
int windowHours = 24) {
return Server::RailConfig{ .mode = "eurc",
.eurcChainsPath = chains,
.eurcPoolPath = pool,
.eurcWindowHours = windowHours };
}
// Two chains on purpose. The first carries a chain_id, so it renders a wallet
// link; the second omits it, so the "watched but not linkable" branch is real
// rather than hypothetical. Both endpoints are unreachable by design — a
// suite that accidentally dialed one would be a suite that fails on a train.
constexpr std::string_view kChains = R"({"chains":[
{"name":"base","rpc":"https://rpc.invalid/base",
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
"decimals":6,"chain_id":8453,"note":"lowest fees"},
{"name":"quiet","rpc":"https://rpc.invalid/quiet",
"contract":"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c"}]})";
// Whatever the checkout hands in as the redirect. This rail has no hosted
// page of its own, so this exact string is what must come back out.
const std::string kOrderPage = "https://catcrafts.net/order/tok";
} // namespace
int main() {
const std::filesystem::path root =
std::filesystem::temp_directory_path() / "catcrafts-eurc-pool";
std::error_code ec;
std::filesystem::remove_all(root, ec);
std::filesystem::create_directories(root, ec);
const std::filesystem::path chains = root / "chains.json";
WriteFile(chains, kChains);
// ── one address, one order ────────────────────────────────────────
{
const std::filesystem::path pool = root / "issue.txt";
// Line 1 is written in checksum case, the way a wallet exports it.
// Everything downstream — the pool, the payId, the covering check —
// compares the lowercase form, so that is what must come back out.
WriteFile(pool, Addr("A1") + "\n" + Addr("b2") + "\n" + Addr("c3") + "\n");
std::unique_ptr<Server::PaymentRail> rail =
Server::MakeEurcRail(Config(chains, pool));
Check(rail != nullptr, "pool: a valid chains file and a valid pool load");
if (rail) {
Check(rail->Name() == "eurc", "rail: names itself — the ledger via prefix");
Check(rail->PollInterval() == std::chrono::seconds(30),
"rail: sweeps at a finality-shaped cadence, not a busy one");
const std::int64_t before = NowUnix();
const std::optional<Server::PaymentLink> first =
rail->CreateLink(57043, "desc", kOrderPage);
const std::optional<Server::PaymentLink> second =
rail->CreateLink(57043, "desc", kOrderPage);
Check(first.has_value() && second.has_value(),
"issue: two checkouts each get a payment link");
if (first && second) {
Check(AddressOf(first->payId) == Addr("a1"),
"issue: the first order gets pool line 1, lowercased",
first->payId);
Check(AddressOf(second->payId) == Addr("b2"),
"issue: the second order gets pool line 2", second->payId);
// The assertion this whole rail exists to satisfy.
Check(AddressOf(first->payId) != AddressOf(second->payId),
"issue: never the same address twice");
// There is no hosted checkout to send the buyer to: the order
// page IS the payment page, so the caller's own URL comes back.
Check(first->payUrl == kOrderPage,
"issue: no provider page — the redirect is handed back",
first->payUrl);
// 24 configured hours = 86400 seconds past the moment of issue.
// Bracketed by readings taken either side of the call, so the
// bound is exact rather than a tolerance that could drift.
const std::int64_t deadline = DeadlineOf(first->payId);
Check(deadline >= before + 86400 && deadline <= NowUnix() + 86400,
"issue: the deadline is the moment of issue plus the window",
std::to_string(deadline - before));
}
// Refused before the pool is touched. A zero or negative total is a
// caller bug, and burning an address for one would spend the single
// resource this rail cannot regenerate on its own.
Check(!rail->CreateLink(0, "desc", kOrderPage).has_value(),
"issue: a zero total buys no address");
Check(!rail->CreateLink(-1, "desc", kOrderPage).has_value(),
"issue: a negative total buys no address");
}
// The cursor is on DISK, not merely in memory: it is the only thing
// standing between a restart and republishing line 1 to a new buyer.
Check(CursorValue(pool) == std::size_t{ 2 },
"cursor: two issued, two burned — and the refusals burned none",
Show(CursorValue(pool)));
// A fresh rail over the SAME two files must resume, never rewind.
std::unique_ptr<Server::PaymentRail> restarted =
Server::MakeEurcRail(Config(chains, pool));
Check(restarted != nullptr, "restart: a partly spent pool still loads");
if (restarted) {
const std::optional<Server::PaymentLink> third =
restarted->CreateLink(57043, "desc", kOrderPage);
Check(third.has_value() && AddressOf(third->payId) == Addr("c3"),
"restart: issues line 3, not line 1",
third ? third->payId : std::string("no link"));
}
Check(CursorValue(pool) == std::size_t{ 3 },
"cursor: the restarted rail advanced the same file",
Show(CursorValue(pool)));
}
// ── what the buyer's wallet is told to send ───────────────────────
{
const std::filesystem::path pool = root / "instructions.txt";
WriteFile(pool, Addr("d1") + "\n" + Addr("d2") + "\n");
std::unique_ptr<Server::PaymentRail> rail =
Server::MakeEurcRail(Config(chains, pool));
Check(rail != nullptr, "instructions: rail loads");
if (rail) {
const std::string payId = Addr("ab") + "@1800000000";
const std::optional<Server::PayInstructions> ins =
rail->Instructions(payId, 57043);
Check(ins.has_value(), "instructions: a well-formed payId renders");
if (ins) {
Check(ins->address == Addr("ab"),
"instructions: the address half is where the money goes",
ins->address);
// EURC is euro-denominated at par, so the token figure IS the
// euro figure: 57043 cents shown as 570.43. No rate, no quote.
Check(ins->amount == "570.43",
"instructions: the amount is the euro total at par",
ins->amount);
Check(ins->deadlineUnix == 1800000000,
"instructions: the deadline travels inside the id");
Check(ins->chains.size() == 2,
"instructions: every watched chain is offered, in file order",
std::to_string(ins->chains.size()));
}
if (ins && ins->chains.size() == 2) {
Check(ins->chains[0].name == "base"
&& ins->chains[0].note == "lowest fees",
"instructions: the first chain is the file's recommendation");
Check(ins->chains[0].contract
== "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42",
"instructions: contract lowercased for the buyer to compare",
ins->chains[0].contract);
// The uint256 is the literal quantity the wallet sends:
// cents x 10^(decimals-2) = 57043 x 10^4 = 570430000 base
// units. A wrong scale charges 10,000x too much or too little,
// and the too-little case never satisfies the covering check in
// CheckPaid — so the order lapses with real money already sat
// at our address, which is the expensive direction.
const std::string expected =
"ethereum:0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42@8453"
"/transfer?address=" + Addr("ab") + "&uint256=570430000";
Check(ins->chains[0].link == expected,
"instructions: EIP-681 link, amount in token base units",
ins->chains[0].link);
// chain_id 0 means "watched, but we cannot name the network in
// a wallet link". The chain is still LISTED so the buyer can
// pay there by hand — an empty link, never a guessed one.
Check(ins->chains[1].name == "quiet" && ins->chains[1].link.empty(),
"instructions: a chain without a chain_id is listed, unlinked",
ins->chains[1].link);
}
// The same id in checksum case resolves to the same address: what
// is published, compared and paid is always the lowercase form.
const std::optional<Server::PayInstructions> upper =
rail->Instructions(Addr("AB") + "@1800000000", 57043);
Check(upper.has_value() && upper->address == Addr("ab"),
"instructions: a mixed-case payId normalises to one address");
Check(!rail->Instructions(payId, 0).has_value(),
"instructions: nothing to ask a buyer for at zero");
Check(!rail->Instructions("garbage", 57043).has_value(),
"instructions: an id that does not split renders nothing");
}
}
// ── an id that cannot identify a payment ──────────────────────────
//
// These are the ONLY CheckPaid inputs this suite may use: each fails to
// split, and SplitPayId runs before BalanceOf, so the answer arrives
// without a single RPC. A truncated or hand-edited ledger line must lapse
// its order — returning Pending here would leave it awaiting payment
// forever, and treating it as an address worth polling would ask a chain
// about a string we never issued.
{
const std::filesystem::path pool = root / "dead.txt";
WriteFile(pool, Addr("e1") + "\n");
std::unique_ptr<Server::PaymentRail> rail =
Server::MakeEurcRail(Config(chains, pool));
Check(rail != nullptr, "dead: rail loads");
if (rail) {
for (const std::string& id : { std::string("not-an-id"),
Addr("ab") + "@notanumber",
std::string("0xdeadbeef@1800000000"),
std::string("@1800000000"),
Addr("ab") + "@" }) {
const std::optional<Server::PaidStatus> st =
rail->CheckPaid(id, 57043);
Check(st.has_value() && st->state == Server::PayState::Dead
&& st->method.empty(),
"dead: a payId that does not split lapses the order", id);
}
}
}
// ── pools this shop refuses to start with ─────────────────────────
//
// Each of these is a STARTUP refusal rather than a runtime surprise. The
// failure a buyer would otherwise meet lands at the one moment they are
// already committed, so it is moved to the moment the operator is watching.
{
// Lines 2 and 4 are one address in two casings. A chain does not care
// about checksum case either, so a "different" line here is the
// address-reuse bug wearing a disguise.
const std::filesystem::path dup = root / "dup.txt";
WriteFile(dup, Addr("11") + "\n" + Addr("AB") + "\n" + Addr("33") + "\n"
+ Addr("ab") + "\n");
Check(Server::MakeEurcRail(Config(chains, dup)) == nullptr,
"refuse: a duplicate address, even in a different case");
// Fatal rather than skipped: a line that does not parse is as likely to
// be a mangled good address as a stray note, and skipping it would
// quietly shorten the list of places we can be paid.
const std::filesystem::path bad = root / "bad.txt";
WriteFile(bad, Addr("11") + "\n0xdeadbeef\n" + Addr("33") + "\n");
Check(Server::MakeEurcRail(Config(chains, bad)) == nullptr,
"refuse: a line that is not an address is fatal, never skipped");
const std::filesystem::path empty = root / "empty.txt";
WriteFile(empty, "");
Check(Server::MakeEurcRail(Config(chains, empty)) == nullptr,
"refuse: an empty pool has nothing to hand out");
Check(Server::MakeEurcRail(Config(chains, root / "absent.txt")) == nullptr,
"refuse: a pool file that does not exist");
const std::filesystem::path good = root / "good.txt";
WriteFile(good, Addr("f1") + "\n");
Check(Server::MakeEurcRail(Config(root / "absent.json", good)) == nullptr,
"refuse: a chains file that does not exist");
// Strict about addresses, not about tidiness: the comment lines, blank
// lines, indentation and CRLF a human produces while topping the pool
// up from the wallet must not be mistaken for a bad pool.
const std::filesystem::path messy = root / "messy.txt";
WriteFile(messy, "# topped up 2026-08-15 from the cold wallet\n"
"\n"
" " + Addr("d4") + " # first of the batch\n"
+ Addr("e5") + " \t\r\n"
"\n");
std::unique_ptr<Server::PaymentRail> tidy =
Server::MakeEurcRail(Config(chains, messy));
Check(tidy != nullptr, "accept: comments, blank lines, indent and trailing CR");
if (tidy) {
// Proves the stripping produced the ADDRESS and not the decoration
// around it — a loader that stored " 0x…d4" would still "load".
const std::optional<Server::PaymentLink> link =
tidy->CreateLink(1000, "desc", kOrderPage);
Check(link.has_value() && AddressOf(link->payId) == Addr("d4"),
"accept: the comment and the indent are stripped, not stored",
link ? link->payId : std::string("no link"));
}
// Exhausted is a refusal, not a wrap-around. Wrapping would reissue
// addresses already sitting in somebody's wallet app.
const std::filesystem::path used = root / "used.txt";
WriteFile(used, Addr("21") + "\n" + Addr("22") + "\n");
WriteFile(CursorOf(used), "2\n");
Check(Server::MakeEurcRail(Config(chains, used)) == nullptr,
"refuse: the cursor says every address in the pool is spent");
// An unreadable cursor reads as exhausted, never as zero. Rewinding to
// the top of a pool whose head is already published is the duplicate
// bug again, arriving through a corrupted file instead of a typo.
const std::filesystem::path garbled = root / "garbled.txt";
WriteFile(garbled, Addr("31") + "\n" + Addr("32") + "\n");
WriteFile(CursorOf(garbled), "x");
Check(Server::MakeEurcRail(Config(chains, garbled)) == nullptr,
"refuse: an unparseable cursor never rewinds to line 1");
}
// ── the payment window ────────────────────────────────────────────
{
const std::filesystem::path pool = root / "window.txt";
WriteFile(pool, Addr("91") + "\n" + Addr("92") + "\n");
// Zero hours is not "no window": there is no processor to expire
// anything here, so an unset value has to mean the generous default
// rather than a deadline that is already in the past at issue time.
std::unique_ptr<Server::PaymentRail> dflt =
Server::MakeEurcRail(Config(chains, pool, 0));
Check(dflt != nullptr, "window: the default-window rail loads");
if (dflt) {
const std::int64_t before = NowUnix();
const std::optional<Server::PaymentLink> link =
dflt->CreateLink(1000, "desc", kOrderPage);
Check(link.has_value()
&& DeadlineOf(link->payId) >= before + 24 * 3600
&& DeadlineOf(link->payId) <= NowUnix() + 24 * 3600,
"window: an unset window is 24 hours, not zero");
}
// The same pool, one address further along, with the hours set.
std::unique_ptr<Server::PaymentRail> hour =
Server::MakeEurcRail(Config(chains, pool, 1));
Check(hour != nullptr, "window: a one-hour rail loads on the same pool");
if (hour) {
const std::int64_t before = NowUnix();
const std::optional<Server::PaymentLink> link =
hour->CreateLink(1000, "desc", kOrderPage);
Check(link.has_value()
&& DeadlineOf(link->payId) >= before + 3600
&& DeadlineOf(link->payId) <= NowUnix() + 3600,
"window: the configured hours are what the id carries");
}
}
std::filesystem::remove_all(root, ec);
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -94,8 +94,8 @@ void OpenShopLifecycle(TestServer& srv) {
{ {
const std::string ledger = srv.OrdersText(); const std::string ledger = srv.OrdersText();
Check(ledger.find("\"country\":\"NL\"") != std::string::npos Check(ledger.find("\"country\":\"NL\"") != std::string::npos
&& ledger.find("\"total_minor\":57830") != std::string::npos, && ledger.find("\"total_minor\":58880") != std::string::npos,
"order stored: NL total is €578.30 (green €563.30 + €15 shipping)"); "order stored: NL total is €588.80 (green €573.80 + €15 shipping)");
// No `pay` field in that submission, which is what a form with only // No `pay` field in that submission, which is what a form with only
// one rail configured posts: it must land on the bank rail rather // one rail configured posts: it must land on the bank rail rather
// than nothing. // than nothing.
@ -109,7 +109,7 @@ void OpenShopLifecycle(TestServer& srv) {
{ {
const std::string page = srv.Body(orderPath); const std::string page = srv.Body(orderPath);
for (std::string_view probe : { "awaiting payment", "Resume payment", "CC-", for (std::string_view probe : { "awaiting payment", "Resume payment", "CC-",
"http-equiv=\"refresh\"", "€578.30" }) { "http-equiv=\"refresh\"", "€588.80" }) {
Check(page.find(probe) != std::string::npos, Check(page.find(probe) != std::string::npos,
std::format("order page has {}", probe)); std::format("order page has {}", probe));
} }
@ -173,8 +173,8 @@ void OpenShopLifecycle(TestServer& srv) {
Check(!tokenGb.empty(), "GB checkout issues an order"); Check(!tokenGb.empty(), "GB checkout issues an order");
if (!tokenGb.empty()) { if (!tokenGb.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenGb)); const std::string page = srv.Body(std::format("/order/{}", tokenGb));
// €465.54 goods (green net) + €55 world shipping = €520.54 // €474.21 goods (green net) + €55 world shipping = €529.21
Check(page.find("€520.54") != 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");
@ -184,14 +184,14 @@ void OpenShopLifecycle(TestServer& srv) {
"conversion is labelled indicative"); "conversion is labelled indicative");
} }
// A two-unit white export order: unit €665, line €1330, net from the LINE // A two-unit white export order: unit €665.38, line €1330.76, net from the
// total (not per unit) = €1082.45, plus €55 world shipping = €1137.45. // 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=GB&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));
Check(page.find("€1137.45") != std::string::npos, Check(page.find("€1154.80") != std::string::npos,
"white ×2 export total nets the line, not the unit"); "white ×2 export total nets the line, not the unit");
Check(page.find("Device × 2") != std::string::npos, "order page shows the quantity"); Check(page.find("Device × 2") != std::string::npos, "order page shows the quantity");
Check(page.find("White") != std::string::npos, "order page names the colour"); Check(page.find("White") != std::string::npos, "order page names the colour");
@ -286,7 +286,7 @@ void OpenShopLifecycle(TestServer& srv) {
for (std::string_view probe : { "BEGIN PGP SIGNED MESSAGE", "# Invoice ", for (std::string_view probe : { "BEGIN PGP SIGNED MESSAGE", "# Invoice ",
"Customer number: ", "Chico Mendesring 256", "Customer number: ", "Chico Mendesring 256",
"KVK 78437059", "NL003329281B38", "CC-", "KVK 78437059", "NL003329281B38", "CC-",
"VAT 21% (NL)", "€578.30" }) { "VAT 21% (NL)", "€588.80" }) {
Check(invoice.body.find(probe) != std::string::npos, Check(invoice.body.find(probe) != std::string::npos,
std::format("invoice has {}", probe)); std::format("invoice has {}", probe));
} }
@ -372,7 +372,7 @@ void OpenShopLifecycle(TestServer& srv) {
if (!nlMail.empty()) { if (!nlMail.empty()) {
for (std::string_view probe : { "To: e2e@example.org", "Subject: Catcrafts order CC-", for (std::string_view probe : { "To: e2e@example.org", "Subject: Catcrafts order CC-",
"From: Catcrafts <info@catcrafts.net>", "From: Catcrafts <info@catcrafts.net>",
"MIME-Version: 1.0", "€578.30", "incl. 21% NL VAT", "MIME-Version: 1.0", "€588.80", "incl. 21% NL VAT",
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE", "KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
"filename=\"catcrafts-invoice-" }) { "filename=\"catcrafts-invoice-" }) {
Check(nlMail.find(probe) != std::string::npos, Check(nlMail.find(probe) != std::string::npos,
@ -495,6 +495,186 @@ void AlwaysOnValidation(TestServer& srv) {
srv.CheckStatus("/shop/nope", "404", "POST", Good()); // unknown product srv.CheckStatus("/shop/nope", "404", "POST", Good()); // unknown product
} }
// The donation item is open in BOTH shop states — it is the soft opening the
// coming-soon phone waits behind — so this whole lifecycle runs
// unconditionally: create with a buyer-named amount, settle on the fake rail,
// confirm there is no invoice and no VAT, and watch /financials book it under
// donations rather than sales.
void DonationLifecycle(TestServer& srv) {
// ── validation ────────────────────────────────────────────────────
// The donation validator's refusals, over real HTTP. No address fields
// exist to miss; the amount is the field that carries the rules.
srv.CheckStatus("/shop/donation", "422", "POST", "email=a%40b.example"); // no amount
srv.CheckStatus("/shop/donation", "422", "POST", "amount=nonsense");
srv.CheckStatus("/shop/donation", "422", "POST", "amount=0.50"); // below €1
srv.CheckStatus("/shop/donation", "422", "POST", "amount=10000.01"); // above €10k
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&email=nonsense");
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&website=spam"); // honeypot
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&pay=free");
{
// A refused amount comes back in the form, like any rejected field.
const auto refused = srv.Post("/shop/donation", "amount=0.50");
Check(refused.body.find("field__error") != std::string::npos,
"a refused donation shows a field error");
Check(refused.body.find("€1 to €10000") != std::string::npos,
"the amount refusal names the bounds");
}
// ── a donation with no email at all ───────────────────────────────
// Identity is optional: the capability URL is the receipt.
const auto created = srv.Post("/shop/donation", "amount=25");
const std::string token = TokenOf(created);
Check(created.status == "303" && !token.empty(),
"POST donation -> 303 straight to payment", created.status);
{
const std::string line = [&] {
for (const std::string& l : LedgerLines(srv)) {
if (l.find(std::format("\"id\":\"{}\"", token)) != std::string::npos
&& l.find("\"type\":\"order\"") != std::string::npos) {
return l;
}
}
return std::string{};
}();
Check(!line.empty(), "the donation order is on the ledger");
for (std::string_view probe : { "\"donation\":true", "\"total_minor\":2500",
"\"shipping_minor\":0", "\"vat_included\":false",
"\"product\":\"donation\"", "\"email\":\"\"" }) {
Check(line.find(probe) != std::string::npos,
std::format("donation ledger line has {}", probe));
}
}
const std::string orderPath = std::format("/order/{}", token);
{
const std::string page = srv.Body(orderPath);
Check(page.find("€25") != std::string::npos,
"donation order page shows the amount");
Check(page.find("Shipping") == std::string::npos,
"donation order page has no shipping row");
}
// ── the payment lands ─────────────────────────────────────────────
// Same fake-rail marker as checkout (idempotent if the open-shop half
// already created it). The paid state is a thank-you, not a dispatch
// promise, and there is no invoice to download — not before, not after.
const std::size_t invoicesBefore =
CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"");
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
WriteFile(std::filesystem::path(srv.Orders().string() + ".fake-paid"), "");
{
const std::string page = srv.WaitForBody(orderPath, "Thank you");
Check(page.find("Thank you") != std::string::npos,
"a paid donation says thank you");
Check(page.find("VAT 0%") != std::string::npos,
"a paid donation states the 0% VAT treatment");
Check(page.find("Zero-rated export") == std::string::npos,
"a donation is not worded as an export");
Check(page.find("invoice.md") == std::string::npos,
"a paid donation offers no invoice download");
Check(page.find("flashed and tested") == std::string::npos,
"a paid donation promises no dispatch");
}
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
// Settle a moment: no invoice event may appear for a donation, ever.
SettleUntil([&] {
return srv.OrdersText().find(std::format("\"id\":\"{}\",\"status\":\"paid\"", token))
!= std::string::npos;
});
Check(CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"") == invoicesBefore,
"a paid donation is assigned no invoice number");
// ── /financials books it under donations ──────────────────────────
// Ledger-derived, same rule as the sales assertion: paid donation orders
// sum into the donations pair, paid goods orders into sales, and no euro
// sits in both.
{
std::set<std::string> paidIds;
const std::vector<std::string> lines = LedgerLines(srv);
for (const std::string& line : lines) {
const auto event = Json::Parse(line);
if (!event || !event->IsObject()) continue;
if (event->Str("type") == "status" && event->Str("status") == "paid") {
paidIds.insert(std::string(event->Str("id")));
}
}
std::int64_t wantSales = 0, wantDonations = 0;
std::size_t wantSalesCount = 0, wantDonationCount = 0;
for (const std::string& line : lines) {
const auto event = Json::Parse(line);
if (!event || !event->IsObject()) continue;
if (event->Str("type") != "order"
|| !paidIds.contains(std::string(event->Str("id")))) {
continue;
}
if (event->Bool("donation")) {
wantDonations += event->Int("total_minor");
++wantDonationCount;
} else {
wantSales += event->Int("total_minor");
++wantSalesCount;
}
}
const std::string page = srv.Body("/financials");
Check(wantDonationCount > 0, "at least one paid donation is on the ledger");
Check(page.find(std::format("data-fin-donations-count=\"{}\"", wantDonationCount))
!= std::string::npos
&& page.find(std::format("data-fin-donations-minor=\"{}\"", wantDonations))
!= std::string::npos,
std::format("donation totals equal the ledger ({} donations, {} cents)",
wantDonationCount, wantDonations));
Check(page.find(std::format("data-fin-sales-count=\"{}\"", wantSalesCount))
!= std::string::npos
&& page.find(std::format("data-fin-sales-minor=\"{}\"", wantSales))
!= std::string::npos,
"sales totals exclude the donation");
}
// ── the confirmation email ────────────────────────────────────────
// With an email given: a thank-you, no invoice attached (none exists).
// Without one: silence — no address means the donor asked for nothing.
const std::size_t mailsBefore = MailCount(srv);
const std::string tokenMailed = TokenOf(srv.Post("/shop/donation",
"amount=10&email=donor%40example.org"));
Check(!tokenMailed.empty(), "a donation with an email goes through");
SettleUntil([&] { return MailCount(srv) > mailsBefore; }, 60);
std::string donationMail;
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
const std::string name = entry.path().filename().string();
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
const std::string mail = ReadFile(entry.path());
if (mail.find(std::format("/order/{}", tokenMailed)) != std::string::npos) {
donationMail = mail;
}
}
Check(!donationMail.empty(), "a donation with an email gets a confirmation");
if (!donationMail.empty()) {
for (std::string_view probe : { "To: donor@example.org",
"Subject: Catcrafts donation CC-",
"Thank you", "€10",
"No VAT applies" }) {
Check(donationMail.find(probe) != std::string::npos,
std::format("donation email has {}", probe));
}
Check(donationMail.find("BEGIN PGP SIGNED MESSAGE") == std::string::npos
&& donationMail.find("filename=\"catcrafts-invoice-") == std::string::npos,
"donation email attaches no invoice");
}
// The no-email donation stays unmailed: sit out two mailer sweeps and
// expect no message carrying its link.
std::this_thread::sleep_for(std::chrono::seconds(5));
bool mailedAnyway = false;
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
const std::string name = entry.path().filename().string();
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
if (ReadFile(entry.path()).find(std::format("/order/{}", token))
!= std::string::npos) {
mailedAnyway = true;
}
}
Check(!mailedAnyway, "a donation without an email is never emailed");
}
// The re-rendered form only exists when the shop is open; while coming-soon a // The re-rendered form only exists when the shop is open; while coming-soon a
// rejection answers with the coming-soon page instead. // rejection answers with the coming-soon page instead.
void RejectedFormEcho(TestServer& srv) { void RejectedFormEcho(TestServer& srv) {
@ -582,6 +762,9 @@ int main(int argc, char** argv) {
AlwaysOnValidation(srv); AlwaysOnValidation(srv);
// The donation item is open in both shop states — that is the point of it.
DonationLifecycle(srv);
if (srv.ShopOpen()) { if (srv.ShopOpen()) {
RejectedFormEcho(srv); RejectedFormEcho(srv);
} }

View file

@ -104,6 +104,41 @@ void FinancialsPage() {
Check(Money::FormatEuro(-26260) == "€-262.60" && Money::FormatEuro(-500) == "€-5.00", Check(Money::FormatEuro(-26260) == "€-262.60" && Money::FormatEuro(-500) == "€-5.00",
"financials: negative euro formatting"); "financials: negative euro formatting");
// Donations paid through the shop join the bank-side ones in ONE row —
// the reader has no use for a split by collection channel. The income
// total and the net move with them.
{
const Views::RenderedPage both = Views::RenderFinancials(2, 113745, fin, 2, 5000);
Check(both.main.View().find("data-fin-donations-count=\"5\"") != std::string_view::npos
&& both.main.View().find("data-fin-donations-minor=\"9500\"")
!= std::string_view::npos,
"financials: shop and bank donations sum into one row");
Check(both.main.View().find("Donations (5)") != std::string_view::npos,
"financials: the donation row counts both sources");
// Net = (4500 + 5000 + 113745) - 234800 = -111555.
Check(both.main.View().find("data-fin-net-minor=\"-111555\"") != std::string_view::npos,
"financials: the net includes shop donations");
}
// A shop donation shows the moment it is paid, even before any bank
// figures exist — it is live from the order ledger, like sales. The
// income total still waits for the bank side: a total missing half its
// inputs is not a total.
{
const Views::RenderedPage shopOnly = Views::RenderFinancials(0, 0, Financials{}, 1, 2500);
Check(shopOnly.main.View().find("data-fin-donations-count=\"1\"") != std::string_view::npos
&& shopOnly.main.View().find("data-fin-donations-minor=\"2500\"")
!= std::string_view::npos,
"financials: a shop donation publishes without bank figures");
Check(shopOnly.main.View().find("Donations (1)") != std::string_view::npos,
"financials: and renders its row");
// The Income SECTION heading always renders; what must wait for the
// bank side is the ruled-off total row (and the net).
Check(shopOnly.main.View().find(R"(<tr class="fin-total"><th scope="row">Income</th>)")
== std::string_view::npos
&& shopOnly.main.View().find("data-fin-net-minor") == std::string_view::npos,
"financials: no income total or net while the bank side is unpublished");
}
// Before the bank figures exist the page says so instead of lying // Before the bank figures exist the page says so instead of lying
// with zeros — and publishes no donation figures at all. // with zeros — and publishes no donation figures at all.
const Views::RenderedPage bare = Views::RenderFinancials(0, 0, Financials{}); const Views::RenderedPage bare = Views::RenderFinancials(0, 0, Financials{});
@ -133,10 +168,19 @@ void FinancialsPage() {
Server::OrderRecord shipped; Server::OrderRecord shipped;
shipped.totalMinor = 200; shipped.totalMinor = 200;
shipped.status = "shipped"; shipped.status = "shipped";
const std::array<Server::OrderRecord, 4> orders{ paid, waiting, refunded, shipped }; // A paid donation is income but not a sale: it must land in the donation
// pair, or the page would book the same euro as a sale.
Server::OrderRecord gift;
gift.totalMinor = 2500;
gift.donation = true;
gift.paidAt = "2026-08-17T00:00:00Z";
gift.status = "paid";
const std::array<Server::OrderRecord, 5> orders{ paid, waiting, refunded, shipped, gift };
const Server::SalesSummary sum = Server::SummarizeSales(orders); const Server::SalesSummary sum = Server::SummarizeSales(orders);
Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200, Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200,
"financials: sales count ever-paid orders only"); "financials: sales count ever-paid orders only, donations excluded");
Check(sum.donationCount == 1 && sum.donationsMinor == 2500,
"financials: a paid shop donation folds into the donation pair");
Check(Server::SummarizeSales({}).count == 0, Check(Server::SummarizeSales({}).count == 0,
"financials: empty ledger sums to zero"); "financials: empty ledger sums to zero");
} }
@ -178,6 +222,42 @@ void BunqIngest() {
.has_value(), .has_value(),
"bunq: a notification with no mutation yields nothing"); "bunq: a notification with no mutation yields nothing");
// The same payload with one field swapped, so every case below differs
// from the parsing case above by exactly the thing under test.
auto payloadWith = [](std::string_view amountObject, std::string_view alias) {
std::string out;
out += R"({"NotificationUrl":{"category":"MUTATION","object":{"Payment":{)";
out += R"("id":4823,"created":"2026-08-14 09:31:02.123456",)";
out += R"("monetary_account_id":9911,"amount":)";
out += amountObject;
out += R"(,"description":"Thanks for imsd!","counterparty_alias":)";
out += alias;
out += R"(}}}})";
return out;
};
constexpr std::string_view kDonorAlias =
R"({"iban":"NL55BUNQ2025123456","display_name":"A Donor"})";
// FindPaymentObject matches on the SHAPE — an amount object carrying a
// value, plus an id — and never looks at what the value SAYS. So a
// locale-mangled or hostile amount reaches the parser inside an otherwise
// perfectly well-formed mutation, and the refusal has to happen here. If
// it ever softened to a zero fallback the mutation would be recorded as
// seen, permanently deduped, with the money dropped from the totals and
// nothing in the operator log to say so.
Check(!Server::ParseBunqMutation(
payloadWith(R"({"currency":"EUR","value":"25,00"})", kDonorAlias))
.has_value(),
"bunq: a comma decimal is refused rather than read as zero");
Check(!Server::ParseBunqMutation(
payloadWith(R"({"currency":"EUR","value":"1.234"})", kDonorAlias))
.has_value(),
"bunq: a third fraction digit is refused rather than truncated");
Check(!Server::ParseBunqMutation(
payloadWith(R"({"currency":"EUR","value":"abc"})", kDonorAlias))
.has_value(),
"bunq: a non-numeric amount is refused");
const Server::FinancialRules rules = Server::LoadFinancialRules( const Server::FinancialRules rules = Server::LoadFinancialRules(
R"({"donation_accounts":[9911],)" R"({"donation_accounts":[9911],)"
R"("rules":[)" R"("rules":[)"
@ -223,6 +303,31 @@ void BunqIngest() {
Check(Server::ClassifyMutation(x, rules).group.empty(), Check(Server::ClassifyMutation(x, rules).group.empty(),
"bunq: an unmatched mutation is withheld, not guessed"); "bunq: an unmatched mutation is withheld, not guessed");
// A payload with no "currency" key at all still has the shape the parser
// needs, so it parses — and is then refused by the classifier, which is
// where the euro-only rule lives. Nothing reaches a euro total on the
// strength of a field that was never sent.
const auto noCurrency =
Server::ParseBunqMutation(payloadWith(R"({"value":"25.00"})", kDonorAlias));
Check(noCurrency && noCurrency->amountMinor == 2500 && noCurrency->currency.empty(),
"bunq: an amount with no currency still parses");
Check(noCurrency && Server::ClassifyMutation(*noCurrency, rules).group.empty(),
"bunq: an unstated currency is never assumed to be euro");
// bunq's other alias flavour nests the IBAN one level down, under
// "labelMonetaryAccount". If that fallback broke, the IBAN would come
// back empty, the owner's own transfer INTO the donation account would
// stop matching its ignore rule, and the donation-account default would
// publish the owner's own money as a stranger's gift — on the one page
// whose entire promise is that the number is true.
const auto nested = Server::ParseBunqMutation(payloadWith(
R"({"currency":"EUR","value":"25.00"})",
R"({"labelMonetaryAccount":{"iban":"NL01OWNSELF0000000","display_name":"Self"}})"));
Check(nested && nested->counterpartyIban == "NL01OWNSELF0000000",
"bunq: the nested alias flavour still yields an iban");
Check(nested && Server::ClassifyMutation(*nested, rules).group == "ignore",
"bunq: the owner's own transfer in is ignored, whichever alias shape carries it");
Server::BankMutation bill; Server::BankMutation bill;
bill.currency = "EUR"; bill.currency = "EUR";
bill.amountMinor = -1200; bill.amountMinor = -1200;
@ -257,6 +362,109 @@ void BunqIngest() {
Check(fin.donationCount == before.donationCount Check(fin.donationCount == before.donationCount
&& fin.ExpensesMinor() == before.ExpensesMinor(), && fin.ExpensesMinor() == before.ExpensesMinor(),
"bunq: an unclassified mutation changes no total"); "bunq: an unclassified mutation changes no total");
// A REFUNDED gift. Reachable because an explicit rule may name a group
// outright, so "donations" is not the exclusive property of the
// incoming-only account default tested above.
const Server::FinancialRules donationRules = Server::LoadFinancialRules(
R"({"rules":[{"iban":"NL55BUNQ2025123456","group":"donations"}]})");
Server::BankMutation giftBack = *m;
giftBack.amountMinor = -1000;
const Server::MutationClass backClass =
Server::ClassifyMutation(giftBack, donationRules);
Check(backClass.group == "donations",
"bunq: an explicit rule can classify outgoing money as a donation");
// The count follows money IN, never money out: 2500 - 1000 = 1500, and
// the one person who gave still gave. Decrementing here would put the
// published donor count below the number of people who actually donated,
// and the weekly reconciliation folds through this same function — it
// would reproduce the wrong figure rather than correct it.
Financials gifts;
gifts.donationsMinor = 2500;
gifts.donationCount = 1;
Server::ApplyMutation(gifts, backClass, giftBack);
Check(gifts.donationsMinor == 1500 && gifts.donationCount == 1,
"bunq: a refunded gift reduces the total and leaves the count alone");
// Two expenses under different labels are two rows, in first-seen order.
// Merging them would hide what the money went on behind one bigger
// number, which is the opposite of what this page is for.
Server::BankMutation supplier;
supplier.currency = "EUR";
supplier.amountMinor = -5000;
supplier.counterpartyIban = "DE02SUPPLIER000000";
supplier.created = "2026-08-16";
const Server::MutationClass supplierClass = Server::ClassifyMutation(supplier, rules);
Check(supplierClass.group == "expense" && supplierClass.label == "Inventory",
"bunq: iban matching picks the supplier's category");
Financials twoCats;
Server::ApplyMutation(twoCats, billClass, bill); // -1200 out → +1200 Hosting
Server::ApplyMutation(twoCats, supplierClass, supplier); // -5000 out → +5000 Inventory
Check(twoCats.expenses.size() == 2
&& twoCats.expenses[0].label == "Hosting"
&& twoCats.expenses[0].totalMinor == 1200
&& twoCats.expenses[1].label == "Inventory"
&& twoCats.expenses[1].totalMinor == 5000,
"bunq: distinct labels become distinct rows, in first-seen order");
Check(twoCats.ExpensesMinor() == 6200, "bunq: the expense total is the sum of its rows");
}
// ── the callback gate ─────────────────────────────────────────────────
//
// The one endpoint that writes public money figures, and the only thing
// standing in front of it. Driven through ConfigureFinancials because that is
// how the real server reaches it; no key material and no network are needed
// to pin the parts that matter.
void CallbackGate() {
// Unconfigured: the path is a plain 404 and nothing authorises. An
// endpoint that is off should not announce itself by answering
// differently to a well-formed guess than to an empty one.
Server::ConfigureFinancials(Server::FinancialsConfig{});
Check(!Server::BunqCallbackConfigured(),
"callback: with no secret the endpoint does not exist");
Check(!Server::BunqCallbackAuthorised("", "{}", ""),
"callback: an empty secret authorises nothing while unconfigured");
Check(!Server::BunqCallbackAuthorised("s3cret-not-real", "{}", ""),
"callback: even a well-formed secret is refused while unconfigured");
Server::FinancialsConfig cfg;
cfg.callbackSecret = "s3cret-not-real"; // never a live one: the real
// secret only ever comes from
// the environment on the box
Server::ConfigureFinancials(cfg);
Check(Server::BunqCallbackAuthorised("s3cret-not-real", "{}", ""),
"callback: the exact secret is authorised");
// SecretEqual folds a length mismatch into the same accumulator as the
// byte differences, so neither a prefix nor an extension can return early
// — a plain == would leak the secret one byte at a time through timing,
// and the secret sits in the URL where it can be probed a request at a
// time.
Check(!Server::BunqCallbackAuthorised("s3cret-not-rea", "{}", ""),
"callback: a prefix of the secret is refused");
Check(!Server::BunqCallbackAuthorised("s3cret-not-realX", "{}", ""),
"callback: an extension of the secret is refused");
Check(!Server::BunqCallbackAuthorised("", "{}", ""),
"callback: an empty secret never matches a configured one");
// A secret with nowhere to write the aggregates is still no endpoint:
// this is what keeps the path a 404 on a box that has the env var but
// not the storage.
Check(!Server::BunqCallbackConfigured(),
"callback: a secret without an aggregates path leaves the endpoint off");
cfg.publicPath = "/nonexistent-catcrafts/financials.json";
Server::ConfigureFinancials(cfg);
Check(Server::BunqCallbackConfigured(),
"callback: secret plus aggregates path is what turns the endpoint on");
// Turning signature checking ON must never become a no-op. With a key
// path that cannot be read there is no way to verify anything, so the
// CORRECT secret now fails too — closed, not open.
cfg.publicKeyPem = "/nonexistent-catcrafts/bunq-public-key.pem";
Server::ConfigureFinancials(cfg);
Check(!Server::BunqCallbackAuthorised("s3cret-not-real", "{}", "YWJj"),
"callback: signature checking with an unreadable key fails closed");
Server::ConfigureFinancials(Server::FinancialsConfig{}); // leave no global behind
} }
} // namespace } // namespace
@ -264,6 +472,7 @@ void BunqIngest() {
int main() { int main() {
FinancialsPage(); FinancialsPage();
BunqIngest(); BunqIngest();
CallbackGate();
if (failures != 0) { if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures); std::println(std::cerr, "{} check(s) failed", failures);

View file

@ -21,15 +21,15 @@ int main(int argc, char** argv) {
// The price is rendered from the same integers the checkout charges, with // The price is rendered from the same integers the checkout charges, with
// the derived ex-VAT twin alongside — asserting both pins the arithmetic. // the derived ex-VAT twin alongside — asserting both pins the arithmetic.
srv.BodyHas("/shop/fp6-pmos", "€563.30", srv.BodyHas("/shop/fp6-pmos", "€573.80",
"product page shows the from-price (green supplier + €50)"); "product page shows the from-price (green supplier + €60.50 gross markup)");
srv.BodyHas("/shop/fp6-pmos", "€465.54", "product page shows the derived ex-VAT price"); srv.BodyHas("/shop/fp6-pmos", "€474.21", "product page shows the derived ex-VAT price");
srv.BodyHas("/shop/fp6-pmos", ">from<", "product page marks the price as a from-price"); srv.BodyHas("/shop/fp6-pmos", ">from<", "product page marks the price as a from-price");
srv.BodyHas("/shop", "€563.30", "shop card shows the from-price"); srv.BodyHas("/shop", "€573.80", "shop card shows the from-price");
// Every colour is priced in the selector, and the form carries the exact // Every colour is priced in the selector, and the form carries the exact
// data blob the preview computes from. // data blob the preview computes from.
srv.BodyHas("/shop/fp6-pmos", "Black &mdash; €569.30", "colour selector prices black"); srv.BodyHas("/shop/fp6-pmos", "Black &mdash; €579.80", "colour selector prices black");
srv.BodyHas("/shop/fp6-pmos", "White &mdash; €654.88", "colour selector prices white"); srv.BodyHas("/shop/fp6-pmos", "White &mdash; €665.38", "colour selector prices white");
if (srv.ShopOpen()) { if (srv.ShopOpen()) {
srv.BodyHas("/shop/fp6-pmos", "data-cc=", "form embeds the pricing blob"); srv.BodyHas("/shop/fp6-pmos", "data-cc=", "form embeds the pricing blob");
srv.BodyHas("/shop/fp6-pmos", "id=\"cc-total\"", "live total element present"); srv.BodyHas("/shop/fp6-pmos", "id=\"cc-total\"", "live total element present");
@ -38,6 +38,29 @@ int main(int argc, char** argv) {
srv.BodyHas("/shop", "coming soon", "shop card carries the coming-soon badge"); srv.BodyHas("/shop", "coming soon", "shop card carries the coming-soon badge");
srv.BodyLacks("/shop/fp6-pmos", "<form", "no order form while coming soon"); srv.BodyLacks("/shop/fp6-pmos", "<form", "no order form while coming soon");
} }
// The donation item: open while the phone above may not be — the shop's
// soft opening. Its card quotes no price (there is none), its page is a
// form asking an amount and, optionally, an email — never an address.
srv.BodyHas("/shop", "/shop/donation", "shop grid lists the donation item");
srv.BodyHas("/shop", "any amount", "donation card quotes no price");
srv.BodyHas("/shop/donation", "name=\"amount\"", "donation form asks for an amount");
srv.BodyHas("/shop/donation", "Donate &mdash; continue to payment",
"donation form submits to payment");
srv.BodyLacks("/shop/donation", "name=\"street\"",
"donation form asks no address — nothing ships");
srv.BodyLacks("/shop/donation", "name=\"quantity\"",
"donation form has no quantity — one gift, one line");
srv.BodyHas("/shop/donation", "No VAT is charged on a donation",
"donation page states the VAT treatment");
srv.BodyHas("/shop/donation", "aggregate total",
"donation page states how it appears on the financials page");
// Both rails are configured in this harness, so the donation offers the
// same payment choice checkout does.
srv.BodyHas("/shop/donation", "value=\"crypto\"", "donation form offers crypto");
// No price, no conversions, no preview: the donation page ships NO
// executable script at all — stricter than the shop pages' one-script rule.
srv.BodyLacks("/shop/donation", "<script>", "donation page ships no executable script");
srv.BodyHas("/shop/fp6-pmos", "src=\"/fp6-pmos.jpg\"", "product page embeds the photo"); srv.BodyHas("/shop/fp6-pmos", "src=\"/fp6-pmos.jpg\"", "product page embeds the photo");
srv.BodyHas("/shop", "src=\"/fp6-pmos.jpg\"", "shop card embeds the thumbnail"); srv.BodyHas("/shop", "src=\"/fp6-pmos.jpg\"", "shop card embeds the thumbnail");
// The image file itself is Caddy's to serve (static asset), so its // The image file itself is Caddy's to serve (static asset), so its
@ -56,7 +79,7 @@ int main(int argc, char** argv) {
srv.BodyHas("/shop", "class=\"price__single\"", "shop card renders the single-number price"); srv.BodyHas("/shop", "class=\"price__single\"", "shop card renders the single-number price");
srv.BodyHas("/shop", "data-gbp=\"", "shop card carries a GBP conversion"); srv.BodyHas("/shop", "data-gbp=\"", "shop card carries a GBP conversion");
srv.BodyHas("/shop", "data-sek=\"~kr ", "shop card carries an SEK conversion"); srv.BodyHas("/shop", "data-sek=\"~kr ", "shop card carries an SEK conversion");
srv.BodyHas("/shop", "data-world=\"€465.54\"", "shop card carries the euro export fallback"); srv.BodyHas("/shop", "data-world=\"€474.21\"", "shop card carries the euro export fallback");
srv.BodyLacks("/shop", "data-usd=", "no USD price for a country the shop refuses"); srv.BodyLacks("/shop", "data-usd=", "no USD price for a country the shop refuses");
srv.BodyLacks("/shop", "data-cad=", "no CAD price for a country the shop refuses"); srv.BodyLacks("/shop", "data-cad=", "no CAD price for a country the shop refuses");
// The product page gets the same headline element, so a British visitor // The product page gets the same headline element, so a British visitor

View file

@ -31,8 +31,11 @@ void CatalogueContract() {
using namespace Catcrafts::Money; using namespace Catcrafts::Money;
const auto& products = Content::Products(); const auto& products = Content::Products();
Check(products.size() == 1, "content: one product"); // Two entries: the phone, and the donation item that soft-opens the shop.
if (products.size() == 1) { // The phone stays FIRST — it is the headline, and the suites below
// address Products()[0] as the priced product.
Check(products.size() == 2, "content: two products");
if (products.size() == 2) {
const Product& pr = products[0]; const Product& pr = products[0];
Check(pr.slug == "fp6-pmos", "content: product slug"); Check(pr.slug == "fp6-pmos", "content: product slug");
// Coming-soon is the pre-launch state; launch flips it to // Coming-soon is the pre-launch state; launch flips it to
@ -40,17 +43,52 @@ void CatalogueContract() {
Check(pr.Buyable() || pr.ComingSoon(), Check(pr.Buyable() || pr.ComingSoon(),
"content: product is buyable or deliberately coming soon"); "content: product is buyable or deliberately coming soon");
Check(pr.variants.size() == 3, "content: three colours"); Check(pr.variants.size() == 3, "content: three colours");
// Cost-plus pricing, derived in code: supplier + €50, exactly. // Cost-plus pricing, derived in code: supplier + €60.50 gross markup,
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 56330, // exactly — the gross-up of the €50 Catcrafts keeps after VAT. The
"content: green = 513.30 supplier + 50 markup"); // margin identity itself (net of retail = net of supplier + 5000) is
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 56930, // asserted below; these pin the resulting stickers.
"content: black = 519.30 supplier + 50 markup"); Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 57380,
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 65488, "content: green = 513.30 supplier + 60.50 gross markup");
"content: white = 604.88 supplier + 50 markup"); Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 57980,
"content: black = 519.30 supplier + 60.50 gross markup");
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 66538,
"content: white = 604.88 supplier + 60.50 gross markup");
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null"); Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
Check(pr.priceInclMinor == 56330, "content: from-price is the cheapest variant"); Check(pr.priceInclMinor == 57380, "content: from-price is the cheapest variant");
// The pricing rule as the user states it: after shipping (a pass-
// through) and VAT, every unit sold walks away with €50.00 — however
// the supplier moves. Checked against the COMPILED catalogue, per
// variant, in the same arithmetic the invoice and checkout use:
// net(retail) - net(supplier) must be exactly 5000 minor. Shipping
// has its own round-trip guarantee in ShouldComputeMoney, and
// Mollie's per-transaction fee is the one accepted deviation.
for (const auto& [slug, supplier] :
std::initializer_list<std::pair<std::string_view, std::int64_t>>{
{ "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) {
const Variant* v = pr.FindVariant(slug);
Check(v && Money::NetFromGross(v->priceInclMinor)
- Money::NetFromGross(supplier) == 5000,
"content: variant nets the supplier price plus exactly €50", slug);
}
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green", Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
"content: cheapest is green"); "content: cheapest is green");
// The boxed weight of one outgoing parcel. Not decoration: this
// single integer picks the carrier's weight bracket, so it decides
// the shipping cents added to every order AND the per-country
// quantity ceiling. Regressed to 0 or off by an order of magnitude,
// the shop quotes a rate the carrier does not honour on real parcels.
Check(pr.shipWeightGrams == 700, "content: one boxed unit weighs 700 g");
{
// A ladder straddling that weight. 700 g fits both bands, and
// RateFor takes the CHEAPEST band that can carry it — 895, not
// the tighter-looking 5500 — while the ceiling comes off the
// heaviest band: 10000 / 700 = 14 units to a parcel.
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 5500 } };
Check(RateFor(ladder, pr.shipWeightGrams) == 895,
"content: the shipped weight lands in the cheap carrier bracket");
Check(MaxUnitsFor(ladder, pr.shipWeightGrams) == 14,
"content: and caps one parcel at fourteen units");
}
Check(pr.safetyNote.find("112") != std::string::npos Check(pr.safetyNote.find("112") != std::string::npos
&& pr.safetyNote.find("not yet verified") != std::string::npos, && pr.safetyNote.find("not yet verified") != std::string::npos,
"content: emergency-calling safety warning present and honest"); "content: emergency-calling safety warning present and honest");
@ -114,6 +152,45 @@ void CatalogueContract() {
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos, && bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: and the rest of the record still parses"); "schema: and the rest of the record still parses");
} }
// The donation item: the shop's soft opening. Available (it is what
// the shop is open FOR) while the phone stays coming-soon; buyer
// names the amount, so no price, no variants, no weight — and the
// Buyable() price check is waived for exactly this shape.
const Product& don = products[1];
Check(don.slug == "donation" && don.donation,
"content: the second product is the donation item");
Check(don.Buyable() && !don.ComingSoon(),
"content: the donation item is on sale while the phone is not");
Check(don.priceInclMinor == 0 && don.variants.empty()
&& don.shipWeightGrams == 0,
"content: a donation has no price, no colours and no parcel");
Check(don.warranty.empty() && don.specs.empty(),
"content: a donation carries no spec sheet and no warranty");
// Its page: a donation form (amount + optional email), no price line,
// no product JSON-LD — an offer with no amount is a claim shopping
// crawlers can only misread — and no Fairphone sections.
{
const auto dp = Views::RenderProduct(don, Rates{});
const std::string_view html = dp.main.View();
Check(html.find("name=\"amount\"") != std::string_view::npos,
"donation page: the form asks for an amount");
Check(html.find("name=\"street\"") == std::string_view::npos
&& html.find("name=\"country\"") == std::string_view::npos,
"donation page: no address is asked for — nothing ships");
Check(html.find("field__req") == std::string_view::npos
|| html.find("Email <span") == std::string_view::npos,
"donation page: email is not marked required");
Check(html.find("price--product") == std::string_view::npos,
"donation page: no price line for an unpriced item");
Check(html.find("Specifications") == std::string_view::npos
&& html.find("Warranty") == std::string_view::npos,
"donation page: no spec or warranty section renders");
Check(dp.meta.jsonLd.empty(),
"donation page: no product JSON-LD is published");
Check(!dp.meta.geoPriceHint,
"donation page: no price-hint script — nothing to convert");
}
} }
Check(!Content::Projects().empty(), "content: projects present"); Check(!Content::Projects().empty(), "content: projects present");
Check(Content::LegalPages().size() == 3, "content: three legal pages"); Check(Content::LegalPages().size() == 3, "content: three legal pages");
@ -205,11 +282,201 @@ void IdentityGraph() {
"schema: founder and about name one Person node"); "schema: founder and about name one Person node");
} }
// ── the sale gates, built by hand ─────────────────────────────────────
// The shipped catalogue is one product in one status with three colours, so
// asserting against it can only ever exercise one arm of each guard. These
// products exist to reach the others.
//
// Buyable() is the ONLY server-side gate on POST /checkout, and it is a
// conjunction: status AND a price. ComingSoon() is separately what decides
// whether a visitor is told "the shop has not opened yet" or "temporarily
// unavailable", so the two are pinned apart rather than as one either/or.
void SaleGates() {
Product priced;
priced.status = "available";
priced.priceInclMinor = 56330;
Check(priced.Buyable() && !priced.ComingSoon(),
"status: available with a price is the one buyable state");
// The half the catalogue can never exercise. An "available" product with
// no price is what a from-price sync that failed to run leaves behind
// (Content::Products derives priceInclMinor from CheapestVariant); if
// this arm of the conjunction regressed, checkout would mint a real order
// record and a live payment link for €0.
Product unpriced;
unpriced.status = "available";
unpriced.priceInclMinor = 0;
Check(!unpriced.Buyable(), "status: an unpriced product cannot be bought");
// The donation arm of the same conjunction: no price by definition, yet
// buyable — and ONLY because the flag says the buyer names the amount.
// The status half still gates it like anything else.
Product gift;
gift.status = "available";
gift.donation = true;
Check(gift.Buyable(), "status: an available donation needs no price");
gift.status = "unavailable";
Check(!gift.Buyable(), "status: a withdrawn donation is not buyable either");
Product withdrawn;
withdrawn.status = "unavailable";
withdrawn.priceInclMinor = 56330;
Check(!withdrawn.Buyable() && !withdrawn.ComingSoon(),
"status: unavailable is neither for sale nor coming soon");
Product soon;
soon.status = "coming-soon";
soon.priceInclMinor = 56330;
Check(soon.ComingSoon() && !soon.Buyable(),
"status: coming-soon publishes a price without opening orders");
// A product with no colours is a supported catalogue shape — priceInclMinor
// is then simply the price. The null return is the only thing standing
// between that shape and the two call sites that dereference the result
// (the checkout form's default selection, and the checkout handler).
Product plain;
plain.priceInclMinor = 56330;
Check(plain.CheapestVariant() == nullptr,
"variants: no colours means no cheapest colour");
Check(plain.FindVariant("green") == nullptr && plain.FindVariant("") == nullptr,
"variants: nothing is ever found in an empty colour list");
// The loop compares with a strict <, so a tie keeps the listed order.
// That is what stops the advertised "from" price and the form's default
// selection from naming different colours when two cost the same.
Product tied;
tied.priceInclMinor = 56330;
tied.variants = {
{ "green", "Forest Green", 56330 },
{ "black", "Black", 56330 },
};
Check(tied.CheapestVariant() && tied.CheapestVariant()->slug == "green",
"variants: equally priced colours keep the listed order");
}
// ── the product page's live-total blob ────────────────────────────────
// RenderCheckoutForm renders only for a Buyable product, and the shipped
// catalogue is coming-soon, so today nothing renders it: the two suites that
// read this attribute both sit behind a ShopOpen() gate. Flip a copy to
// "available" and read the markup here instead — the refusal lists in that
// blob are what make the on-page total decline in exactly the places
// checkout declines, and a page that quotes a total for a sanctioned or
// no-sale destination invites an order that must then be refused.
void CheckoutPreviewData() {
if (Content::Products().empty()) return;
Product pr = Content::Products()[0];
pr.status = "available";
Check(pr.Buyable(), "checkout: the flipped copy is buyable, so the form renders");
const std::vector<Money::ShipRates> feedTable{
{ "NL", { { 2000, 895 } } },
{ "DE", { { 2000, 995 } } },
};
const auto page = Views::RenderProduct(pr, Rates{}, feedTable, {}, {}, true);
const std::string_view html = page.main.View();
// The blob is a JSON document inside an HTML attribute, so every quote
// arrives escaped — matching the escaped form is matching what the
// 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;]")
!= std::string_view::npos,
"checkout: the preview carries the sanctions list verbatim");
// The unit weight, which is what selects a bracket out of the carrier
// table the same blob carries.
Check(html.find("&quot;g&quot;:700") != std::string_view::npos,
"checkout: the preview knows one unit's shipping weight");
// Per-colour unit prices: the preview multiplies these, so they are the
// same integers the checkout charges or the two disagree on screen.
Check(html.find("&quot;green&quot;:57380") != std::string_view::npos
&& html.find("&quot;black&quot;:57980") != std::string_view::npos
&& html.find("&quot;white&quot;:66538") != std::string_view::npos,
"checkout: every colour is priced in the preview blob");
}
// ── which euro amount a localised price converts ──────────────────────
// The headline price rides along as one pre-formatted attribute per
// currency, and the basis differs by EU membership: a member's currency
// converts the VAT-inclusive price the buyer pays, everyone else's converts
// the ex-VAT export price. Invert that test and a Swedish visitor sees a
// figure 21% below what their card is charged, or a British one 21% above
// the export price — both while every existing assertion (which only checks
// that the attributes exist) still passes.
void LocalisedPriceBasis() {
// €121.00 inclusive: net = 12100 * 10000 / 12100 = 10000 exactly, so the
// two bases are a clean €121 and €100 with no rounding to reason about.
Product pr;
pr.slug = "basis-probe";
pr.name = "Basis probe";
pr.status = "coming-soon";
pr.priceInclMinor = 12100;
// 1 EUR = 1 unit in both currencies, so the printed number can only
// report which euro amount the conversion started from.
Rates rates;
rates.date = "2026-01-01";
rates.microPerEur.emplace_back("SEK", 1'000'000);
rates.microPerEur.emplace_back("GBP", 1'000'000);
const auto page = Views::RenderProduct(pr, rates);
const std::string_view html = page.main.View();
// SE is an EU member: base 12100, converted whole and half-up ->
// (12100 * 1e6 + 5e7) / 1e8 = 121.
Check(html.find(R"(data-sek="~kr 121")") != std::string_view::npos,
"price: an EU member's currency converts the VAT-inclusive price");
// GB is not: base 10000 -> 100.
Check(html.find(R"(data-gbp="~£100")") != std::string_view::npos,
"price: a non-EU currency converts the ex-VAT export price");
// The euro fallback that no-JS visitors and crawlers read is the export
// price, in the same units the two above were derived from.
Check(html.find(R"(data-world="100")") != std::string_view::npos,
"price: the export euro price is the world default");
}
// ── the URLs this site advertises as its own ──────────────────────────
// The sitemap is the list of URLs the site asks crawlers to index; the nav
// is what a visitor clicks. Both are hand-maintained lists, so a renamed
// legal slug or a retired path left behind advertises a 404 — or a 301 — as
// canonical, silently and forever. Nothing else cross-checks them: the route
// suite walks a literal list of its own rather than SitemapPaths().
void AdvertisedUrls() {
Views::SiteContent site;
site.legal = Content::LegalPages();
for (const std::string_view path : SitemapPaths()) {
const Route r = ParseRoute(path);
Check(r.kind != RouteKind::NotFound,
"sitemap: every advertised path resolves to a page", path);
// A route carrying a canonical target is a redirect whatever it
// renders, and asking a crawler to index a redirect is asking it to
// index a non-canonical URL.
Check(r.canonicalRedirect.empty(),
"sitemap: no advertised path is itself a redirect", path);
// Parsing only proves the URL is SHAPED like a legal page; the
// dispatcher's lookup is what decides whether it renders one.
if (r.kind == RouteKind::Legal) {
Check(site.FindLegal(r.slug) != nullptr,
"sitemap: every advertised legal slug names a real page", path);
}
}
for (const NavItem& item : NavItems()) {
Check(ParseRoute(item.href).kind == item.kind,
"nav: every nav entry parses to the route it claims", item.href);
}
}
} // namespace } // namespace
int main() { int main() {
CatalogueContract(); CatalogueContract();
IdentityGraph(); IdentityGraph();
SaleGates();
CheckoutPreviewData();
LocalisedPriceBasis();
AdvertisedUrls();
if (failures != 0) { if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures); std::println(std::cerr, "{} check(s) failed", failures);

View file

@ -50,6 +50,29 @@ int main() {
Check(parse("n=caf%C3%A9")->Get("n") == "café", "form: utf-8 percent-decoding"); Check(parse("n=caf%C3%A9")->Get("n") == "café", "form: utf-8 percent-decoding");
Check(parse("n=100%")->Get("n") == "100%", "form: malformed escape passes through"); Check(parse("n=100%")->Get("n") == "100%", "form: malformed escape passes through");
Check(parse("n=%zz")->Get("n") == "%zz", "form: non-hex escape passes through"); Check(parse("n=%zz")->Get("n") == "%zz", "form: non-hex escape passes through");
// A repeated field keeps BOTH pairs and Get answers with the first. Most
// urlencoded parsers in the wild take the last, so this is pinned rather
// than left to the header comment: every refusal in ValidateCheckout reads
// its field through Get, and flipping this to "last wins" would silently
// hand a second `country=` the final say over where a parcel may go.
{
auto dup = parse("country=NL&country=RU");
Check(dup->Size() == 2, "form: a repeated field keeps both pairs");
Check(dup->Get("country") == "NL", "form: duplicates resolve to the first");
}
// Field NAMES are percent-decoded, not only values — %73 is 's', so
// `web%73ite` is the field `website`. The honeypot below is found by its
// decoded name and nothing else, so this is what makes the trap closed
// against a bot that encodes the key it is trying to avoid.
{
auto encodedName = parse("web%73ite=spam");
Check(encodedName->Has("website"), "form: a percent-encoded field name decodes");
Check(encodedName->Get("website") == "spam",
"form: the value still attaches to the decoded name");
}
// A field name is not allowed to be empty — "=x" is malformed, not a field. // A field name is not allowed to be empty — "=x" is malformed, not a field.
Check(!parse("=x").has_value(), "form: empty field name rejected"); Check(!parse("=x").has_value(), "form: empty field name rejected");
// Oversized input must be refused outright rather than truncated: acting on // Oversized input must be refused outright rather than truncated: acting on
@ -129,6 +152,18 @@ int main() {
Check(pot.errors.size() == 1 && pot.errors[0].message.find("honeypot") == std::string::npos Check(pot.errors.size() == 1 && pot.errors[0].message.find("honeypot") == std::string::npos
&& pot.errors[0].message.find("website") == std::string::npos, && pot.errors[0].message.find("website") == std::string::npos,
"checkout: honeypot failure does not name the trap"); "checkout: honeypot failure does not name the trap");
// The same trap with the trigger field's NAME percent-encoded, which is the
// obvious way to try to slip past it. It still fails closed only because
// ParseUrlEncoded decodes the key before Get looks it up, and the refusal
// has to stay identical — a different answer for the encoded spelling would
// itself tell a bot which spelling worked.
{
auto sneaky = validate(std::string(kGoodOrder) + "&web%73ite=http%3A%2F%2Fspam");
Check(!sneaky.Ok(), "checkout: honeypot catches a percent-encoded field name");
Check(sneaky.errors.size() == 1 && sneaky.errors[0].field.empty()
&& sneaky.errors[0].message == "Submission rejected.",
"checkout: the encoded-name trap gives the same single generic refusal");
}
Check(!validate("email=a%40b.example&name=" + std::string(200, 'x') Check(!validate("email=a%40b.example&name=" + std::string(200, 'x')
+ "&street=x&postal=1&city=y&country=NL").Ok(), + "&street=x&postal=1&city=y&country=NL").Ok(),
@ -150,6 +185,45 @@ int main() {
"checkout: past the technical ceiling rejected"); "checkout: past the technical ceiling rejected");
Check(!validate(std::string(kGoodOrder) + "&quantity=two").Ok(), Check(!validate(std::string(kGoodOrder) + "&quantity=two").Ok(),
"checkout: non-numeric quantity rejected"); "checkout: non-numeric quantity rejected");
// "two" fails at the first character, which is the easy half. The hard half
// is a valid numeric PREFIX: from_chars consumes what it can, reports
// success, and leaves the leftovers to the caller — so the only thing
// standing between "2x" and a two-unit charge is the check that parsing
// reached the end of the field. Quantity multiplies the unit price into
// what the buyer actually pays, so a partial parse is a billing bug.
{
auto trailing = validate(std::string(kGoodOrder) + "&quantity=2x");
Check(!trailing.Ok(), "checkout: a numeric prefix with trailing junk rejected");
Check(trailing.errors.size() == 1 && trailing.errors[0].field == "quantity",
"checkout: the quantity refusal hangs off the quantity field");
// Rejected means rejected, not "keep what we managed to read": the
// parsed 2 must not survive into value, because value is what the
// handler prices if anything upstream ever ignores Ok().
Check(trailing.value.quantity == 1,
"checkout: a rejected quantity resets to 1, not the parsed prefix");
}
// from_chars for an integer stops at 'e' and at '.', so left unchecked each
// of these would be read as a bare 1 rather than refused — and "1e3" is a
// spelling of 1000 that no form control produces.
Check(!validate(std::string(kGoodOrder) + "&quantity=1e3").Ok(),
"checkout: exponent notation rejected rather than partly read");
Check(!validate(std::string(kGoodOrder) + "&quantity=1.5").Ok(),
"checkout: a fractional quantity rejected rather than truncated");
// "-1" parses cleanly all the way to the end, so it survives the prefix
// check and is caught by the range floor instead.
Check(!validate(std::string(kGoodOrder) + "&quantity=-1").Ok(),
"checkout: a negative quantity rejected");
Check(validate(std::string(kGoodOrder) + "&quantity=-1").value.quantity == 1,
"checkout: a negative quantity never reaches the record");
// Trim runs before from_chars, so surrounding whitespace is not junk:
// "%20" decodes to a space and " 2" trims back to "2". A buyer who pastes
// a padded number is not making a hostile submission.
Check(validate(std::string(kGoodOrder) + "&quantity=%202").Ok(),
"checkout: a leading space on the quantity is trimmed, not rejected");
Check(validate(std::string(kGoodOrder) + "&quantity=%202").value.quantity == 2,
"checkout: the trimmed quantity is the one that counts");
Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(), Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(),
"checkout: oversized colour rejected"); "checkout: oversized colour rejected");
@ -210,6 +284,21 @@ int main() {
Check(ru.value.country == "RU", "checkout: sanctioned country echoed back"); Check(ru.value.country == "RU", "checkout: sanctioned country echoed back");
} }
// Parameter pollution against the order gate. Every refusal above reads its
// 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
// — kGoodOrder already carries country=nl, and the trailing RU is inert.
// The same property protects the number the buyer is charged for.
{
auto polluted = validate(std::string(kGoodOrder) + "&country=RU");
Check(polluted.Ok(),
"checkout: a trailing second country cannot displace the first");
Check(polluted.value.country == "NL",
"checkout: the first country is the one validated and stored");
Check(validate(std::string(kGoodOrder) + "&quantity=2&quantity=99").value.quantity == 2,
"checkout: a second quantity cannot raise what is charged");
}
// 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
@ -249,6 +338,78 @@ int main() {
Check(rejected.value.country == "NLD", "checkout: invalid country echoed back as typed"); Check(rejected.value.country == "NLD", "checkout: invalid country echoed back as typed");
Check(rejected.value.name == "Ada", "checkout: valid sibling field preserved"); Check(rejected.value.name == "Ada", "checkout: valid sibling field preserved");
// ── the euro-amount parser ────────────────────────────────────────
// Exact integer parsing for the one amount that ever arrives from a
// client (the donation). Same no-floats rule as every money path.
Check(ParseEuroAmountToMinor("25") == 2500, "amount: whole euros");
Check(ParseEuroAmountToMinor("12.50") == 1250, "amount: euros and cents");
Check(ParseEuroAmountToMinor("12,50") == 1250, "amount: comma decimal mark");
Check(ParseEuroAmountToMinor("2.5") == 250, "amount: one decimal is tenths, not cents");
Check(ParseEuroAmountToMinor("0.01") == 1, "amount: a single cent parses");
Check(ParseEuroAmountToMinor("10000") == 1000000, "amount: the ceiling parses");
Check(!ParseEuroAmountToMinor("").has_value(), "amount: empty rejected");
Check(!ParseEuroAmountToMinor("-5").has_value(), "amount: negative rejected");
Check(!ParseEuroAmountToMinor("1e3").has_value(), "amount: exponent rejected");
Check(!ParseEuroAmountToMinor("1.234").has_value(), "amount: third decimal rejected");
Check(!ParseEuroAmountToMinor("1.2.3").has_value(), "amount: two marks rejected");
Check(!ParseEuroAmountToMinor(".50").has_value(), "amount: bare fraction rejected");
Check(!ParseEuroAmountToMinor("25 EUR").has_value(), "amount: trailing text rejected");
Check(!ParseEuroAmountToMinor("12345678901").has_value(), "amount: oversized rejected");
// ── donation validation ───────────────────────────────────────────
// Its own validator, not checkout with fields waived: nothing ships, so
// no address is even asked for, and email is optional — the order page's
// capability URL is already the receipt.
auto donate = [](std::string_view body) {
return ValidateDonation(*ParseUrlEncoded(body));
};
{
auto ok = donate("amount=25");
Check(ok.Ok(), "donation: an amount alone is a complete submission");
Check(ok.value.amountMinor == 2500, "donation: the amount lands in cents");
Check(ok.value.quantity == 1, "donation: quantity is always one");
Check(ok.value.email.empty(), "donation: no email means no email");
}
Check(donate("amount=12.50&email=a%40b.example").Ok(),
"donation: an email may ride along for the confirmation");
Check(donate("amount=12.50&email=a%40b.example").value.amountMinor == 1250,
"donation: cents survive alongside the email");
Check(!donate("amount=25&email=nonsense").Ok(),
"donation: a present-but-bad email is still refused");
Check(!donate("email=a%40b.example").Ok(), "donation: no amount, no donation");
Check(!donate("amount=nonsense").Ok(), "donation: an unparseable amount is refused");
Check(!donate("amount=0.99").Ok(), "donation: below the €1 floor refused");
Check(donate("amount=1").Ok(), "donation: the €1 floor itself is welcome");
Check(donate("amount=10000").Ok(), "donation: the €10,000 ceiling itself is welcome");
Check(!donate("amount=10000.01").Ok(), "donation: past the ceiling refused");
{
// Rejected means rejected: the out-of-range figure must not survive
// into value, because value is what the handler charges if anything
// upstream ever ignores Ok().
auto big = donate("amount=99999");
Check(big.value.amountMinor == 0,
"donation: a refused amount never reaches the record");
Check(big.errors.size() == 1 && big.errors[0].field == "amount",
"donation: the refusal hangs off the amount field");
}
// The same honeypot as checkout, reported just as namelessly.
{
auto pot2 = donate("amount=25&website=spam");
Check(!pot2.Ok(), "donation: honeypot rejects");
Check(pot2.errors.size() == 1 && pot2.errors[0].field.empty()
&& pot2.errors[0].message == "Submission rejected.",
"donation: honeypot failure does not name the trap");
}
// The payment choice, same rules as checkout.
Check(donate("amount=25&pay=crypto").value.payChoice == Catcrafts::Form::kPayCrypto,
"donation: crypto choice parsed");
Check(!donate("amount=25&pay=free").Ok(), "donation: unknown payment choice rejected");
// First-wins duplicates protect the amount exactly as they protect
// checkout's quantity: a trailing second value cannot raise the charge.
Check(donate("amount=25&amount=9999").value.amountMinor == 2500,
"donation: a second amount cannot displace the first");
if (failures != 0) { if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures); std::println(std::cerr, "{} check(s) failed", failures);
return 1; return 1;

View file

@ -66,7 +66,26 @@ done
[ -n "$FILES" ] || { echo "publish-media: no input file. See --help." >&2; exit 1; } [ -n "$FILES" ] || { echo "publish-media: no input file. See --help." >&2; exit 1; }
WORK="$(mktemp -d)" WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# One SSH connection for the whole run, not one per operation. The origin runs
# `ufw limit 22/tcp`, which rejects the sixth new connection from a source inside
# 30 s — and a video publishes three artifacts at three connections each (probe,
# copy, rename), so a run used to trip the limit on the poster's rename and die
# with the H.264 fallback still unsent. The failure does not even look like rate
# limiting: ufw REJECTs with ICMP port-unreachable, ssh moves on to the AAAA
# record, and reports the errno of the last address it tried — so a refused IPv4
# connection surfaces as "Network is unreachable" from a host with no IPv6 route.
# Multiplexing over one master socket keeps a run at a single connection however
# many artifacts it produces, and leaves the limit in place doing its real job.
SSH_CTL="$WORK/cm"
SSH_OPTS="-o BatchMode=yes -o ControlMaster=auto -o ControlPath=$SSH_CTL -o ControlPersist=60"
cleanup() {
if [ -S "$SSH_CTL" ]; then
ssh -O exit $SSH_OPTS "$MEDIA_HOST" >/dev/null 2>&1 || true
fi
rm -rf "$WORK"
}
trap cleanup EXIT
# Upload only what is not already there. The name is the content hash, so a name # Upload only what is not already there. The name is the content hash, so a name
# that exists on the mount holds these exact bytes and re-sending them would # that exists on the mount holds these exact bytes and re-sending them would
@ -75,14 +94,14 @@ trap 'rm -rf "$WORK"' EXIT
publish_file() { publish_file() {
local_file=$1 local_file=$1
remote_name=$2 remote_name=$2
if ssh -o BatchMode=yes "$MEDIA_HOST" "test -f '$MEDIA_PATH/$remote_name'"; then if ssh $SSH_OPTS "$MEDIA_HOST" "test -f '$MEDIA_PATH/$remote_name'"; then
echo " already published: $remote_name" echo " already published: $remote_name"
return 0 return 0
fi fi
# Land it under a temporary name and move it into place, so a reader can # Land it under a temporary name and move it into place, so a reader can
# never see a partial file under the name its hash promises. # never see a partial file under the name its hash promises.
scp -q -o BatchMode=yes "$local_file" "$MEDIA_HOST:$MEDIA_PATH/.$remote_name.part" scp -q $SSH_OPTS "$local_file" "$MEDIA_HOST:$MEDIA_PATH/.$remote_name.part"
ssh -o BatchMode=yes "$MEDIA_HOST" \ ssh $SSH_OPTS "$MEDIA_HOST" \
"chmod 0644 '$MEDIA_PATH/.$remote_name.part' && \ "chmod 0644 '$MEDIA_PATH/.$remote_name.part' && \
mv -f '$MEDIA_PATH/.$remote_name.part' '$MEDIA_PATH/$remote_name'" mv -f '$MEDIA_PATH/.$remote_name.part' '$MEDIA_PATH/$remote_name'"
echo " uploaded: $remote_name" echo " uploaded: $remote_name"