catcrafts.net/tests/ShouldBuildInvoices/main.cpp
Jorijn van der Graaf abbd616b40
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
donation item, shop soft open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:04:03 +02:00

335 lines
18 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The invoice builder and the order confirmation email — the two documents a
// paying customer actually receives, including the VAT treatment on each and
// the header-injection guard on the address that goes into the envelope.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
// The euro amount on the table row starting with `prefix`, in minor units, or
// -1 when the row is missing. Reads the rendered document, not the builder's
// internals: FormatEuro prints "€938.21", or "€580" when the cents are zero.
std::int64_t RowMinor(const std::string& md, std::string_view prefix) {
const std::size_t at = md.find(prefix);
if (at == std::string::npos) return -1;
std::size_t i = at + prefix.size();
std::int64_t euros = 0;
bool any = false;
for (; i < md.size() && md[i] >= '0' && md[i] <= '9'; ++i) {
euros = euros * 10 + (md[i] - '0');
any = true;
}
if (!any) return -1;
std::int64_t cents = 0;
if (i + 2 < md.size() && md[i] == '.') {
cents = (md[i + 1] - '0') * 10 + (md[i + 2] - '0');
}
return euros * 100 + cents;
}
} // namespace
int main() {
Server::OrderRecord o;
o.token = "0123456789abcdef0123456789abcdef";
o.reference = "CC-TEST01";
o.invoiceNumber = "f57c6512-f012-4b91-adb3-077876480178-7";
o.invoicedAt = "2026-08-05T10:00:00Z";
o.createdAt = "2026-08-05T09:55:00Z";
o.paidVia = "ideal";
o.buyer = { "b@example.org", "Ada Lovelace", "Main St 1", "1234AB",
"Delft", "NL" };
o.quantity = 2;
o.unitMinor = 56330;
o.goodsMinor = 112660;
o.shippingMinor = 863;
o.totalMinor = 113523;
o.vatIncluded = true;
const std::string eu = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
Check(eu.find("# Invoice f57c6512-f012-4b91-adb3-077876480178-7") != std::string::npos,
"invoice: number heading");
Check(eu.find("* Customer number: f57c6512-f012-4b91-adb3-077876480178") != std::string::npos,
"invoice: customer series shown separately");
Check(eu.find("* Invoice number: 7") != std::string::npos,
"invoice: sequence within the series");
Check(eu.find("Chico Mendesring 256") != std::string::npos, "invoice: seller address");
Check(eu.find("3315NN Dordrecht") != std::string::npos, "invoice: seller city");
Check(eu.find("KVK 78437059") != std::string::npos, "invoice: KVK");
Check(eu.find("NL003329281B38") != std::string::npos, "invoice: VAT id");
Check(eu.find("CC-TEST01") != std::string::npos, "invoice: order reference");
Check(eu.find("Ada Lovelace") != std::string::npos, "invoice: buyer name");
Check(eu.find("Fairphone 6 — Forest Green") != std::string::npos,
"invoice: item names the colour");
Check(eu.find("VAT 21% (NL)") != std::string::npos, "invoice: EU VAT line");
Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total");
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
// Every cent of the amounts table, pinned. This is the document a Dutch
// buyer, an accountant and the Belastingdienst read, so a rounding change
// in Money::NetFromGross must break a test rather than ship a wrong VAT
// figure. Derived by hand from net = (gross*10000 + 6050) / 12100:
// goods 112660 -> (1'126'600'000 + 6050) / 12100 = 93107 -> €931.07
// total 113523 -> (1'135'230'000 + 6050) / 12100 = 93821 -> €938.21
// VAT = 113523 - 93821 = 19702 -> €197.02
// shipping = 93821 - 93107 = 714 -> €7.14
// The shipping line is the REMAINDER of the subtotal, not a rounding of
// its own — that is what makes the columns add up. Rounded independently
// it would print €7.13 ((8'630'000 + 6050) / 12100 = 713) and sit a cent
// below the subtotal, which is why the remainder rule exists: shipping
// absorbs the cent so a signed tax document cannot disagree with itself.
Check(eu.find("| Fairphone 6 — Forest Green | 2 | €931.07 |\n") != std::string::npos,
"invoice: EU item line is net, not the gross the buyer paid");
Check(eu.find("| Shipping | 1 | €7.14 |\n") != std::string::npos,
"invoice: EU shipping line is the subtotal remainder");
Check(eu.find("| Subtotal (ex VAT) | | €938.21 |\n") != std::string::npos,
"invoice: EU subtotal is the net of the gross total");
Check(eu.find("| VAT 21% (NL) | | €197.02 |\n") != std::string::npos,
"invoice: EU VAT line is the amount actually remitted");
Check(eu.find("| **Total (incl. VAT)** | | **€1135.23** |\n") != std::string::npos,
"invoice: EU gross total is what was charged");
// The property the pinned cents above are one instance of, swept across
// the realistic price grid: the three retail prices × every quantity a
// parcel can carry × the range a shipping rate lives in. Before the
// remainder rule, roughly a quarter of these combinations printed lines
// one cent apart from their own subtotal (three independent half-up
// roundings; two errors uniform on [-½,½) cross a boundary with
// probability ¼). Rendered and re-parsed rather than recomputed, so what
// is being held is the document itself:
// item + shipping == subtotal (the remainder rule, by construction)
// subtotal + VAT == total (what the buyer paid, to the cent)
// |shipping - NetFromGross(shipping gross)| <= 1 (the cent stops here)
{
Server::OrderRecord s = o;
std::string broke;
for (const std::int64_t unit : { 57380, 57980, 66538 }) {
for (std::int64_t qty = 1; qty <= 28; ++qty) {
for (std::int64_t ship = 400; ship <= 6000; ship += 97) {
s.quantity = qty;
s.unitMinor = unit;
s.goodsMinor = unit * qty;
s.shippingMinor = ship;
s.totalMinor = s.goodsMinor + ship;
const std::string md = Server::BuildInvoiceMarkdown(s, "P", "");
const std::int64_t item =
RowMinor(md, std::format("| P | {} | €", qty));
const std::int64_t shipping = RowMinor(md, "| Shipping | 1 | €");
const std::int64_t sub = RowMinor(md, "| Subtotal (ex VAT) | | €");
const std::int64_t vat = RowMinor(md, "| VAT 21% (NL) | | €");
const std::int64_t total =
RowMinor(md, "| **Total (incl. VAT)** | | **€");
const bool ok = item >= 0 && shipping >= 0 && sub >= 0
&& vat >= 0 && total == s.totalMinor
&& item + shipping == sub
&& sub + vat == total
&& shipping - Money::NetFromGross(ship) <= 1
&& Money::NetFromGross(ship) - shipping <= 1;
if (!ok && broke.empty()) {
broke = std::format("unit {} qty {} ship {}: {} + {} vs {}",
unit, qty, ship, item, shipping, sub);
}
}
}
}
Check(broke.empty(),
"invoice: EU columns add up across the whole price grid", broke);
}
o.vatIncluded = false;
o.buyer.country = "GB";
o.goodsMinor = 93107;
o.shippingMinor = 2395;
o.totalMinor = 95502;
const std::string ex = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
Check(ex.find("VAT 0%") != std::string::npos, "invoice: export VAT 0%");
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
Check(ex.find("€955.02") != std::string::npos, "invoice: export total");
// The mirror image of the EU table: a zero-rated export carries no VAT to
// strip, so every line is the gross that was charged and NetFromGross must
// never touch it. 93107 stays €931.07 (netting it again would print
// €769.48) and 2395 stays €23.95 (€19.79 netted) — the two branches
// swapping their treatment is exactly the accident these pin down.
Check(ex.find("| Fairphone 6 — Forest Green | 2 | €931.07 |\n") != std::string::npos,
"invoice: export item line stays gross");
Check(ex.find("| Shipping | 1 | €23.95 |\n") != std::string::npos,
"invoice: export shipping line stays gross");
Check(ex.find("| **Total** | | **€955.02** |\n") != std::string::npos,
"invoice: export total carries no VAT label");
// ── the order confirmation email ──────────────────────────────────
// Same order, EU shape again; the attachment stands in for the
// clearsigned invoice — the builder must carry it verbatim.
o.vatIncluded = true;
o.buyer.country = "NL";
o.goodsMinor = 112660;
o.shippingMinor = 863;
o.totalMinor = 113523;
const std::string mail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/0123456789abcdef0123456789abcdef",
"SIGNED-INVOICE-STAND-IN\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(mail.find("From: Catcrafts <info@catcrafts.net>\n") != std::string::npos,
"email: From header");
Check(mail.find("To: b@example.org\n") != std::string::npos, "email: To header");
Check(mail.find("Subject: Catcrafts order CC-TEST01 confirmed\n") != std::string::npos,
"email: subject carries the reference");
Check(mail.find("Date: Fri, 08 Aug 2026 10:00:00 +0000\n") != std::string::npos,
"email: date header");
Check(mail.find("Message-ID: <0123456789abcdef0123456789abcdef@catcrafts.net>\n")
!= std::string::npos,
"email: message id from the token");
Check(mail.find("MIME-Version: 1.0\n") != std::string::npos, "email: mime version");
Check(mail.find("multipart/mixed") != std::string::npos, "email: multipart");
Check(mail.find("Fairphone 6 — Forest Green × 2") != std::string::npos,
"email: item names colour and quantity");
Check(mail.find("€1135.23") != std::string::npos, "email: total");
Check(mail.find("incl. 21% NL VAT") != std::string::npos, "email: EU VAT wording");
Check(mail.find("* Paid via: ideal\n") != std::string::npos, "email: payment method");
Check(mail.find("https://catcrafts.net/order/0123456789abcdef0123456789abcdef")
!= std::string::npos,
"email: order page link");
Check(mail.find("filename=\"catcrafts-invoice-"
"f57c6512-f012-4b91-adb3-077876480178-7.md\"") != std::string::npos,
"email: attachment filename is the invoice number");
Check(mail.find("SIGNED-INVOICE-STAND-IN\n") != std::string::npos,
"email: attachment body verbatim");
Check(mail.find("--=_cc_0123456789abcdef0123456789abcdef--\n") != std::string::npos,
"email: multipart closes");
Check(mail.find("KVK 78437059") != std::string::npos, "email: footer identity");
// The export wording mirrors the invoice's VAT treatment.
o.vatIncluded = false;
o.buyer.country = "GB";
o.totalMinor = 95502;
const std::string exMail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(exMail.find("zero-rated export") != std::string::npos,
"email: export VAT wording");
Check(exMail.find("€955.02") != std::string::npos, "email: export total");
// A single unit does not advertise a quantity.
o.quantity = 1;
const std::string one = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(one.find("Forest Green ×") == std::string::npos, "email: qty 1 stays silent");
// The last line of defence: an address that could smuggle a header
// yields NO message at all, however it got into the record.
o.buyer.email = "a@b.example\nBcc: leak@evil.example";
Check(Server::BuildOrderConfirmationEmail(
o, "F", "", "x", "u", "S", "D").empty(),
"email: header-injecting address yields no message");
// The bare newline is only the loudest of the shapes that would widen the
// envelope. Under `msmtp -t` the To: header IS the recipient list, so
// every address Form::LooksLikeEmail rejects must yield NO message —
// a comma is the cheapest extra-recipient smuggle of the lot, and it is
// barred only because that shared form validator happens to bar it.
// Pinning the coupling here means a future loosening of LooksLikeEmail
// (a legitimate-looking change to a form helper) cannot quietly re-open
// the envelope, and each of these carries a buyer's name and address.
// "…, evil@…" comma, plus a second '@'
// "…> , <evil@…" angle brackets, comma, second '@'
// "…\rBcc: …" bare CR — a header break on its own under CRLF
// "a@b" no dot in the domain
// "" empty, below the minimum length
for (const std::string_view addr : { "a@b.example, evil@x.example",
"a@b.example> , <evil@x.example",
"a@b.example\rBcc: x@y.example",
"a@b",
"" }) {
o.buyer.email = std::string(addr);
Check(Server::BuildOrderConfirmationEmail(
o, "F", "", "x", "u", "S", "D").empty(),
"email: address the envelope check rejects yields no message", addr);
}
// ── the GPG key id alphabet ───────────────────────────────────────
// gGpgKeyId is interpolated straight into a std::system() command line
// between single quotes, so a single accepted quote character is remote
// code execution as the shop user. The alphabet check in
// ConfigureInvoicing is the entire defence. None of this reaches gpg:
// a refused id leaves signing unconfigured, which is what we assert.
Check(!Server::InvoiceSigningConfigured(), "invoice: signing starts unconfigured");
for (const std::string_view bad : { "abc'; touch /tmp/pwned; '",
"0xDEADBEEF BEEF",
"0xDEADBEEF`id`",
"0xDEADBEEF$(id)",
"0xDEADBEEF\nBEEF" }) {
Server::ConfigureInvoicing(std::string(bad));
Check(!Server::InvoiceSigningConfigured(),
"invoice: key id outside the safe alphabet is refused", bad);
}
// The other half of the same contract, which the caller leans on: with no
// signer installed the answer is refusal, never the plaintext. Returning
// the markdown here would serve an UNSIGNED invoice through the path that
// promises a signed one — and the caller cannot tell the difference.
Check(!Server::ClearsignInvoice("# x").has_value(),
"invoice: unconfigured signing yields nullopt, not the plaintext");
// What a fingerprint or a uid email actually needs: alnum plus @ . _ - +.
Server::ConfigureInvoicing("0xDEADBEEF@catcrafts.net");
Check(Server::InvoiceSigningConfigured(),
"invoice: a key id inside the safe alphabet is accepted");
// Put the process back the way we found it — nothing after this line
// should be able to shell out to gpg.
Server::ConfigureInvoicing("");
Check(!Server::InvoiceSigningConfigured(),
"invoice: an empty key id means no signing");
// ── MAIL_FROM is a header, and is guarded like one ────────────────
// MAIL_FROM is written verbatim into the From: header of a message
// delivered with `msmtp -t`, where the headers ARE the envelope: one
// smuggled newline adds a recipient to EVERY order confirmation, and each
// of those carries the buyer's name and full postal address.
Check(!Server::MailConfigured(), "mail: starts unconfigured");
Check(Server::MailFrom().empty(), "mail: no From before configuration");
Server::ConfigureMail(Server::MailConfig{
"true", "Catcrafts <info@catcrafts.net>\nBcc: leak@evil.example" });
// Refused WHOLE, not sanitised: the guard returns before gMail is
// assigned, so the command does not install either. A half-applied config
// would be the dangerous outcome — a mailer that runs with a bad From.
Check(!Server::MailConfigured(),
"mail: a From with a line break rejects the whole config");
Check(Server::MailFrom().empty(),
"mail: a rejected From is never installed", Server::MailFrom());
// kSellerName + kSellerSite, so an operator who sets MAIL_COMMAND and
// forgets MAIL_FROM still sends from an address that exists.
Server::ConfigureMail(Server::MailConfig{ "true", "" });
Check(Server::MailConfigured(), "mail: a clean config installs the command");
Check(Server::MailFrom() == "Catcrafts <info@catcrafts.net>",
"mail: empty MAIL_FROM defaults to the shop inbox", Server::MailFrom());
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}