/* 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. */ // Invoices: markdown, clearsigned with GPG. // // Markdown because an invoice's job is to be READ — by the buyer, by an // accountant, by a tax office, in thirty years, with any text editor. A // clearsigned document keeps the text human-readable with the signature // inline (gpg --verify checks it), so authenticity does not depend on this // server still existing — which is the point: the buyer downloads the file // once and the shop makes no promise to host receipt pages forever. // // Signing shells out to the gpg binary rather than linking a PGP library: // the key management story (GNUPGHOME, agent, key generation) is exactly the // part a library reimplements badly, and the server signs a handful of // documents per week. The subprocess writes to files under a private // directory, never a shell-interpolated user string — the only variable in // the command line is the key id, validated to a safe alphabet. module; #include #include module Catcrafts.Server; import std; import Catcrafts.Shared; namespace Catcrafts::Server { namespace { std::string gGpgKeyId; // The registered business identity lives in the module interface // (kSeller...) — the order email's footer states the same facts, and two // copies of a KVK number is one copy too many. } // namespace std::string BuildInvoiceMarkdown(const OrderRecord& o, std::string_view productName, std::string_view colorLabel) { std::string md; md.reserve(2048); const std::string item = colorLabel.empty() ? std::string(productName) : std::format("{} — {}", productName, colorLabel); // The number scheme continues the pre-shop administration: the customer // number is a UUID series, the invoice number counts within it. const std::size_t dash = o.invoiceNumber.size() > 37 ? 36 : std::string::npos; const std::string customer = dash != std::string::npos ? o.invoiceNumber.substr(0, 36) : o.invoiceNumber; const std::string seq = dash != std::string::npos ? o.invoiceNumber.substr(37) : o.invoiceNumber; md += std::format("# Invoice {}\n\n", o.invoiceNumber); md += std::format("**{}** \n{} \n{} \nKVK {} · VAT {} · {}\n\n", kSellerName, kSellerStreet, kSellerCity, kSellerKvk, kSellerVat, kSellerSite); // "*" bullets, never "-": clearsigning dash-escapes lines that start // with a dash ("- - Invoice date"), and the raw file is meant to be read. md += std::format("* Customer number: {}\n", customer); md += std::format("* Invoice number: {}\n", seq); md += std::format("* Invoice date: {}\n", o.invoicedAt); md += std::format("* Order reference: {}\n", o.reference); md += std::format("* Order placed: {}\n", o.createdAt); if (!o.paidVia.empty()) { md += std::format("* Paid via: {}\n", o.paidVia); } md += "\n## Billed and shipped to\n\n"; md += std::format("{} \n{} \n{} {} \n{}\n\n", o.buyer.name, o.buyer.street, o.buyer.postal, o.buyer.city, o.buyer.country); 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. const std::int64_t net = Money::NetFromGross(o.totalMinor); const std::int64_t vat = o.totalMinor - net; md += std::format("| {} | {} | {} |\n", item, o.quantity, Money::FormatEuro(Money::NetFromGross(o.goodsMinor))); md += std::format("| Shipping | 1 | {} |\n", Money::FormatEuro(Money::NetFromGross(o.shippingMinor))); 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", Money::FormatEuro(o.totalMinor)); } else { md += std::format("| {} | {} | {} |\n", item, o.quantity, Money::FormatEuro(o.goodsMinor)); md += std::format("| Shipping | 1 | {} |\n", Money::FormatEuro(o.shippingMinor)); md += std::format("| **Total** | | **{}** |\n", Money::FormatEuro(o.totalMinor)); md += "\nVAT 0%: zero-rated export outside the EU " "(art. 146 EU VAT Directive). Import duties and taxes are levied " "by the destination country and are not part of this invoice.\n"; } md += "\nThis invoice was generated by catcrafts.net and signed with the " "shop's GPG key. Verify with: gpg --verify \n"; return md; } void ConfigureInvoicing(std::string gpgKeyId) { // The key id ends up on a command line — constrain it to the alphabet a // fingerprint or uid email actually needs, and refuse anything else. for (const char c : gpgKeyId) { const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '@' || c == '.' || c == '_' || c == '-' || c == '+'; if (!ok) { std::println(std::cerr, "invoice: refusing GPG key id with unexpected characters"); return; } } gGpgKeyId = std::move(gpgKeyId); if (!gGpgKeyId.empty()) { std::println(std::cerr, "invoice: signing with GPG key '{}'", gGpgKeyId); } } bool InvoiceSigningConfigured() { return !gGpgKeyId.empty(); } std::optional ClearsignInvoice(const std::string& markdown) { if (gGpgKeyId.empty()) return std::nullopt; std::error_code ec; const std::filesystem::path dir = std::filesystem::temp_directory_path(ec) / "catcrafts-invoice"; if (ec) return std::nullopt; std::filesystem::create_directories(dir, ec); std::filesystem::permissions(dir, std::filesystem::perms::owner_all, ec); // Distinct per call so concurrent downloads cannot collide. static std::atomic counter{1}; const std::uint64_t n = counter.fetch_add(1); const std::filesystem::path in = dir / std::format("in-{}.md", n); const std::filesystem::path out = dir / std::format("out-{}.md.asc", n); { std::ofstream f(in, std::ios::trunc | std::ios::binary); if (!f) return std::nullopt; f << markdown; if (!f.flush()) return std::nullopt; } // --batch: never prompt (the service has no terminal). The key must be // passphrase-free or preset in the agent — deploy/README.md covers it. const std::string cmd = std::format( "gpg --batch --yes --clearsign --local-user '{}' -o '{}' '{}' 2>/dev/null", gGpgKeyId, out.string(), in.string()); const int rc = std::system(cmd.c_str()); std::string signedText; if (rc == 0) { std::ifstream f(out, std::ios::binary); std::ostringstream buf; buf << f.rdbuf(); signedText = buf.str(); } else { std::println(std::cerr, "invoice: gpg clearsign failed (rc {})", rc); } std::filesystem::remove(in, ec); std::filesystem::remove(out, ec); if (signedText.empty()) return std::nullopt; return signedText; } } // namespace Catcrafts::Server