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
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue