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

@ -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.
//

View file

@ -187,7 +187,11 @@ HTTPResponse RenderPage(std::string_view target) {
if (route.kind == RouteKind::Invoice) {
HTTPResponse res;
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";
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<OrderRecord> 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<std::int64_t> 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<std::int64_t> 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<AdvanceResult> 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;

View file

@ -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",

View file

@ -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);

View file

@ -120,6 +120,9 @@ std::vector<OrderRecord> 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<const OrderRecord> 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;
}

View file

@ -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<const OrderRecord> orders);