catcrafts.net/server/implementations/Catcrafts.Server-Orders.cpp

301 lines
12 KiB
C++
Raw Normal View History

2026-08-05 04:18:37 +02:00
/*
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<unsigned char>(c) < 0x20) {
out += std::format("\\u{:04x}", static_cast<unsigned char>(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<bool>(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<OrderRecord> FoldLocked() {
std::vector<OrderRecord> 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<char>(c - 'A' + 'a');
}
return out;
}
// A random v4 UUID — the customer number, matching the pre-shop invoice
// administration (folders named by customer UUID, invoices <uuid>-<n>).
std::string NewCustomerUuid() {
std::random_device rd;
std::array<std::uint32_t, 4> w{ rd(), rd(), rd(), rd() };
auto* b = reinterpret_cast<unsigned char*>(w.data());
b[6] = static_cast<unsigned char>((b[6] & 0x0f) | 0x40); // version 4
b[8] = static_cast<unsigned char>((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<std::string> 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<OrderRecord> 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 "<uuid(36)>-<seq>".
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<OrderRecord> 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<OrderRecord> 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<char>(c - 'a' + 'A');
out += c;
}
return out;
}
} // namespace Catcrafts::Server