All checks were successful
Deploy / build-deploy (push) Successful in 6m14s
318 lines
13 KiB
C++
318 lines
13 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.
|
|
*/
|
|
|
|
// Order storage: an append-only JSON-lines event log.
|
|
//
|
|
// Four event types share the file:
|
|
//
|
|
// {"type":"order", ...full record...} written once, at checkout
|
|
// {"type":"status", "id":..,"status":..} one per transition
|
|
// {"type":"invoice", "id":..,"number":..} the number assignment, at paid
|
|
// {"type":"notified","id":..,"what":..} the confirmation email left
|
|
//
|
|
// 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 == "notified") {
|
|
OrderRecord* r = find(doc->Str("id"));
|
|
if (!r) continue;
|
|
// "what" names the message so a future shipped-notice can share
|
|
// the event type without re-marking the confirmation as sent.
|
|
if (doc->Str("what") == "confirmation") {
|
|
r->confirmationSentAt = 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;
|
|
}
|
|
|
|
bool AppendOrderNotified(std::string_view token, std::string_view isoTimestamp) {
|
|
std::lock_guard lock(gOrdersMutex);
|
|
return AppendLine(std::format(
|
|
R"({{"type":"notified","at":"{}","id":"{}","what":"confirmation"}})",
|
|
JsonEscape(isoTimestamp), JsonEscape(token)));
|
|
}
|
|
|
|
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
|