186 lines
7.8 KiB
C++
186 lines
7.8 KiB
C++
|
|
/*
|
||
|
|
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 <cstdio>
|
||
|
|
#include <cstdlib>
|
||
|
|
module Catcrafts.Server;
|
||
|
|
|
||
|
|
import std;
|
||
|
|
import Catcrafts.Shared;
|
||
|
|
|
||
|
|
namespace Catcrafts::Server {
|
||
|
|
|
||
|
|
namespace {
|
||
|
|
|
||
|
|
std::string gGpgKeyId;
|
||
|
|
|
||
|
|
// The registered business identity. On every invoice — these are the fields
|
||
|
|
// a Dutch invoice must carry along with the sequential number and amounts.
|
||
|
|
constexpr std::string_view kSellerName = "Catcrafts";
|
||
|
|
constexpr std::string_view kSellerStreet = "Chico Mendesring 256";
|
||
|
|
constexpr std::string_view kSellerCity = "3315NN Dordrecht";
|
||
|
|
constexpr std::string_view kSellerKvk = "78437059";
|
||
|
|
constexpr std::string_view kSellerVat = "NL003329281B38";
|
||
|
|
constexpr std::string_view kSellerSite = "catcrafts.net";
|
||
|
|
|
||
|
|
} // 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 <this file>\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<std::string> 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<std::uint64_t> 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
|