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.
|
|
|
|
|
//
|
2026-08-09 00:14:09 +02:00
|
|
|
// Four event types share the file:
|
2026-08-05 04:18:37 +02:00
|
|
|
//
|
2026-08-09 00:14:09 +02:00
|
|
|
// {"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
|
2026-08-05 04:18:37 +02:00
|
|
|
//
|
2026-08-13 23:34:19 +02:00
|
|
|
// New keys on the order event are additive: the fold defaults anything absent,
|
|
|
|
|
// so a ledger written by an older build still reads correctly under a newer
|
|
|
|
|
// one. That property is the reason nothing here is ever rewritten in place.
|
|
|
|
|
//
|
2026-08-05 04:18:37 +02:00
|
|
|
// 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");
|
2026-08-17 11:04:03 +02:00
|
|
|
// Additive key: absent (every pre-donations line) reads false,
|
|
|
|
|
// so an old ledger's orders all stay sales.
|
|
|
|
|
r.donation = doc->Bool("donation");
|
2026-08-05 04:18:37 +02:00
|
|
|
r.status = std::string(doc->Str("status", "awaiting_payment"));
|
2026-08-13 23:34:19 +02:00
|
|
|
// Read as written, with no default applied here: the ledger
|
|
|
|
|
// should keep saying exactly what it recorded, and resolving an
|
|
|
|
|
// unexpected value to a rail is PaymentRails::For's single job.
|
|
|
|
|
r.payChoice = std::string(doc->Str("pay_choice"));
|
2026-08-05 04:18:37 +02:00
|
|
|
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"));
|
2026-08-09 00:14:09 +02:00
|
|
|
} 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"));
|
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
} 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);
|
|
|
|
|
}
|
2026-08-14 02:50:58 +02:00
|
|
|
// The FIRST paid event is the sale, whatever happens later — a
|
|
|
|
|
// refund folds the status onward but never unhappens the payment
|
|
|
|
|
// (see SummarizeSales). First, not last, so a replayed line
|
|
|
|
|
// cannot move the recorded moment.
|
|
|
|
|
if (status == "paid" && r->paidAt.empty()) {
|
|
|
|
|
r->paidAt = std::string(doc->Str("at"));
|
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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);
|
2026-08-17 11:04:03 +02:00
|
|
|
// "donation" is written only when true, matching the status writer's
|
|
|
|
|
// omit-rather-than-empty rule: absent-means-false is what lets a ledger
|
|
|
|
|
// from before donations existed keep reading correctly.
|
2026-08-05 04:18:37 +02:00
|
|
|
return AppendLine(std::format(
|
|
|
|
|
R"({{"type":"order","at":"{}","id":"{}","ref":"{}","product":"{}",)"
|
|
|
|
|
R"("color":"{}","quantity":{},"unit_minor":{},)"
|
|
|
|
|
R"("email":"{}","name":"{}","street":"{}","postal":"{}","city":"{}","country":"{}",)"
|
2026-08-17 11:04:03 +02:00
|
|
|
R"("goods_minor":{},"shipping_minor":{},"total_minor":{},"vat_included":{},{})"
|
2026-08-13 23:34:19 +02:00
|
|
|
R"("status":"{}","pay_choice":"{}","pay_url":"{}","pay_id":"{}"}})",
|
2026-08-05 04:18:37 +02:00
|
|
|
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,
|
2026-08-17 11:04:03 +02:00
|
|
|
o.donation ? R"("donation":true,)" : "",
|
2026-08-13 23:34:19 +02:00
|
|
|
JsonEscape(o.status), JsonEscape(o.payChoice),
|
|
|
|
|
JsonEscape(o.payUrl), JsonEscape(o.payId)));
|
2026-08-05 04:18:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 00:14:09 +02:00
|
|
|
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)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 02:50:58 +02:00
|
|
|
SalesSummary SummarizeSales(std::span<const OrderRecord> orders) {
|
|
|
|
|
SalesSummary out;
|
|
|
|
|
for (const OrderRecord& r : orders) {
|
|
|
|
|
// paidAt is the signal. The status check keeps faith with a ledger
|
|
|
|
|
// whose order was marked shipped by hand without a paid event ever
|
|
|
|
|
// being written — the same paid-or-shipped idiom the invoice
|
|
|
|
|
// download uses.
|
|
|
|
|
if (r.paidAt.empty() && r.status != "paid" && r.status != "shipped") continue;
|
2026-08-17 11:04:03 +02:00
|
|
|
// A paid donation is income but not a sale: /financials shows it in
|
|
|
|
|
// the donations row, and counting it here too would double-book it.
|
|
|
|
|
if (r.donation) {
|
|
|
|
|
++out.donationCount;
|
|
|
|
|
out.donationsMinor += r.totalMinor;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-14 02:50:58 +02:00
|
|
|
++out.count;
|
|
|
|
|
out.totalMinor += r.totalMinor;
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 20:15:47 +02:00
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
// ISO 7064 mod-97-10 over an alphanumeric string, the same arithmetic that
|
|
|
|
|
// checks an IBAN: letters become two digits (A=10 … Z=35), everything is read
|
|
|
|
|
// as one long decimal number, and the remainder mod 97 is taken. Folded
|
|
|
|
|
// incrementally so no big-integer type is needed — the running value never
|
|
|
|
|
// exceeds 97*100+35, which fits an int comfortably.
|
|
|
|
|
//
|
|
|
|
|
// Returns nullopt on any character that is not [0-9A-Z], because silently
|
|
|
|
|
// skipping one would make two different references check out identically.
|
|
|
|
|
std::optional<int> Mod97(std::string_view s) {
|
|
|
|
|
int rem = 0;
|
|
|
|
|
for (const char c : s) {
|
|
|
|
|
if (c >= '0' && c <= '9') {
|
|
|
|
|
rem = (rem * 10 + (c - '0')) % 97;
|
|
|
|
|
} else if (c >= 'A' && c <= 'Z') {
|
|
|
|
|
const int v = c - 'A' + 10;
|
|
|
|
|
rem = (rem * 100 + v) % 97;
|
|
|
|
|
} else {
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The body an RF reference carries: the CC- reference with its hyphen dropped,
|
|
|
|
|
// because ISO 11649 permits only alphanumerics. "CC-2B6457" -> "CC2B6457".
|
|
|
|
|
std::string ReferenceBody(std::string_view token) {
|
|
|
|
|
const std::string human = ReferenceFromToken(token);
|
|
|
|
|
std::string body;
|
|
|
|
|
body.reserve(human.size());
|
|
|
|
|
for (const char c : human) {
|
|
|
|
|
if (c != '-') body += c;
|
|
|
|
|
}
|
|
|
|
|
return body;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
std::string CreditorReferenceFromToken(std::string_view token) {
|
|
|
|
|
const std::string body = ReferenceBody(token);
|
|
|
|
|
// The check digits are computed over the body followed by "RF00" — the
|
|
|
|
|
// standard's rearrangement, prefix and placeholder moved to the end.
|
|
|
|
|
const std::optional<int> rem = Mod97(body + "RF00");
|
|
|
|
|
if (!rem) return {}; // unreachable for our own token alphabet
|
|
|
|
|
const int check = 98 - *rem;
|
|
|
|
|
return std::format("RF{:02}{}", check, body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool IsValidCreditorReference(std::string_view s) {
|
|
|
|
|
// "RF" + 2 check digits + 1..21 body characters.
|
|
|
|
|
if (s.size() < 5 || s.size() > 25) return false;
|
|
|
|
|
if (s[0] != 'R' || s[1] != 'F') return false;
|
|
|
|
|
if (s[2] < '0' || s[2] > '9' || s[3] < '0' || s[3] > '9') return false;
|
|
|
|
|
// Rearranged the same way the generator does it, then the whole thing must
|
|
|
|
|
// leave a remainder of exactly 1 — that is what mod-97-10 verification is.
|
|
|
|
|
std::string rearranged(s.substr(4));
|
|
|
|
|
rearranged += s.substr(0, 4);
|
|
|
|
|
const std::optional<int> rem = Mod97(rearranged);
|
|
|
|
|
return rem && *rem == 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
} // namespace Catcrafts::Server
|