diff --git a/project.cpp b/project.cpp index 87399b2..41a7f9e 100644 --- a/project.cpp +++ b/project.cpp @@ -230,6 +230,8 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("ShouldGuardRequestProvenance").Dependencies({ core, shared }); cfg.AddTest("ShouldBuildInvoices").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) ────────────────── // Each spawns the REAL binary — depending on &cfg is what builds it diff --git a/server/implementations/Catcrafts.Server-Eurc.cpp b/server/implementations/Catcrafts.Server-Eurc.cpp index 769fad5..7c84eca 100644 --- a/server/implementations/Catcrafts.Server-Eurc.cpp +++ b/server/implementations/Catcrafts.Server-Eurc.cpp @@ -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 // 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 // same arithmetic every other amount in this codebase already uses. // diff --git a/server/implementations/Catcrafts.Server-Http.cpp b/server/implementations/Catcrafts.Server-Http.cpp index f6adc7b..5795f9a 100644 --- a/server/implementations/Catcrafts.Server-Http.cpp +++ b/server/implementations/Catcrafts.Server-Http.cpp @@ -187,7 +187,11 @@ HTTPResponse RenderPage(std::string_view target) { if (route.kind == RouteKind::Invoice) { HTTPResponse res; std::optional 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"; ApplyPageHeaders(res, "text/plain; charset=utf-8", false, true); res.body = "Not found\n"; @@ -290,6 +294,7 @@ HTTPResponse RenderPage(std::string_view target) { view.shippingMinor = order->shippingMinor; view.totalMinor = order->totalMinor; view.vatIncluded = order->vatIncluded; + view.donation = order->donation; view.quantity = order->quantity; view.unitMinor = order->unitMinor; if (const Product* p = gContent.FindProduct(order->product)) { @@ -361,8 +366,11 @@ HTTPResponse RenderPage(std::string_view target) { const std::vector orders = ListOrders(); const SalesSummary sales = SummarizeSales(orders); 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 = - Views::RenderFinancials(sales.count, sales.totalMinor, fin); + Views::RenderFinancials(sales.count, sales.totalMinor, fin, + sales.donationCount, sales.donationsMinor); HTTPResponse res; res.status = std::to_string(page.status); 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"); } - 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()) { return reject(parsed.errors, parsed.value, "422"); } @@ -640,57 +653,76 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) { parsed.value, "429"); } - // 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"); + std::int64_t unitMinor = 0; + Money::Totals totals; + if (product->donation) { + // THE amount, donation case: the validated buyer-named amount — the + // one figure that legitimately arrives from the client, and + // ValidateDonation has already bounded it. Nothing ships and no VAT + // is charged: a gift with nothing supplied in return is not a + // taxable supply, so the whole shipping-and-VAT computation below + // simply does not apply. + unitMinor = parsed.value.amountMinor; + 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; - } - const std::int64_t unitMinor = - variant ? variant->priceInclMinor : product->priceInclMinor; + unitMinor = variant ? variant->priceInclMinor : product->priceInclMinor; - // THE amount. Computed here from the catalogue, the validated country and - // the live shipping table; nothing about money ever arrives from the - // client. Shipping is per order, not per unit — one parcel — so the weight - // that picks the carrier bracket is the whole order's. - if (product->shipWeightGrams <= 0) { - // A catalogue bug, not a buyer problem: without a weight no bracket can - // be selected. Refuse rather than fall through to the cheapest rate, - // and say so in the log where it can be fixed. - std::println(std::cerr, "checkout: product '{}' has no shipping weight", - product->slug); - return reject({{ "", "Shipping for this product can't be priced right now — " - "nothing was charged." }}, parsed.value, "503"); - } - const std::int64_t parcelGrams = product->shipWeightGrams * parsed.value.quantity; - const std::optional shippingMinor = - ShipCostFor(parsed.value.country, parcelGrams); - if (!shippingMinor) { - // No rate covers this parcel, so there is no price to charge. Which of - // the two refusals it is decides what the buyer can do about it: an - // uncovered country is ours to fix, a too-heavy parcel has a quantity - // that would work. The error hangs off the field the buyer would - // change in each case. - const std::int64_t fits = - shipTable.MaxUnits(parsed.value.country, product->shipWeightGrams); - if (fits <= 0 && parsed.value.quantity == 1) { - return reject({{ "country", Form::NoShippingMessage(parsed.value.country) }}, + // THE amount. Computed here from the catalogue, the validated country + // and the live shipping table; nothing about money ever arrives from + // the client. Shipping is per order, not per unit — one parcel — so + // the weight that picks the carrier bracket is the whole order's. + if (product->shipWeightGrams <= 0) { + // A catalogue bug, not a buyer problem: without a weight no + // bracket can be selected. Refuse rather than fall through to the + // cheapest rate, and say so in the log where it can be fixed. + std::println(std::cerr, "checkout: product '{}' has no shipping weight", + product->slug); + return reject({{ "", "Shipping for this product can't be priced right now — " + "nothing was charged." }}, parsed.value, "503"); + } + const std::int64_t parcelGrams = + product->shipWeightGrams * parsed.value.quantity; + const std::optional shippingMinor = + ShipCostFor(parsed.value.country, parcelGrams); + if (!shippingMinor) { + // No rate covers this parcel, so there is no price to charge. + // Which of the two refusals it is decides what the buyer can do + // about it: an uncovered country is ours to fix, a too-heavy + // parcel has a quantity that would work. The error hangs off the + // field the buyer would change in each case. + const std::int64_t fits = + shipTable.MaxUnits(parsed.value.country, product->shipWeightGrams); + 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"); } - return reject({{ "quantity", - Form::TooHeavyMessage(parsed.value.country, fits) }}, - parsed.value, "422"); + totals = Money::ComputeTotals( + unitMinor, parsed.value.quantity, *shippingMinor, parsed.value.country); } - const Money::Totals totals = Money::ComputeTotals( - unitMinor, parsed.value.quantity, *shippingMinor, parsed.value.country); OrderRecord order; order.token = NewOrderToken(); @@ -705,6 +737,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) { order.shippingMinor = totals.shipping; order.totalMinor = totals.total; order.vatIncluded = totals.vatIncluded; + order.donation = product->donation; // Normalised, not echoed: the record must name the rail that issued the // link, and an empty submitted choice took the bank rail above. order.payChoice = std::string(wantsCrypto ? Form::kPayCrypto : Form::kPayBank); @@ -805,8 +838,11 @@ std::optional PollAndAdvance(const OrderRecord& order) { if (paid->state == PayState::Paid) { if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) { // The invoice number exists from the moment the money does — - // sequential by payment order, which is what the bookkeeping wants. - AssignInvoiceNumber(order.token, NowIso8601()); + // sequential by payment order, which is what the bookkeeping + // 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, Money::FormatMinor(order.totalMinor), paid->method.empty() ? "?" : paid->method); @@ -969,9 +1005,11 @@ void ReconcilerLoop(const std::stop_token& stop) { bool SendConfirmationEmail(const OrderRecord& order) { // The invoice rides along, so its number must exist. It normally does // 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; - if (o.invoiceNumber.empty()) { + if (!o.donation && o.invoiceNumber.empty()) { if (!AssignInvoiceNumber(o.token, NowIso8601())) return false; const auto reread = FindOrder(o.token); 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 // failure means no email now (retry later), never an unsigned invoice. - std::string invoice = BuildInvoiceMarkdown(o, productName, colorLabel); - if (InvoiceSigningConfigured()) { - const auto signedText = ClearsignInvoice(invoice); - if (!signedText) { - std::println(std::cerr, "mail: invoice signing failed for {} — retrying later", - o.reference); - return false; + std::string invoice; + if (!o.donation) { + invoice = BuildInvoiceMarkdown(o, productName, colorLabel); + if (InvoiceSigningConfigured()) { + const auto signedText = ClearsignInvoice(invoice); + if (!signedText) { + 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( @@ -1043,6 +1085,13 @@ void MailerLoop(const std::stop_token& stop) { for (const OrderRecord& order : ListOrders()) { 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()) { attempts.erase(order.token); continue; diff --git a/server/implementations/Catcrafts.Server-Invoice.cpp b/server/implementations/Catcrafts.Server-Invoice.cpp index 51c6e19..c85d0b5 100644 --- a/server/implementations/Catcrafts.Server-Invoice.cpp +++ b/server/implementations/Catcrafts.Server-Invoice.cpp @@ -82,14 +82,23 @@ std::string BuildInvoiceMarkdown(const OrderRecord& o, md += "## Amounts\n\n"; md += "| Description | Qty | Amount |\n|---|---|---|\n"; if (o.vatIncluded) { - // EU supply: net amounts per line, VAT once over the taxable total — - // the same line-total rounding the checkout charged with. + // EU supply: VAT once over the taxable total — the same rounding the + // 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 vat = o.totalMinor - net; + const std::int64_t goodsNet = Money::NetFromGross(o.goodsMinor); md += std::format("| {} | {} | {} |\n", item, o.quantity, - Money::FormatEuro(Money::NetFromGross(o.goodsMinor))); + Money::FormatEuro(goodsNet)); 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("| VAT 21% (NL) | | {} |\n", Money::FormatEuro(vat)); md += std::format("| **Total (incl. VAT)** | | **{}** |\n", diff --git a/server/implementations/Catcrafts.Server-Mail.cpp b/server/implementations/Catcrafts.Server-Mail.cpp index 34fdfaf..620f87d 100644 --- a/server/implementations/Catcrafts.Server-Mail.cpp +++ b/server/implementations/Catcrafts.Server-Mail.cpp @@ -75,6 +75,38 @@ std::string BuildOrderConfirmationEmail(const OrderRecord& o, // CR/LF would become an extra recipient, so the check repeats here. 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() ? std::string(productName) : std::format("{} — {}", productName, colorLabel); diff --git a/server/implementations/Catcrafts.Server-Orders.cpp b/server/implementations/Catcrafts.Server-Orders.cpp index 7776e86..902a968 100644 --- a/server/implementations/Catcrafts.Server-Orders.cpp +++ b/server/implementations/Catcrafts.Server-Orders.cpp @@ -120,6 +120,9 @@ std::vector FoldLocked() { r.shippingMinor = doc->Int("shipping_minor"); r.totalMinor = doc->Int("total_minor"); 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")); // Read as written, with no default applied here: the ledger // 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) { 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( R"({{"type":"order","at":"{}","id":"{}","ref":"{}","product":"{}",)" R"("color":"{}","quantity":{},"unit_minor":{},)" 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":"{}"}})", JsonEscape(o.createdAt), JsonEscape(o.token), JsonEscape(o.reference), 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.postal), JsonEscape(o.buyer.city), JsonEscape(o.buyer.country), o.goodsMinor, o.shippingMinor, o.totalMinor, o.vatIncluded, + o.donation ? R"("donation":true,)" : "", JsonEscape(o.status), JsonEscape(o.payChoice), JsonEscape(o.payUrl), JsonEscape(o.payId))); } @@ -311,6 +318,13 @@ SalesSummary SummarizeSales(std::span orders) { // being written — the same paid-or-shipped idiom the invoice // download uses. 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.totalMinor += r.totalMinor; } diff --git a/server/interfaces/Catcrafts.Server.cppm b/server/interfaces/Catcrafts.Server.cppm index a73851a..01d7339 100644 --- a/server/interfaces/Catcrafts.Server.cppm +++ b/server/interfaces/Catcrafts.Server.cppm @@ -64,6 +64,13 @@ export namespace Catcrafts::Server { std::int64_t shippingMinor = 0; std::int64_t totalMinor = 0; 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 payChoice; // Form::kPayBank | Form::kPayCrypto; which // 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 // 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 - // exported for the self-test. + // an expense on that page, it does not un-happen the sale. Donations paid + // 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 { std::int64_t count = 0; std::int64_t totalMinor = 0; + std::int64_t donationCount = 0; + std::int64_t donationsMinor = 0; }; SalesSummary SummarizeSales(std::span orders); diff --git a/shared/interfaces/Catcrafts.Shared-Content.cppm b/shared/interfaces/Catcrafts.Shared-Content.cppm index c4ef26d..6ae1f90 100644 --- a/shared/interfaces/Catcrafts.Shared-Content.cppm +++ b/shared/interfaces/Catcrafts.Shared-Content.cppm @@ -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, // not C++. // -// PRICING RULE (the user's): retail = supplier price + markup, exactly. -// Supplier prices are what the retailer currently charges (incl VAT); -// change one number when the supplier moves and the margin stays put. +// PRICING RULE (the user's): retail = supplier price + markup, exactly, and +// the markup is what Catcrafts walks away with AFTER shipping and VAT — €50 +// 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; import std; import :Model; +import :Money; namespace Catcrafts::Content { -// The flat markup on every variant — "whatever it costs me + 50". -inline constexpr std::int64_t kMarkupMinor = 5000; +// The flat markup on every variant — "whatever it costs me + 50", where the +// €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& Products() { static const std::vector products = [] { @@ -89,7 +107,25 @@ export const std::vector& Products() { if (const Variant* cheapest = p.CheapestVariant()) { p.priceInclMinor = cheapest->priceInclMinor; } - return std::vector{ 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{ std::move(p), std::move(d) }; }(); return products; } @@ -206,12 +242,12 @@ export const LegalPage& FinancialsPage() { static const LegalPage page{ .slug = "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.", .sections = { { "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.", } }, { "What is never published", @@ -291,7 +327,7 @@ export const std::vector& LegalPages() { { .slug = "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.", .sections = { { "Ordering and payment", @@ -302,6 +338,12 @@ export const std::vector& 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.", "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", { "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.", diff --git a/shared/interfaces/Catcrafts.Shared-Form.cppm b/shared/interfaces/Catcrafts.Shared-Form.cppm index f2863a4..14ba5c8 100644 --- a/shared/interfaces/Catcrafts.Shared-Form.cppm +++ b/shared/interfaces/Catcrafts.Shared-Form.cppm @@ -193,6 +193,12 @@ export struct Checkout { std::string color; // variant slug; whether it EXISTS is the handler's // check against the catalogue, not a shape check 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 // not offer a choice, which the handler reads as // bank. Whether the chosen rail is CONFIGURED is @@ -414,4 +420,100 @@ export CheckoutResult ValidateCheckout(const Fields& f) { 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 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 diff --git a/shared/interfaces/Catcrafts.Shared-Model.cppm b/shared/interfaces/Catcrafts.Shared-Model.cppm index 9a3cef0..2f8f1ab 100644 --- a/shared/interfaces/Catcrafts.Shared-Model.cppm +++ b/shared/interfaces/Catcrafts.Shared-Model.cppm @@ -200,6 +200,13 @@ export struct Product { // gap, price swing). In both closed states the page stays up, the buy // form does not, and the checkout POST is refused server-side. 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 // this is the FROM price (cheapest variant) and is kept in sync by the // loader; without variants it is simply the price. @@ -226,7 +233,11 @@ export struct Product { std::string safetyNote; std::vector 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"; } // 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 totalMinor = 0; 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; // the page then renders instructions instead of a "resume payment" button. std::optional cryptoPay; diff --git a/shared/interfaces/Catcrafts.Shared-Views.cppm b/shared/interfaces/Catcrafts.Shared-Views.cppm index b5563d2..8c91db3 100644 --- a/shared/interfaces/Catcrafts.Shared-Views.cppm +++ b/shared/interfaces/Catcrafts.Shared-Views.cppm @@ -610,7 +610,11 @@ export RenderedPage RenderShop(std::span products, const Rates& r R"()", thumb, 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"(

any amount

)") + : p.Buyable() ? RenderCardPrice(p, rates) : p.ComingSoon() ? Format(R"({}

coming soon

)", RenderCardPrice(p, rates)) @@ -650,7 +654,8 @@ export RenderedPage RenderShop(std::span products, const Rates& r R"(

Shop

)" R"(

Hardware that runs the software from the )" 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"()" R"(

{}
)", cards.empty() ? Raw(R"(

No products listed.

)") : Join(cards)); @@ -674,6 +679,40 @@ SafeHtml CustomsNote() { R"(estimate them bindingly, and is not a party to them.

)"); } +// 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 )" + R"(Bank or card — iDEAL, card, or a plain )" + R"(bank transfer. Handled by Mollie.)" + R"()" + R"({})" + R"()", + Attr("value", std::string(Form::kPayBank)), + wantsCrypto ? SafeHtml{} : Raw(" checked"), + Attr("value", std::string(Form::kPayCrypto)), + wantsCrypto ? Raw(" checked") : SafeHtml{}, + payError); +} + // The checkout form. // // A real
, 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)); - // The payment choice. A radio group rather than a )" - R"(Bank or card — iDEAL, card, or a plain )" - R"(bank transfer. Handled by Mollie.)" - R"()" - R"({})" - R"()", - Attr("value", std::string(Form::kPayBank)), - wantsCrypto ? SafeHtml{} : Raw(" checked"), - Attr("value", std::string(Form::kPayCrypto)), - wantsCrypto ? Raw(" checked") : SafeHtml{}, - errorFor("pay")); - } + const SafeHtml payFieldset = + offerCrypto ? RenderPayFieldset(prev, errorFor("pay")) : SafeHtml{}; return Format( R"(
)" @@ -931,6 +939,76 @@ SafeHtml RenderCheckoutForm(const Product& product, 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 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"(

{}

)", Escape(e.message)); + } + } + return SafeHtml{}; + }; + SafeHtml formError; + for (const Form::FieldError& e : errors) { + if (e.field.empty()) { + formError = Format(R"(

{}

)", Escape(e.message)); + break; + } + } + + return Format( + R"(
)" + R"(

Donate

)" + R"(

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.

)" + R"(

No VAT is charged on a donation and no )" + R"(invoice is issued — the donation page is its receipt. Donations )" + R"(appear on the financials page as an aggregate total, never )" + R"(individually.

)" + R"({})" + R"()" + R"(
)" + R"()" + R"()" + R"({})" + R"(
)" + R"(
)" + R"()" + R"()" + R"(

Optional — only used to send the )" + R"(confirmation. Leave it empty and the donation page is your receipt.

)" + R"({})" + R"(
)" + R"({})" + R"()" + R"()" + R"()" + R"(
)", + 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 // defaults to false. Only the native server passes it true, because only the // 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.ogType = "product"; 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 // 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 // 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 // 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 @@ -971,7 +1055,7 @@ export RenderedPage RenderProduct(const Product& product, // at single-unit weight, the same integers checkout charges, so the listing // and the till cannot disagree; a destination with no carrier rate is // simply not advertised, because it is not for sale. - { + if (!product.donation) { const std::string productUrl = "https://catcrafts.net/shop/" + product.slug; std::string_view availability = product.Buyable() ? "https://schema.org/InStock" @@ -1154,7 +1238,9 @@ export RenderedPage RenderProduct(const Product& product, Escape(product.safetyNote)); 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); } else if (product.ComingSoon()) { // 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.

)"); } + // 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"(
)" + R"(

Specifications

)" + R"(

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.

)" + R"({}
)" + R"(
)", + Join(specRows)); + const SafeHtml warrantySection = product.warranty.empty() ? SafeHtml{} : Format( + R"(
)" + R"(

Warranty

)" + R"(

{}

)" + R"(
)", + Escape(product.warranty)); + page.main = Format( R"(