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

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