donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
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:
parent
b0666841f6
commit
abbd616b40
23 changed files with 2898 additions and 209 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue