/* 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. */ // Order storage: an append-only JSON-lines event log. // // Two event types share the file: // // {"type":"order", ...full record...} written once, at checkout // {"type":"status", "id":..,"status":..} one per transition // // Current state is a left fold over the file; later events win. Nothing is // ever rewritten, so the log doubles as the audit trail the tax records need, // and a crash mid-write costs at most its own line (a truncated last line is // skipped by the reader, not fatal). // // Why not SQLite yet: single-digit orders per week, one writer, no relations. // The day volume proves that wrong, this imports into a database in one // sitting. What this file holds is personal data (name, address, email), so // the same rules as ever: 0600 via the service's umask, off the web root, // encrypted before any backup leaves the machine. module; module Catcrafts.Server; import std; import Catcrafts.Shared; namespace Catcrafts::Server { namespace { std::mutex gOrdersMutex; std::filesystem::path gOrdersPath; // Minimal JSON string escaping. Values were validated upstream, but they are // still user input, and a raw newline or quote would corrupt the // line-per-record format — silently truncating the data on the next read. std::string JsonEscape(std::string_view s) { std::string out; out.reserve(s.size() + 8); for (const char c : s) { switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; default: if (static_cast(c) < 0x20) { out += std::format("\\u{:04x}", static_cast(c)); } else { out += c; } } } return out; } bool AppendLine(const std::string& line) { if (gOrdersPath.empty()) return false; // Open per append: orders arrive rarely, and a file reopened each time can // be rotated or edited underneath the running process without a restart. std::ofstream out(gOrdersPath, std::ios::app | std::ios::binary); if (!out) return false; out << line << '\n'; out.flush(); // Report the stream state: a full disk must surface as a visible error, // not a payment link over an order that was never recorded. return static_cast(out); } // Fold the whole log into id -> record. Corrupt lines are skipped — one bad // line must not take the rest of the ledger with it. std::vector FoldLocked() { std::vector out; if (gOrdersPath.empty()) return out; std::ifstream in(gOrdersPath, std::ios::binary); if (!in) return out; auto find = [&](std::string_view token) -> OrderRecord* { for (OrderRecord& r : out) { if (r.token == token) return &r; } return nullptr; }; std::string line; while (std::getline(in, line)) { auto doc = Json::Parse(line); if (!doc || !doc->IsObject()) continue; const std::string_view type = doc->Str("type"); if (type == "order") { OrderRecord r; r.token = std::string(doc->Str("id")); r.reference = std::string(doc->Str("ref")); r.product = std::string(doc->Str("product")); r.color = std::string(doc->Str("color")); r.quantity = doc->Int("quantity", 1); r.unitMinor = doc->Int("unit_minor"); r.createdAt = std::string(doc->Str("at")); r.updatedAt = r.createdAt; r.buyer.email = std::string(doc->Str("email")); r.buyer.name = std::string(doc->Str("name")); r.buyer.street = std::string(doc->Str("street")); r.buyer.postal = std::string(doc->Str("postal")); r.buyer.city = std::string(doc->Str("city")); r.buyer.country = std::string(doc->Str("country")); r.goodsMinor = doc->Int("goods_minor"); r.shippingMinor = doc->Int("shipping_minor"); r.totalMinor = doc->Int("total_minor"); r.vatIncluded = doc->Bool("vat_included"); r.status = std::string(doc->Str("status", "awaiting_payment")); r.payUrl = std::string(doc->Str("pay_url")); r.payId = std::string(doc->Str("pay_id")); if (r.token.empty()) continue; // A duplicate "order" event for an id would be a writer bug; first // one wins so a replayed line cannot rewrite history. if (!find(r.token)) out.push_back(std::move(r)); } else if (type == "invoice") { OrderRecord* r = find(doc->Str("id")); if (!r) continue; r->invoiceNumber = std::string(doc->Str("number")); r->invoicedAt = std::string(doc->Str("at")); } else if (type == "status") { OrderRecord* r = find(doc->Str("id")); if (!r) continue; // status for an unknown order: skip, keep folding const std::string_view status = doc->Str("status"); if (status.empty()) continue; r->status = std::string(status); r->updatedAt = std::string(doc->Str("at")); if (const std::string_view via = doc->Str("via"); !via.empty()) { r->paidVia = std::string(via); } } } return out; } } // namespace void SetOrdersPath(const std::filesystem::path& p) { std::lock_guard lock(gOrdersMutex); gOrdersPath = p; } bool CreateOrder(const OrderRecord& o) { std::lock_guard lock(gOrdersMutex); 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"("status":"{}","pay_url":"{}","pay_id":"{}"}})", JsonEscape(o.createdAt), JsonEscape(o.token), JsonEscape(o.reference), JsonEscape(o.product), JsonEscape(o.color), o.quantity, o.unitMinor, 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, JsonEscape(o.status), JsonEscape(o.payUrl), JsonEscape(o.payId))); } bool AppendOrderStatus(std::string_view token, std::string_view status, std::string_view isoTimestamp, std::string_view via) { std::lock_guard lock(gOrdersMutex); if (via.empty()) { return AppendLine(std::format( R"({{"type":"status","at":"{}","id":"{}","status":"{}"}})", JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(status))); } return AppendLine(std::format( R"({{"type":"status","at":"{}","id":"{}","status":"{}","via":"{}"}})", JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(status), JsonEscape(via))); } namespace { // Case-normalised email: the customer key. Good enough on purpose — a person // with two addresses is two customers, exactly as they would be in the manual // administration this scheme continues. std::string CustomerKey(std::string_view email) { std::string out(email); for (char& c : out) { if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); } return out; } // A random v4 UUID — the customer number, matching the pre-shop invoice // administration (folders named by customer UUID, invoices -). std::string NewCustomerUuid() { std::random_device rd; std::array w{ rd(), rd(), rd(), rd() }; auto* b = reinterpret_cast(w.data()); b[6] = static_cast((b[6] & 0x0f) | 0x40); // version 4 b[8] = static_cast((b[8] & 0x3f) | 0x80); // variant 10 std::string out; out.reserve(36); static constexpr char hex[] = "0123456789abcdef"; for (int i = 0; i < 16; ++i) { if (i == 4 || i == 6 || i == 8 || i == 10) out += '-'; out += hex[b[i] >> 4]; out += hex[b[i] & 0xf]; } return out; } } // namespace std::optional AssignInvoiceNumber(std::string_view token, std::string_view isoTimestamp) { // Numbering continues the shop owner's existing administration: one // SERIES PER CUSTOMER (a random UUID as customer number), sequential // within it — "f57c6512-…-3" is that customer's third invoice. Multiple // series are what art. 226(2)'s "one or more series" permits, and the // append-only ledger plus the payment provider's records carry the // completeness proof an auditor actually wants. std::lock_guard lock(gOrdersMutex); const OrderRecord* target = nullptr; std::vector all = FoldLocked(); for (const OrderRecord& r : all) { if (r.token == token) { target = &r; break; } } if (!target) return std::nullopt; // Idempotent: a paid order re-processed (manual CLI after the reconciler, // say) keeps its number — a sequence never burns a member on a retry. if (!target->invoiceNumber.empty()) return target->invoiceNumber; // The customer's existing series, if any: same email (case-normalised), // highest sequence. Invoice numbers are "-". const std::string key = CustomerKey(target->buyer.email); std::string customer; std::int64_t maxSeq = 0; for (const OrderRecord& r : all) { if (r.invoiceNumber.size() < 38 || CustomerKey(r.buyer.email) != key) continue; customer = r.invoiceNumber.substr(0, 36); std::int64_t seq = 0; const char* b = r.invoiceNumber.data() + 37; std::from_chars(b, r.invoiceNumber.data() + r.invoiceNumber.size(), seq); maxSeq = std::max(maxSeq, seq); } if (customer.empty()) customer = NewCustomerUuid(); const std::string number = std::format("{}-{}", customer, maxSeq + 1); if (!AppendLine(std::format( R"({{"type":"invoice","at":"{}","id":"{}","number":"{}","customer":"{}"}})", JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(number), JsonEscape(customer)))) { return std::nullopt; } return number; } std::optional FindOrder(std::string_view token) { std::lock_guard lock(gOrdersMutex); for (OrderRecord& r : FoldLocked()) { if (r.token == token) return std::move(r); } return std::nullopt; } std::vector ListOrders() { std::lock_guard lock(gOrdersMutex); return FoldLocked(); } std::string NewOrderToken() { // std::random_device on this platform reads the kernel CSPRNG. The token // gates access to a name and address, so 128 bits — the same order of // unguessability as a session cookie. std::random_device rd; std::string out; out.reserve(32); for (int i = 0; i < 4; ++i) { const std::uint32_t w = rd(); out += std::format("{:08x}", w); } return out; } std::string ReferenceFromToken(std::string_view token) { // Derived, not random: an order can never carry a mismatched pair. Six hex // chars is what a human will actually type into a transfer description; at // this volume a collision is a curiosity, and the amount+time still // disambiguate at reconciliation. std::string out = "CC-"; for (std::size_t i = 0; i < 6 && i < token.size(); ++i) { char c = token[i]; if (c >= 'a' && c <= 'z') c = static_cast(c - 'a' + 'A'); out += c; } return out; } } // namespace Catcrafts::Server