This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -1,562 +0,0 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// The payment rails: bunq (real money) and fake (tests).
|
||||
//
|
||||
// The bunq client speaks the v1 REST API over Crafter.Network's ClientHTTP1
|
||||
// with TLS — no SDK, because the four calls this needs (installation,
|
||||
// device-server, session-server, bunqme-tab) do not justify a dependency, and
|
||||
// every byte in and out goes through the same strict JSON reader as the rest
|
||||
// of the site.
|
||||
//
|
||||
// Context (RSA key, installation token, session token, ids) persists in ONE
|
||||
// JSON file under the service's StateDirectory — never in the repo, never in
|
||||
// the web root. Delete the file and the client re-onboards from the API key.
|
||||
//
|
||||
// A word on trust direction: this code never treats an inbound signal as
|
||||
// authoritative. The ?status= on bunq's redirect back to the order page is
|
||||
// ignored entirely; an order becomes paid ONLY when an authenticated GET to
|
||||
// bunq's API says the tab's payments cover the amount. That is the poll — the
|
||||
// reconciler in Catcrafts.Server-Http.cpp drives it.
|
||||
//
|
||||
// Request signing: bunq stopped REQUIRING body signatures in 2019, but the
|
||||
// keypair exists anyway (installation demands a public key), signing is ~40
|
||||
// lines, and a signed request is valid whether or not the server checks. So
|
||||
// every body is signed — X-Bunq-Client-Signature, RSA-SHA256 over the raw
|
||||
// body, base64.
|
||||
|
||||
module;
|
||||
#include <openssl/bio.h>
|
||||
#include <openssl/err.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#include <openssl/rsa.h>
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
// ── small pure helpers ────────────────────────────────────────────────
|
||||
|
||||
std::string Base64(std::span<const unsigned char> in) {
|
||||
static constexpr char tbl[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve(((in.size() + 2) / 3) * 4);
|
||||
std::size_t i = 0;
|
||||
for (; i + 2 < in.size(); i += 3) {
|
||||
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8) | in[i + 2];
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += tbl[(n >> 6) & 63]; out += tbl[n & 63];
|
||||
}
|
||||
if (i + 1 == in.size()) {
|
||||
const std::uint32_t n = in[i] << 16;
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += "==";
|
||||
} else if (i + 2 == in.size()) {
|
||||
const std::uint32_t n = (in[i] << 16) | (in[i + 1] << 8);
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += tbl[(n >> 6) & 63]; out += '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string JsonEscapeB(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;
|
||||
}
|
||||
|
||||
std::string RandomHex(std::size_t words) {
|
||||
std::random_device rd;
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < words; ++i) out += std::format("{:08x}", rd());
|
||||
return out;
|
||||
}
|
||||
|
||||
// Find the first object under any key in bunq's Response array:
|
||||
// {"Response":[{"Id":{...}},{"Token":{...}}]}
|
||||
const Json::Value* FindInResponse(const Json::Value& doc, std::string_view key) {
|
||||
const Json::Value* resp = doc.Find("Response");
|
||||
if (!resp || !resp->IsArray()) return nullptr;
|
||||
for (const Json::Value& item : resp->array) {
|
||||
if (!item.IsObject()) continue;
|
||||
if (const Json::Value* v = item.Find(key)) return v;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s) {
|
||||
// Exactly: 1*DIGIT ["." 1*2DIGIT]. Anything else — signs, exponents,
|
||||
// spaces, thousands separators — is rejected. Money parsing has no
|
||||
// "probably fine" mode.
|
||||
if (s.empty() || s.size() > 15) return std::nullopt;
|
||||
std::int64_t units = 0;
|
||||
std::size_t i = 0;
|
||||
if (s[i] < '0' || s[i] > '9') return std::nullopt;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
units = units * 10 + (s[i] - '0');
|
||||
}
|
||||
std::int64_t cents = 0;
|
||||
if (i < s.size()) {
|
||||
if (s[i] != '.') return std::nullopt;
|
||||
++i;
|
||||
const std::size_t fracStart = i;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
cents = cents * 10 + (s[i] - '0');
|
||||
}
|
||||
const std::size_t digits = i - fracStart;
|
||||
if (i != s.size() || digits == 0 || digits > 2) return std::nullopt;
|
||||
if (digits == 1) cents *= 10;
|
||||
}
|
||||
return units * 100 + cents;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// ── the fake rail ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Exists so the ENTIRE order lifecycle — checkout, storage, status page,
|
||||
// reconciler, paid transition — runs in e2e with zero network. Payment links
|
||||
// point at a made-up URL; CheckPaid answers true once a marker file exists,
|
||||
// which the test creates when it wants "the customer has paid" to happen.
|
||||
|
||||
class FakeRail final : public PaymentRail {
|
||||
public:
|
||||
explicit FakeRail(std::filesystem::path marker) : marker_(std::move(marker)) {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t, const std::string&,
|
||||
const std::string& redirectUrl) override {
|
||||
static std::atomic<std::int64_t> counter{1};
|
||||
PaymentLink link;
|
||||
link.payId = std::format("fake-{}", counter.fetch_add(1));
|
||||
// Checkout 303s the buyer to payUrl. The fake rail has no checkout to
|
||||
// send anyone to, so it points at the order page itself — which keeps
|
||||
// the browser flow usable in dev and the e2e redirect parseable.
|
||||
link.payUrl = redirectUrl;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string&, std::int64_t) override {
|
||||
std::error_code ec;
|
||||
return PaidStatus{
|
||||
std::filesystem::exists(marker_, ec) ? PayState::Paid : PayState::Pending,
|
||||
"fake" };
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "fake"; }
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(1); }
|
||||
|
||||
private:
|
||||
std::filesystem::path marker_;
|
||||
};
|
||||
|
||||
// ── the bunq rail ─────────────────────────────────────────────────────
|
||||
|
||||
class BunqRail final : public PaymentRail {
|
||||
public:
|
||||
explicit BunqRail(RailConfig cfg)
|
||||
: cfg_(std::move(cfg)),
|
||||
host_(cfg_.sandbox ? "public-api.sandbox.bunq.com" : "api.bunq.com") {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||
const std::string& description,
|
||||
const std::string& redirectUrl) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!EnsureSession()) return std::nullopt;
|
||||
|
||||
const std::string body = std::format(
|
||||
R"({{"bunqme_tab_entry":{{"amount_inquired":{{"value":"{}","currency":"EUR"}},)"
|
||||
R"("description":"{}","redirect_url":"{}"}}}})",
|
||||
Money::FormatMinor(amountMinor), JsonEscapeB(description),
|
||||
JsonEscapeB(redirectUrl));
|
||||
|
||||
auto doc = Call("POST", TabsPath(), body);
|
||||
if (!doc) return std::nullopt;
|
||||
const Json::Value* id = FindInResponse(*doc, "Id");
|
||||
if (!id) return std::nullopt;
|
||||
const std::int64_t tabId = id->Int("id");
|
||||
if (tabId <= 0) return std::nullopt;
|
||||
|
||||
// The POST answers with the id only; the share URL comes from a GET.
|
||||
auto tab = Call("GET", TabsPath() + "/" + std::to_string(tabId), {});
|
||||
if (!tab) return std::nullopt;
|
||||
const Json::Value* bmt = FindInResponse(*tab, "BunqMeTab");
|
||||
if (!bmt) return std::nullopt;
|
||||
const std::string url(bmt->Str("bunqme_tab_share_url"));
|
||||
if (url.empty()) return std::nullopt;
|
||||
|
||||
PaymentLink link;
|
||||
link.payId = std::to_string(tabId);
|
||||
link.payUrl = url;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||
std::int64_t expectedMinor) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
// The id is a bunq tab number that travelled through our ledger.
|
||||
std::int64_t tabId = 0;
|
||||
auto [ptr, ec] = std::from_chars(payId.data(), payId.data() + payId.size(), tabId);
|
||||
if (ec != std::errc{} || ptr != payId.data() + payId.size() || tabId <= 0) {
|
||||
return PaidStatus{ PayState::Dead, {} };
|
||||
}
|
||||
if (!EnsureSession()) return std::nullopt;
|
||||
|
||||
auto tab = Call("GET", TabsPath() + "/" + std::to_string(tabId), {});
|
||||
if (!tab) return std::nullopt;
|
||||
const Json::Value* bmt = FindInResponse(*tab, "BunqMeTab");
|
||||
if (!bmt) return std::nullopt;
|
||||
|
||||
// Sum every settled inquiry on the tab. A tab accepts unlimited
|
||||
// payments until cancelled, so the question is "do the payments cover
|
||||
// the amount", not "is there a payment".
|
||||
std::int64_t paid = 0;
|
||||
if (const Json::Value* inquiries = bmt->Find("result_inquiries");
|
||||
inquiries && inquiries->IsArray()) {
|
||||
for (const Json::Value& entry : inquiries->array) {
|
||||
if (!entry.IsObject()) continue;
|
||||
const Json::Value* payment = entry.Find("payment");
|
||||
if (payment && payment->IsObject()) {
|
||||
if (const Json::Value* inner = payment->Find("Payment");
|
||||
inner && inner->IsObject()) payment = inner;
|
||||
}
|
||||
if (!payment) continue;
|
||||
const Json::Value* amount = payment->Find("amount");
|
||||
if (!amount || !amount->IsObject()) continue;
|
||||
if (amount->Str("currency") != "EUR") continue;
|
||||
if (auto minor = ParseAmountToMinor(amount->Str("value"))) {
|
||||
paid += *minor;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A bunq tab never dies on its own — it accepts payments until
|
||||
// cancelled — so the only states here are Paid and Pending. The
|
||||
// method is not identified per payment; "bunq" is honest enough.
|
||||
return PaidStatus{ paid >= expectedMinor ? PayState::Paid : PayState::Pending,
|
||||
"bunq" };
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "bunq"; }
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(15); }
|
||||
|
||||
private:
|
||||
// ── context persistence ───────────────────────────────────────────
|
||||
|
||||
void LoadState() {
|
||||
std::ifstream in(cfg_.statePath, std::ios::binary);
|
||||
if (!in) return;
|
||||
std::ostringstream buf;
|
||||
buf << in.rdbuf();
|
||||
auto doc = Json::Parse(buf.str());
|
||||
if (!doc || !doc->IsObject()) return;
|
||||
privateKeyPem_ = std::string(doc->Str("private_key_pem"));
|
||||
installationToken_ = std::string(doc->Str("installation_token"));
|
||||
deviceRegistered_ = doc->Bool("device_registered");
|
||||
sessionToken_ = std::string(doc->Str("session_token"));
|
||||
userId_ = doc->Int("user_id");
|
||||
accountId_ = doc->Int("account_id");
|
||||
}
|
||||
|
||||
bool SaveState() {
|
||||
// 0600 before content: the file holds the private key.
|
||||
std::ofstream out(cfg_.statePath, std::ios::trunc | std::ios::binary);
|
||||
if (!out) return false;
|
||||
out << std::format(
|
||||
R"({{"private_key_pem":"{}","installation_token":"{}",)"
|
||||
R"("device_registered":{},"session_token":"{}","user_id":{},"account_id":{}}})",
|
||||
JsonEscapeB(privateKeyPem_), JsonEscapeB(installationToken_),
|
||||
deviceRegistered_, JsonEscapeB(sessionToken_), userId_, accountId_);
|
||||
out.flush();
|
||||
std::error_code ec;
|
||||
std::filesystem::permissions(cfg_.statePath,
|
||||
std::filesystem::perms::owner_read
|
||||
| std::filesystem::perms::owner_write,
|
||||
ec);
|
||||
return static_cast<bool>(out);
|
||||
}
|
||||
|
||||
// ── crypto ────────────────────────────────────────────────────────
|
||||
|
||||
bool EnsureKeypair() {
|
||||
if (!privateKeyPem_.empty()) return LoadKey();
|
||||
EVP_PKEY* raw = EVP_RSA_gen(2048);
|
||||
if (!raw) return false;
|
||||
key_.reset(raw);
|
||||
|
||||
BIO* bio = BIO_new(BIO_s_mem());
|
||||
if (!bio) return false;
|
||||
if (PEM_write_bio_PrivateKey(bio, key_.get(), nullptr, nullptr, 0, nullptr, nullptr) != 1) {
|
||||
BIO_free(bio);
|
||||
return false;
|
||||
}
|
||||
char* data = nullptr;
|
||||
const long len = BIO_get_mem_data(bio, &data);
|
||||
privateKeyPem_.assign(data, static_cast<std::size_t>(len));
|
||||
BIO_free(bio);
|
||||
return SaveState();
|
||||
}
|
||||
|
||||
bool LoadKey() {
|
||||
if (key_) return true;
|
||||
BIO* bio = BIO_new_mem_buf(privateKeyPem_.data(),
|
||||
static_cast<int>(privateKeyPem_.size()));
|
||||
if (!bio) return false;
|
||||
EVP_PKEY* raw = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
|
||||
BIO_free(bio);
|
||||
if (!raw) return false;
|
||||
key_.reset(raw);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string PublicKeyPem() {
|
||||
if (!LoadKey()) return {};
|
||||
BIO* bio = BIO_new(BIO_s_mem());
|
||||
if (!bio) return {};
|
||||
if (PEM_write_bio_PUBKEY(bio, key_.get()) != 1) {
|
||||
BIO_free(bio);
|
||||
return {};
|
||||
}
|
||||
char* data = nullptr;
|
||||
const long len = BIO_get_mem_data(bio, &data);
|
||||
std::string pem(data, static_cast<std::size_t>(len));
|
||||
BIO_free(bio);
|
||||
return pem;
|
||||
}
|
||||
|
||||
std::string SignBody(std::string_view body) {
|
||||
if (!LoadKey()) return {};
|
||||
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||
if (!ctx) return {};
|
||||
std::string out;
|
||||
do {
|
||||
if (EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr, key_.get()) != 1) break;
|
||||
std::size_t len = 0;
|
||||
if (EVP_DigestSign(ctx, nullptr, &len,
|
||||
reinterpret_cast<const unsigned char*>(body.data()),
|
||||
body.size()) != 1) break;
|
||||
std::vector<unsigned char> sig(len);
|
||||
if (EVP_DigestSign(ctx, sig.data(), &len,
|
||||
reinterpret_cast<const unsigned char*>(body.data()),
|
||||
body.size()) != 1) break;
|
||||
sig.resize(len);
|
||||
out = Base64(sig);
|
||||
} while (false);
|
||||
EVP_MD_CTX_free(ctx);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── transport ─────────────────────────────────────────────────────
|
||||
|
||||
// One HTTPS call, returning the parsed JSON on 2xx. On 401 with a live
|
||||
// session the caller decides whether to re-session; this layer only
|
||||
// reports. Network and TLS failures land as nullopt — the reconciler
|
||||
// treats that as "unknown, retry later", never as "unpaid".
|
||||
std::optional<Json::Value> DoCall(std::string_view method, const std::string& path,
|
||||
const std::string& body, const std::string& authToken,
|
||||
std::string* statusOut = nullptr) {
|
||||
try {
|
||||
if (!client_) {
|
||||
client_ = std::make_unique<Crafter::ClientHTTP1>(
|
||||
host_, static_cast<std::uint16_t>(443),
|
||||
Crafter::TLSClientCredentials{});
|
||||
}
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = std::string(method);
|
||||
req.path = path;
|
||||
req.authority = host_;
|
||||
req.body = body;
|
||||
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||
req.headers["cache-control"] = "no-cache";
|
||||
req.headers["x-bunq-client-request-id"] = RandomHex(4);
|
||||
req.headers["x-bunq-geolocation"] = "0 0 0 0 000";
|
||||
req.headers["x-bunq-language"] = "en_US";
|
||||
req.headers["x-bunq-region"] = "nl_NL";
|
||||
if (!body.empty()) {
|
||||
req.headers["content-type"] = "application/json";
|
||||
const std::string sig = SignBody(body);
|
||||
if (!sig.empty()) req.headers["x-bunq-client-signature"] = sig;
|
||||
}
|
||||
if (!authToken.empty()) {
|
||||
req.headers["x-bunq-client-authentication"] = authToken;
|
||||
}
|
||||
|
||||
const Crafter::HTTPResponse res = client_->Send(req);
|
||||
if (statusOut) *statusOut = res.status;
|
||||
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||
std::println(std::cerr, "bunq: {} {} -> {} {}", method, path, res.status,
|
||||
res.body.substr(0, 200));
|
||||
return std::nullopt;
|
||||
}
|
||||
auto doc = Json::Parse(res.body);
|
||||
if (!doc) return std::nullopt;
|
||||
return std::move(*doc);
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "bunq: {} {} failed: {}", method, path, e.what());
|
||||
client_.reset(); // dial fresh next time
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
// A session-authenticated call, with one automatic re-session on 401 —
|
||||
// sessions expire server-side and that must not surface as a failure.
|
||||
std::optional<Json::Value> Call(std::string_view method, const std::string& path,
|
||||
const std::string& body) {
|
||||
std::string status;
|
||||
auto doc = DoCall(method, path, body, sessionToken_, &status);
|
||||
if (!doc && status == "401") {
|
||||
sessionToken_.clear();
|
||||
if (!EnsureSession()) return std::nullopt;
|
||||
doc = DoCall(method, path, body, sessionToken_, &status);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── onboarding ────────────────────────────────────────────────────
|
||||
//
|
||||
// installation (once, ever) -> installation token
|
||||
// device-server (once, ever) -> binds the API key to this "device"
|
||||
// session-server (per session) -> session token + user id
|
||||
// monetary-account (once) -> account to attach tabs to
|
||||
//
|
||||
// All idempotent to re-run individually; state records how far we got.
|
||||
|
||||
bool EnsureSession() {
|
||||
if (!loaded_) { LoadState(); loaded_ = true; }
|
||||
if (cfg_.apiKey.empty()) {
|
||||
std::println(std::cerr, "bunq: no API key configured");
|
||||
return false;
|
||||
}
|
||||
if (!EnsureKeypair()) return false;
|
||||
|
||||
if (installationToken_.empty()) {
|
||||
const std::string body =
|
||||
std::format(R"({{"client_public_key":"{}"}})", JsonEscapeB(PublicKeyPem()));
|
||||
auto doc = DoCall("POST", "/v1/installation", body, {});
|
||||
if (!doc) return false;
|
||||
const Json::Value* token = FindInResponse(*doc, "Token");
|
||||
if (!token) return false;
|
||||
installationToken_ = std::string(token->Str("token"));
|
||||
if (installationToken_.empty()) return false;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (!deviceRegistered_) {
|
||||
// permitted_ips "*": this box sits on a residential connection
|
||||
// whose address changes; pinning the current IP would brick the
|
||||
// integration on the next DHCP lease. The API key secret still
|
||||
// gates everything.
|
||||
const std::string body = std::format(
|
||||
R"({{"description":"catcrafts.net server","secret":"{}","permitted_ips":["*"]}})",
|
||||
JsonEscapeB(cfg_.apiKey));
|
||||
auto doc = DoCall("POST", "/v1/device-server", body, installationToken_);
|
||||
if (!doc) return false;
|
||||
deviceRegistered_ = true;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (sessionToken_.empty() || userId_ == 0) {
|
||||
const std::string body =
|
||||
std::format(R"({{"secret":"{}"}})", JsonEscapeB(cfg_.apiKey));
|
||||
auto doc = DoCall("POST", "/v1/session-server", body, installationToken_);
|
||||
if (!doc) return false;
|
||||
const Json::Value* token = FindInResponse(*doc, "Token");
|
||||
if (!token) return false;
|
||||
sessionToken_ = std::string(token->Str("token"));
|
||||
// The user object's key varies by account type; take whichever came.
|
||||
for (std::string_view k : { "UserPerson", "UserCompany", "UserApiKey" }) {
|
||||
if (const Json::Value* u = FindInResponse(*doc, k)) {
|
||||
userId_ = u->Int("id");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sessionToken_.empty() || userId_ == 0) return false;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
if (accountId_ == 0) {
|
||||
auto doc = Call("GET",
|
||||
std::format("/v1/user/{}/monetary-account?count=25", userId_), {});
|
||||
if (!doc) return false;
|
||||
const Json::Value* resp = doc->Find("Response");
|
||||
if (!resp || !resp->IsArray()) return false;
|
||||
for (const Json::Value& item : resp->array) {
|
||||
const Json::Value* acc = item.Find("MonetaryAccountBank");
|
||||
if (acc && acc->Str("status") == "ACTIVE") {
|
||||
accountId_ = acc->Int("id");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (accountId_ == 0) {
|
||||
std::println(std::cerr, "bunq: no active MonetaryAccountBank found");
|
||||
return false;
|
||||
}
|
||||
SaveState();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string TabsPath() const {
|
||||
return std::format("/v1/user/{}/monetary-account/{}/bunqme-tab", userId_, accountId_);
|
||||
}
|
||||
|
||||
struct PkeyDeleter {
|
||||
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
|
||||
};
|
||||
|
||||
RailConfig cfg_;
|
||||
std::string host_;
|
||||
std::mutex mutex_;
|
||||
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
||||
std::unique_ptr<EVP_PKEY, PkeyDeleter> key_;
|
||||
bool loaded_ = false;
|
||||
std::string privateKeyPem_;
|
||||
std::string installationToken_;
|
||||
bool deviceRegistered_ = false;
|
||||
std::string sessionToken_;
|
||||
std::int64_t userId_ = 0;
|
||||
std::int64_t accountId_ = 0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// The roster itself (MakeRail) lives in the Mollie unit; these two factories
|
||||
// keep FakeRail/BunqRail construction next to their definitions.
|
||||
std::unique_ptr<PaymentRail> MakeFakeRail(const RailConfig& config) {
|
||||
return std::make_unique<FakeRail>(config.statePath);
|
||||
}
|
||||
|
||||
std::unique_ptr<PaymentRail> MakeBunqRail(const RailConfig& config) {
|
||||
return std::make_unique<BunqRail>(config);
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
286
server/implementations/Catcrafts.Server-Coingate.cpp
Normal file
286
server/implementations/Catcrafts.Server-Coingate.cpp
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// The CoinGate payment rail — the crypto half of the checkout.
|
||||
//
|
||||
// Why a processor rather than a self-hosted node: accepting crypto for goods
|
||||
// makes this shop a MERCHANT, not a crypto-asset service provider, under either
|
||||
// arrangement. What differs is everything around it. CoinGate is MiCA-licensed
|
||||
// (mandatory to serve EU clients since 1 July 2026) and settles EUR to the
|
||||
// business account by SEPA at a locked rate, so the money that lands is the
|
||||
// money the invoice says, the bookkeeping line is identical to Mollie's, and no
|
||||
// coin ever sits on this balance sheet waiting to move in price. A self-hosted
|
||||
// BTCPay would cost 1% less and a Bitcoin node's worth of operations, custody
|
||||
// and per-payment revaluation — a trade worth making for sovereignty, not for a
|
||||
// tail of international orders.
|
||||
//
|
||||
// Which is why receive_currency is EUR below and not DO_NOT_CONVERT: that one
|
||||
// parameter is the whole difference between "a second Mollie" and "the shop now
|
||||
// holds crypto". Changing it is a tax decision, not a code cleanup.
|
||||
//
|
||||
// The API is the same shape as Mollie's, so this client is the same shape as
|
||||
// that one:
|
||||
//
|
||||
// POST /api/v2/orders {price_amount, price_currency, …} -> id + payment_url
|
||||
// GET /api/v2/orders/{id} -> status, pay_currency
|
||||
//
|
||||
// Two differences from Mollie worth knowing. Requests are form-encoded, which
|
||||
// is what every CoinGate example uses and what their v2 API is documented
|
||||
// against — the responses are JSON either way, and JSON is the only direction
|
||||
// that matters here, since it is the one carrying money. And their ids are JSON
|
||||
// NUMBERS, not strings, so the parser renders them to decimal (see
|
||||
// ParseCoingateOrder) and the ledger stores text like it does for every rail.
|
||||
//
|
||||
// Trust direction is the design rule and is unchanged: CoinGate can be told a
|
||||
// callback_url and it is deliberately NOT given one. An order becomes paid only
|
||||
// when an authenticated GET says status=paid over a covering EUR amount.
|
||||
// Crypto invoices die fast — two hours before a coin is picked, twenty minutes
|
||||
// after — so Dead is a state this rail reaches far more often than Mollie does,
|
||||
// and the reconciler lapsing those orders is the normal case rather than an
|
||||
// exception.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
// Percent-encode one form value. Unreserved characters pass; everything else
|
||||
// becomes %XX, including the space (rather than '+', which is only correct in
|
||||
// a query string and is one of those differences that works until it doesn't).
|
||||
std::string FormEncode(std::string_view s) {
|
||||
static constexpr std::string_view kHex = "0123456789ABCDEF";
|
||||
std::string out;
|
||||
out.reserve(s.size() + 8);
|
||||
for (const char c : s) {
|
||||
const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|
||||
|| (c >= '0' && c <= '9')
|
||||
|| c == '-' || c == '_' || c == '.' || c == '~';
|
||||
if (unreserved) {
|
||||
out += c;
|
||||
} else {
|
||||
const auto byte = static_cast<unsigned char>(c);
|
||||
out += '%';
|
||||
out += kHex[byte >> 4];
|
||||
out += kHex[byte & 0x0f];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ticker symbols arrive uppercase ("BTC"); the ledger's via column is lowercase
|
||||
// everywhere else ("ideal", "creditcard"), and a column that shouts in one row
|
||||
// and whispers in the next is just noise to read past.
|
||||
std::string LowerAscii(std::string_view s) {
|
||||
std::string out(s);
|
||||
for (char& c : out) {
|
||||
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// CoinGate caps title at 150 characters and description at 500. Both are built
|
||||
// from the order reference here and come nowhere near either, but a truncating
|
||||
// helper means a future longer description degrades to a shorter one rather
|
||||
// than to a 422 at checkout — with the buyer already committed.
|
||||
std::string Clamp(std::string_view s, std::size_t max) {
|
||||
return std::string(s.substr(0, std::min(s.size(), max)));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<CoingateOrder> ParseCoingateOrder(std::string_view json) {
|
||||
auto doc = Json::Parse(json);
|
||||
if (!doc || !doc->IsObject()) return std::nullopt;
|
||||
|
||||
CoingateOrder o;
|
||||
// The id arrives as a number. Accept a string too: costing a live checkout
|
||||
// over a provider changing a field's JSON type would be an absurd way to
|
||||
// lose a sale, and either spelling names the same order.
|
||||
if (const Json::Value* id = doc->Find("id")) {
|
||||
if (id->type == Json::Type::String) {
|
||||
o.id = id->string;
|
||||
} else if (id->type == Json::Type::Number) {
|
||||
o.id = std::format("{}", static_cast<std::int64_t>(id->number));
|
||||
}
|
||||
}
|
||||
o.status = std::string(doc->Str("status"));
|
||||
o.payCurrency = std::string(doc->Str("pay_currency"));
|
||||
o.payUrl = std::string(doc->Str("payment_url"));
|
||||
if (o.id.empty() || o.status.empty()) return std::nullopt;
|
||||
|
||||
// Only euro-priced orders are ever created, so anything else failing to
|
||||
// parse to zero is the safe outcome — a zero amount never satisfies an
|
||||
// order total. Note this is price_amount (what the buyer owed) and not
|
||||
// receive_amount (what lands after conversion and fee): the question being
|
||||
// asked is whether the buyer paid their invoice, not what the shop nets.
|
||||
if (doc->Str("price_currency") == "EUR") {
|
||||
if (auto minor = ParseAmountToMinor(doc->Str("price_amount"))) {
|
||||
o.priceMinor = *minor;
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
class CoingateRail final : public PaymentRail {
|
||||
public:
|
||||
explicit CoingateRail(RailConfig cfg)
|
||||
: cfg_(std::move(cfg)),
|
||||
host_(cfg_.sandbox ? "api-sandbox.coingate.com" : "api.coingate.com") {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
|
||||
const std::string& description,
|
||||
const std::string& redirectUrl) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
// receive_currency=EUR is the settlement decision; see the header.
|
||||
// No callback_url on purpose: state comes from the poll, never from
|
||||
// something that arrives unbidden claiming an order was paid.
|
||||
const std::string body = std::format(
|
||||
"price_amount={}&price_currency=EUR&receive_currency=EUR"
|
||||
"&title={}&description={}&order_id={}&success_url={}&cancel_url={}",
|
||||
FormEncode(Money::FormatMinor(amountMinor)),
|
||||
FormEncode(Clamp(description, 150)),
|
||||
FormEncode(Clamp(description, 500)),
|
||||
FormEncode(Clamp(description, 255)),
|
||||
FormEncode(redirectUrl), FormEncode(redirectUrl));
|
||||
|
||||
const std::optional<std::string> res = Call("POST", "/api/v2/orders", body);
|
||||
if (!res) return std::nullopt;
|
||||
const auto order = ParseCoingateOrder(*res);
|
||||
if (!order || order->payUrl.empty()) {
|
||||
std::println(std::cerr, "coingate: create returned no payment url");
|
||||
return std::nullopt;
|
||||
}
|
||||
PaymentLink link;
|
||||
link.payId = order->id;
|
||||
link.payUrl = order->payUrl;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string& payId,
|
||||
std::int64_t expectedMinor) override {
|
||||
std::lock_guard lock(mutex_);
|
||||
// CoinGate ids are decimal integers. The id came from them, but it
|
||||
// travels through our ledger — keep the path composition strict anyway.
|
||||
if (payId.empty()) return PaidStatus{ PayState::Dead, {} };
|
||||
for (const char c : payId) {
|
||||
if (c < '0' || c > '9') return PaidStatus{ PayState::Dead, {} };
|
||||
}
|
||||
|
||||
const std::optional<std::string> res =
|
||||
Call("GET", "/api/v2/orders/" + payId, {});
|
||||
if (!res) return std::nullopt;
|
||||
const auto order = ParseCoingateOrder(*res);
|
||||
if (!order) return std::nullopt;
|
||||
|
||||
PaidStatus out;
|
||||
// What settled it, for the ledger's "via" column: the coin the shopper
|
||||
// actually paid in ("BTC" -> "bitcoin" is their business, not ours —
|
||||
// the ticker is the honest record). Empty until a coin is picked.
|
||||
out.method = order->payCurrency.empty()
|
||||
? std::string("crypto")
|
||||
: LowerAscii(order->payCurrency);
|
||||
|
||||
const std::string_view status = order->status;
|
||||
if (status == "paid" && order->priceMinor >= expectedMinor) {
|
||||
out.state = PayState::Paid;
|
||||
} else if (status == "new" || status == "pending" || status == "confirming") {
|
||||
// Still in flight. "confirming" is the blockchain-confirmation
|
||||
// wait: the money is visible but not final, and this rail does not
|
||||
// treat visible as received.
|
||||
out.state = PayState::Pending;
|
||||
} else if (status == "refunded" || status == "partially_refunded") {
|
||||
// Paid and then given back — which, seen from an order still
|
||||
// awaiting payment, means the reconciler missed the entire paid
|
||||
// window (a long outage) and the money has since left again. Lapse
|
||||
// it rather than confirm an order whose payment was undone, and say
|
||||
// so loudly: this is the one case where --mark-paid may be the
|
||||
// right answer and only a human can tell.
|
||||
std::println(std::cerr,
|
||||
"coingate: order {} is {} — lapsing; confirm by hand if "
|
||||
"the refund was partial and the goods still ship",
|
||||
payId, status);
|
||||
out.state = PayState::Dead;
|
||||
} else if (status == "invalid" || status == "expired" || status == "canceled") {
|
||||
out.state = PayState::Dead;
|
||||
} else {
|
||||
// An unknown status is not a licence to guess. Pending means "ask
|
||||
// again", which is the only safe reading of a word we do not know.
|
||||
std::println(std::cerr, "coingate: order {} has unknown status '{}'",
|
||||
payId, status);
|
||||
out.state = PayState::Pending;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return "coingate"; }
|
||||
// Slower than Mollie's ten seconds: a crypto payment waits on block
|
||||
// confirmations, so there is nothing a faster sweep could learn. The
|
||||
// buyer's own arrival at the order page still polls once immediately.
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(20); }
|
||||
|
||||
private:
|
||||
// One HTTPS call; nullopt on transport failure or a non-2xx answer. The
|
||||
// reconciler treats nullopt as "unknown, retry" — never as unpaid or dead.
|
||||
std::optional<std::string> Call(std::string_view method, const std::string& path,
|
||||
const std::string& body) {
|
||||
try {
|
||||
if (!client_) {
|
||||
client_ = std::make_unique<Crafter::ClientHTTP1>(
|
||||
host_, static_cast<std::uint16_t>(443),
|
||||
Crafter::TLSClientCredentials{});
|
||||
}
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = std::string(method);
|
||||
req.path = path;
|
||||
req.authority = host_;
|
||||
req.body = body;
|
||||
// Not "Bearer": CoinGate's scheme word is literally "Token".
|
||||
req.headers["authorization"] = "Token " + cfg_.apiKey;
|
||||
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||
req.headers["accept"] = "application/json";
|
||||
if (!body.empty()) {
|
||||
req.headers["content-type"] = "application/x-www-form-urlencoded";
|
||||
}
|
||||
|
||||
const Crafter::HTTPResponse res = client_->Send(req);
|
||||
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||
std::println(std::cerr, "coingate: {} {} -> {} {}", method, path,
|
||||
res.status, res.body.substr(0, 200));
|
||||
return std::nullopt;
|
||||
}
|
||||
return res.body;
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "coingate: {} {} failed: {}", method, path, e.what());
|
||||
client_.reset(); // dial fresh next time
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
RailConfig cfg_;
|
||||
std::string host_;
|
||||
std::mutex mutex_;
|
||||
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<PaymentRail> MakeCoingateRail(const RailConfig& config) {
|
||||
return std::make_unique<CoingateRail>(config);
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
|
|
@ -34,6 +34,33 @@ using namespace Crafter;
|
|||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
// Request provenance. Both are pure and declared in the module interface,
|
||||
// where the reasoning for each lives; the self-test drives them directly.
|
||||
|
||||
std::string_view ClientAddressFromForwarded(std::string_view forwarded) {
|
||||
// The RIGHTMOST entry, because that is the one Caddy appended. Anything to
|
||||
// its left is whatever the client felt like claiming.
|
||||
const std::size_t comma = forwarded.rfind(',');
|
||||
const std::string_view last = comma == std::string_view::npos
|
||||
? forwarded
|
||||
: forwarded.substr(comma + 1);
|
||||
return Form::Trim(last);
|
||||
}
|
||||
|
||||
bool OriginAllowed(std::string_view origin, std::string_view redirectBase) {
|
||||
if (origin.empty()) return true; // not a browser form post; see the header
|
||||
// A trailing slash is legal in a configured base and never present in an
|
||||
// Origin header, so normalise both ends rather than depend on the operator.
|
||||
auto trim = [](std::string_view s) {
|
||||
while (!s.empty() && s.back() == '/') s.remove_suffix(1);
|
||||
return s;
|
||||
};
|
||||
const std::string_view want = trim(redirectBase);
|
||||
// An unconfigured base must not silently accept every origin.
|
||||
if (want.empty()) return false;
|
||||
return trim(origin) == want;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Loaded once at startup. The content files are generated at build time (CI
|
||||
|
|
@ -43,11 +70,28 @@ Views::SiteContent gContent;
|
|||
std::string gBootScripts;
|
||||
std::string gCssHref = "/styles.css";
|
||||
|
||||
// The payment rail, installed by ConfigurePayments before Serve; a null rail
|
||||
// means checkout answers 503 rather than creating orders nothing can pay.
|
||||
std::unique_ptr<PaymentRail> gRail;
|
||||
// The payment rails, installed by ConfigurePayments before Serve; two null
|
||||
// rails mean checkout answers 503 rather than creating orders nothing can pay.
|
||||
PaymentRails gRails;
|
||||
std::string gRedirectBase = "https://catcrafts.net";
|
||||
|
||||
// The reconciler's sweep cadence: the shortest interval any configured rail
|
||||
// asks for. Each order is still paced by ITS OWN rail's interval inside the
|
||||
// loop — a shared sweep that ran at the slower rail's pace would make the
|
||||
// faster one late for every order, and one that ran at the faster pace would
|
||||
// poll the slower provider harder than it asked to be polled.
|
||||
std::chrono::seconds SweepInterval() {
|
||||
std::chrono::seconds out = std::chrono::seconds(10);
|
||||
bool first = true;
|
||||
for (const PaymentRail* rail : { gRails.bank.get(), gRails.crypto.get() }) {
|
||||
if (!rail) continue;
|
||||
const std::chrono::seconds want = rail->PollInterval();
|
||||
out = first ? want : std::min(out, want);
|
||||
first = false;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string ReadFile(const std::filesystem::path& p) {
|
||||
std::ifstream in(p, std::ios::binary);
|
||||
if (!in) return {};
|
||||
|
|
@ -64,6 +108,7 @@ struct AdvanceResult {
|
|||
std::string paidVia;
|
||||
};
|
||||
std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order);
|
||||
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval);
|
||||
std::string NowIso8601();
|
||||
|
||||
// Common headers on every HTML response.
|
||||
|
|
@ -104,12 +149,15 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
// The product page embeds the live carrier rate table into its checkout
|
||||
// preview, and that table is runtime state — so, like orders below, it is
|
||||
// rendered here rather than through the shared dispatch (which the wasm
|
||||
// backend-down fallback uses with the zone table only).
|
||||
// backend-down fallback uses with no rate table at all, and therefore
|
||||
// quotes no totals: with the zone fallback gone there is nothing for it to
|
||||
// price from, which is correct — that path cannot reach checkout either).
|
||||
if (route.kind == RouteKind::Product) {
|
||||
if (const Product* product = gContent.FindProduct(route.slug)) {
|
||||
const ShippingTable ship = CurrentShippingTable();
|
||||
const Views::RenderedPage page =
|
||||
Views::RenderProduct(*product, gContent.rates, ship.perCountry);
|
||||
Views::RenderProduct(*product, gContent.rates, ship.perCountry,
|
||||
{}, {}, CryptoPaymentAvailable());
|
||||
HTTPResponse res;
|
||||
res.status = std::to_string(page.status);
|
||||
ApplyPageHeaders(res, "text/html; charset=utf-8",
|
||||
|
|
@ -197,15 +245,24 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
}
|
||||
|
||||
// The buyer usually arrives here seconds after paying, redirected by
|
||||
// Mollie — but the reconciler may not have polled yet. Ask the rail
|
||||
// right now so the page they land on already says paid, instead of an
|
||||
// alarming "awaiting payment" that flips ten seconds later. Still the
|
||||
// poll-is-truth rule: this trusts Mollie's authenticated answer, never
|
||||
// the fact of being redirected.
|
||||
// the provider — but the reconciler may not have polled yet. Ask the
|
||||
// rail right now so the page they land on already says paid, instead
|
||||
// of an alarming "awaiting payment" that flips ten seconds later.
|
||||
// Still the poll-is-truth rule: this trusts the provider's
|
||||
// authenticated answer, never the fact of being redirected.
|
||||
//
|
||||
// Gated to one call per token per rail interval — see
|
||||
// ArrivalPollAllowed. A reload past that renders from the ledger and
|
||||
// lets the reconciler do its job, which is the whole point of having
|
||||
// one. The interval is the ORDER'S rail's, so a crypto order is not
|
||||
// paced by Mollie's cadence or the other way round.
|
||||
if (order->status == "awaiting_payment") {
|
||||
if (const auto advanced = PollAndAdvance(*order)) {
|
||||
order->status = advanced->status;
|
||||
order->paidVia = advanced->paidVia;
|
||||
if (const PaymentRail* rail = gRails.For(order->payChoice);
|
||||
rail && ArrivalPollAllowed(order->token, rail->PollInterval())) {
|
||||
if (const auto advanced = PollAndAdvance(*order)) {
|
||||
order->status = advanced->status;
|
||||
order->paidVia = advanced->paidVia;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,6 +270,7 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
view.token = order->token;
|
||||
view.reference = order->reference;
|
||||
view.status = order->status;
|
||||
view.payChoice = order->payChoice;
|
||||
view.payUrl = order->payUrl;
|
||||
view.createdAt = order->createdAt;
|
||||
view.country = order->buyer.country;
|
||||
|
|
@ -331,29 +389,66 @@ HTTPResponse ServeFeed() {
|
|||
return res;
|
||||
}
|
||||
|
||||
// A very coarse rate limit on checkout submissions.
|
||||
// Rate limiting on checkout submissions: per-peer first, global as a backstop.
|
||||
//
|
||||
// Not a general-purpose limiter, and deliberately not per-IP: the server sits
|
||||
// behind Caddy, so every request arrives from 127.0.0.1 unless forwarding
|
||||
// headers are trusted — and trusting a client-settable header for rate limiting
|
||||
// is worse than not limiting at all. So this is a global cap, which is the
|
||||
// honest thing a reverse-proxied process can enforce by itself. Per-IP limiting
|
||||
// belongs in Caddy, where the real peer address lives.
|
||||
// This used to be a single global cap, on the reasoning that a reverse-proxied
|
||||
// process cannot know its real peer and that trusting a client-settable header
|
||||
// is worse than not limiting at all. The first half was wrong and the second
|
||||
// half made the conclusion dangerous. A SHARED budget is exhaustible by
|
||||
// whoever is rudest: thirty submissions from one script closed checkout for
|
||||
// every real buyer for ten minutes, and one submission every twenty seconds
|
||||
// kept the shop shut indefinitely — a working denial of sales for the price of
|
||||
// a shell loop. A limit that turns one attacker into an outage is not a limit.
|
||||
//
|
||||
// The intent is only to stop a script filling the file overnight; the honeypot
|
||||
// handles ordinary bots and Caddy handles volume.
|
||||
// The real peer IS knowable here, carefully: Caddy appends it to
|
||||
// X-Forwarded-For, so the rightmost entry is Caddy's own word rather than the
|
||||
// client's (see ClientAddressFromForwarded, which is where that reasoning
|
||||
// lives). It is trustworthy only because nothing else can reach this listener.
|
||||
//
|
||||
// So the per-peer cap is the actual control, and the global cap stays purely
|
||||
// as a runaway backstop — set high enough that it is not a lever one peer can
|
||||
// pull, since tripping it still denies everyone. A flood broad enough to reach
|
||||
// it is an infrastructure problem, and belongs to Caddy and the host.
|
||||
//
|
||||
// Unproxied requests (dev, e2e, a direct curl at the loopback port) carry no
|
||||
// X-Forwarded-For. They are charged to the global budget only — there is no
|
||||
// peer to key on, and inventing one would be a lie.
|
||||
using RatePoint = std::chrono::steady_clock::time_point;
|
||||
|
||||
std::mutex gRateMutex;
|
||||
std::deque<std::chrono::steady_clock::time_point> gRecentSubmissions;
|
||||
constexpr std::size_t kMaxSubmissionsPerWindow = 30;
|
||||
std::deque<RatePoint> gRecentSubmissions;
|
||||
std::unordered_map<std::string, std::deque<RatePoint>> gRecentPerPeer;
|
||||
// Per peer: enough for a buyer who mistypes, retries, changes their mind about
|
||||
// a colour and orders twice. Not enough to be a source of volume.
|
||||
constexpr std::size_t kMaxSubmissionsPerPeer = 6;
|
||||
// Global: a backstop, an order of magnitude above any real ten minutes here.
|
||||
constexpr std::size_t kMaxSubmissionsPerWindow = 240;
|
||||
constexpr auto kRateWindow = std::chrono::minutes(10);
|
||||
|
||||
bool RateLimitAllows() {
|
||||
bool RateLimitAllows(std::string_view peer) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
std::lock_guard lock(gRateMutex);
|
||||
while (!gRecentSubmissions.empty() && now - gRecentSubmissions.front() > kRateWindow) {
|
||||
gRecentSubmissions.pop_front();
|
||||
}
|
||||
|
||||
auto expire = [&](std::deque<RatePoint>& seen) {
|
||||
while (!seen.empty() && now - seen.front() > kRateWindow) seen.pop_front();
|
||||
};
|
||||
|
||||
expire(gRecentSubmissions);
|
||||
if (gRecentSubmissions.size() >= kMaxSubmissionsPerWindow) return false;
|
||||
|
||||
if (!peer.empty()) {
|
||||
// Expire every peer, not just this one, and drop those whose window has
|
||||
// emptied: otherwise the map keeps one entry per address that ever
|
||||
// submitted, which is a slow leak an attacker chooses the rate of.
|
||||
std::erase_if(gRecentPerPeer, [&](auto& entry) {
|
||||
expire(entry.second);
|
||||
return entry.second.empty();
|
||||
});
|
||||
std::deque<RatePoint>& seen = gRecentPerPeer[std::string(peer)];
|
||||
if (seen.size() >= kMaxSubmissionsPerPeer) return false;
|
||||
seen.push_back(now);
|
||||
}
|
||||
|
||||
gRecentSubmissions.push_back(now);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -409,12 +504,25 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
res.status = std::string(status);
|
||||
ApplyPageHeaders(res, "text/html; charset=utf-8", false, true);
|
||||
const Views::RenderedPage page = Views::RenderProduct(
|
||||
*product, gContent.rates, shipTable.perCountry, errors, prev);
|
||||
*product, gContent.rates, shipTable.perCountry, errors, prev,
|
||||
CryptoPaymentAvailable());
|
||||
res.body = Views::RenderDocument(page, Views::RenderNav(RouteKind::Product),
|
||||
Views::RenderFooter(), {}, gCssHref);
|
||||
return res;
|
||||
};
|
||||
|
||||
// Cross-site request forgery. This POST creates an order and calls the
|
||||
// payment provider, so it must have come from our own form — a page on
|
||||
// another origin must not be able to drive it with a visitor's browser.
|
||||
// OriginAllowed carries the reasoning, including why a MISSING Origin is
|
||||
// accepted (a non-browser client cannot forge cross-site).
|
||||
if (const auto origin = req.headers.find("origin"); origin != req.headers.end()) {
|
||||
if (!OriginAllowed(origin->second, gRedirectBase)) {
|
||||
return reject({{ "", "That submission didn't come from this site. "
|
||||
"Nothing was charged." }}, {}, "403");
|
||||
}
|
||||
}
|
||||
|
||||
// Only urlencoded — the form sends nothing else, and accepting more content
|
||||
// types means parsing more attacker-chosen formats for no benefit.
|
||||
const auto ct = req.headers.find("content-type");
|
||||
|
|
@ -443,12 +551,35 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
parsed.value, "409");
|
||||
}
|
||||
|
||||
if (!gRail) {
|
||||
if (!gRails.Any()) {
|
||||
return reject({{ "", "Checkout is offline right now — nothing was charged. "
|
||||
"Please try again later." }}, parsed.value, "503");
|
||||
}
|
||||
|
||||
if (!RateLimitAllows()) {
|
||||
// The rail the buyer picked. ValidateCheckout has already refused anything
|
||||
// that is not one of the two words, so what remains is the case where the
|
||||
// word is valid but its slot is not configured — a form cached from before
|
||||
// the rail was switched off, or a hand-made post. Say which one is missing
|
||||
// rather than "checkout is offline": the other method is right there and
|
||||
// still works.
|
||||
const bool wantsCrypto = parsed.value.payChoice == Form::kPayCrypto;
|
||||
PaymentRail* rail = gRails.For(parsed.value.payChoice);
|
||||
if (!rail) {
|
||||
return reject({{ "pay", wantsCrypto
|
||||
? "Crypto payment isn't available right now — nothing "
|
||||
"was charged. Please pick bank or card."
|
||||
: "Bank and card payment isn't available right now — "
|
||||
"nothing was charged. Please pick crypto." }},
|
||||
parsed.value, "503");
|
||||
}
|
||||
|
||||
// Charged to this peer's own budget, so a flood costs the flooder their
|
||||
// checkout and nobody else theirs.
|
||||
std::string_view peer;
|
||||
if (const auto fwd = req.headers.find("x-forwarded-for"); fwd != req.headers.end()) {
|
||||
peer = ClientAddressFromForwarded(fwd->second);
|
||||
}
|
||||
if (!RateLimitAllows(peer)) {
|
||||
return reject({{ "", "Too many submissions just now — please try again shortly." }},
|
||||
parsed.value, "429");
|
||||
}
|
||||
|
|
@ -472,12 +603,38 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
|
||||
// THE amount. Computed here from the catalogue, the validated country and
|
||||
// the live shipping table; nothing about money ever arrives from the
|
||||
// client. Shipping is per order, not per unit — one parcel.
|
||||
const std::int64_t shippingMinor = ShipCostFor(
|
||||
parsed.value.country, product->shipNlMinor, product->shipEuMinor,
|
||||
product->shipWorldMinor);
|
||||
// client. Shipping is per order, not per unit — one parcel — so the weight
|
||||
// that picks the carrier bracket is the whole order's.
|
||||
if (product->shipWeightGrams <= 0) {
|
||||
// A catalogue bug, not a buyer problem: without a weight no bracket can
|
||||
// be selected. Refuse rather than fall through to the cheapest rate,
|
||||
// and say so in the log where it can be fixed.
|
||||
std::println(std::cerr, "checkout: product '{}' has no shipping weight",
|
||||
product->slug);
|
||||
return reject({{ "", "Shipping for this product can't be priced right now — "
|
||||
"nothing was charged." }}, parsed.value, "503");
|
||||
}
|
||||
const std::int64_t parcelGrams = product->shipWeightGrams * parsed.value.quantity;
|
||||
const std::optional<std::int64_t> shippingMinor =
|
||||
ShipCostFor(parsed.value.country, parcelGrams);
|
||||
if (!shippingMinor) {
|
||||
// No rate covers this parcel, so there is no price to charge. Which of
|
||||
// the two refusals it is decides what the buyer can do about it: an
|
||||
// uncovered country is ours to fix, a too-heavy parcel has a quantity
|
||||
// that would work. The error hangs off the field the buyer would
|
||||
// change in each case.
|
||||
const std::int64_t fits =
|
||||
shipTable.MaxUnits(parsed.value.country, product->shipWeightGrams);
|
||||
if (fits <= 0 && parsed.value.quantity == 1) {
|
||||
return reject({{ "country", Form::NoShippingMessage(parsed.value.country) }},
|
||||
parsed.value, "422");
|
||||
}
|
||||
return reject({{ "quantity",
|
||||
Form::TooHeavyMessage(parsed.value.country, fits) }},
|
||||
parsed.value, "422");
|
||||
}
|
||||
const Money::Totals totals = Money::ComputeTotals(
|
||||
unitMinor, parsed.value.quantity, shippingMinor, parsed.value.country);
|
||||
unitMinor, parsed.value.quantity, *shippingMinor, parsed.value.country);
|
||||
|
||||
OrderRecord order;
|
||||
order.token = NewOrderToken();
|
||||
|
|
@ -492,8 +649,11 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
order.shippingMinor = totals.shipping;
|
||||
order.totalMinor = totals.total;
|
||||
order.vatIncluded = totals.vatIncluded;
|
||||
// Normalised, not echoed: the record must name the rail that issued the
|
||||
// link, and an empty submitted choice took the bank rail above.
|
||||
order.payChoice = std::string(wantsCrypto ? Form::kPayCrypto : Form::kPayBank);
|
||||
|
||||
auto link = gRail->CreateLink(
|
||||
auto link = rail->CreateLink(
|
||||
order.totalMinor,
|
||||
std::format("{} catcrafts.net", order.reference),
|
||||
std::format("{}/order/{}", gRedirectBase, order.token));
|
||||
|
|
@ -517,7 +677,7 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
|
||||
std::println(std::cerr, "order {} created: {} {} -> {}", order.reference,
|
||||
Money::FormatMinor(order.totalMinor), order.buyer.country,
|
||||
gRail->Name());
|
||||
rail->Name());
|
||||
|
||||
// Straight to the payment page — the buyer clicked "buy", not "read an
|
||||
// interim status page". The order page stays the receipt/status URL that
|
||||
|
|
@ -531,12 +691,60 @@ HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
|
|||
return res;
|
||||
}
|
||||
|
||||
// The gate on the order page's arrival poll.
|
||||
//
|
||||
// Rendering /order/<token> asks the provider whether the payment landed, so a
|
||||
// buyer redirected back from Mollie sees "paid" immediately instead of an
|
||||
// alarming "awaiting payment" that flips ten seconds later. That is a good
|
||||
// thing to do once. The problem was that it happened on EVERY render: an
|
||||
// outbound HTTPS round trip, on the request thread, holding the rail's mutex,
|
||||
// reachable as often as anyone cared to reload.
|
||||
//
|
||||
// The hole that closes: an attacker places one order — their own, so no token
|
||||
// guessing is involved — and then reloads it in a loop. Every reload spent a
|
||||
// live Mollie API call against the shop's key, and because CreateLink shares
|
||||
// that same mutex, real buyers' checkouts queued behind the flood. The
|
||||
// listener is thread-per-connection with no cap, so the blocked threads piled
|
||||
// up as well.
|
||||
//
|
||||
// One poll per token per rail interval is all the arrival check ever needed.
|
||||
// Its job is to beat the reconciler to the FIRST render, not to become a
|
||||
// second reconciler — everything after that is the reconciler's work, and it
|
||||
// already paces itself by order age. Tying the gate to the rail's own cadence
|
||||
// keeps the two honest about each other: the fake rail's one-second interval
|
||||
// leaves dev and the e2e suite behaving exactly as before.
|
||||
std::mutex gArrivalPollMutex;
|
||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> gLastArrivalPoll;
|
||||
|
||||
bool ArrivalPollAllowed(std::string_view token, std::chrono::seconds interval) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
std::lock_guard lock(gArrivalPollMutex);
|
||||
// Orders settle or lapse; their entries should not outlive them. An hour
|
||||
// idle is far past both, and pruning here keeps the map bounded by live
|
||||
// traffic rather than by every token ever viewed.
|
||||
std::erase_if(gLastArrivalPoll, [&](const auto& entry) {
|
||||
return now - entry.second > std::chrono::hours(1);
|
||||
});
|
||||
const auto [it, inserted] = gLastArrivalPoll.try_emplace(std::string(token), now);
|
||||
if (inserted) return true;
|
||||
if (now - it->second < interval) return false;
|
||||
it->second = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
// One reconciliation step for one order: ask the rail, append the transition
|
||||
// if there is one, and report the order's (possibly new) status fields.
|
||||
// Shared by the reconciler thread and the order page's on-arrival check.
|
||||
std::optional<AdvanceResult> PollAndAdvance(const OrderRecord& order) {
|
||||
if (!gRail || order.status != "awaiting_payment") return std::nullopt;
|
||||
const std::optional<PaidStatus> paid = gRail->CheckPaid(order.payId, order.totalMinor);
|
||||
if (order.status != "awaiting_payment") return std::nullopt;
|
||||
// The rail that ISSUED this order's link, never simply "the rail": asking
|
||||
// the wrong provider about an id it never handed out is at best a 404 and
|
||||
// at worst a question about somebody else's order. A slot that is no
|
||||
// longer configured means this order cannot be polled at all — leave it
|
||||
// awaiting for the manual CLI rather than guess with the other one.
|
||||
PaymentRail* rail = gRails.For(order.payChoice);
|
||||
if (!rail) return std::nullopt;
|
||||
const std::optional<PaidStatus> paid = rail->CheckPaid(order.payId, order.totalMinor);
|
||||
if (!paid.has_value()) return std::nullopt;
|
||||
if (paid->state == PayState::Paid) {
|
||||
if (AppendOrderStatus(order.token, "paid", NowIso8601(), paid->method)) {
|
||||
|
|
@ -627,11 +835,13 @@ std::size_t ContentPostCount() { return gContent.posts.size(); }
|
|||
std::size_t ContentProjectCount() { return gContent.projects.size(); }
|
||||
std::size_t ContentProductCount() { return gContent.products.size(); }
|
||||
|
||||
void ConfigurePayments(std::unique_ptr<PaymentRail> rail, std::string redirectBase) {
|
||||
gRail = std::move(rail);
|
||||
void ConfigurePayments(PaymentRails rails, std::string redirectBase) {
|
||||
gRails = std::move(rails);
|
||||
if (!redirectBase.empty()) gRedirectBase = std::move(redirectBase);
|
||||
}
|
||||
|
||||
bool CryptoPaymentAvailable() { return gRails.crypto != nullptr; }
|
||||
|
||||
namespace {
|
||||
|
||||
// The reconciler: the ONLY thing that moves an order to paid.
|
||||
|
|
@ -641,45 +851,58 @@ namespace {
|
|||
// the client (or a redirect parameter) says. This thread sweeps awaiting
|
||||
// orders and asks the rail; a positive answer appends a status event.
|
||||
//
|
||||
// Poll pacing backs off with order age — a buyer mid-flow gets answers in
|
||||
// seconds, a day-old order gets checked hourly, and after seven days the
|
||||
// order stops being polled (a very late payment is then found by the manual
|
||||
// CLI path, which exists for exactly that).
|
||||
// Poll pacing backs off with order age — a buyer mid-flow gets answers at
|
||||
// their provider's own cadence, a two-hour-old order drops to every 10
|
||||
// minutes, and after seven days it stops being polled (a very late payment is
|
||||
// then found by the manual CLI path, which exists for exactly that).
|
||||
//
|
||||
// Two timestamps per order rather than one. The sweep runs at the FASTEST
|
||||
// configured rail's cadence, because that rail's orders deserve it, so
|
||||
// "poll on every pass" would silently poll the slower provider at the faster
|
||||
// one's rate — with two rails that is no longer a rounding error but double
|
||||
// the request volume CoinGate was promised. `first` drives the age backoff,
|
||||
// `last` enforces the interval; keeping them apart also retires the modulo
|
||||
// pacing that used to approximate this with one.
|
||||
void ReconcilerLoop(const std::stop_token& stop) {
|
||||
std::unordered_map<std::string, std::chrono::steady_clock::time_point> lastPoll;
|
||||
struct Seen {
|
||||
std::chrono::steady_clock::time_point first; // for the age backoff
|
||||
std::chrono::steady_clock::time_point last; // for the interval
|
||||
};
|
||||
std::unordered_map<std::string, Seen> seen;
|
||||
|
||||
while (!stop.stop_requested()) {
|
||||
std::this_thread::sleep_for(gRail->PollInterval());
|
||||
std::this_thread::sleep_for(SweepInterval());
|
||||
if (stop.stop_requested()) break;
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
for (const OrderRecord& order : ListOrders()) {
|
||||
if (order.status != "awaiting_payment") {
|
||||
lastPoll.erase(order.token);
|
||||
seen.erase(order.token);
|
||||
continue;
|
||||
}
|
||||
// The order's OWN provider: the two rails ask to be polled at
|
||||
// different rates, and a sweep running at the faster one's cadence
|
||||
// must not push the slower one.
|
||||
const PaymentRail* rail = gRails.For(order.payChoice);
|
||||
if (!rail) continue;
|
||||
|
||||
// Age from the record's own timestamp is string math we don't
|
||||
// need: steady-clock first-seen is good enough for backoff.
|
||||
auto [it, inserted] = lastPoll.try_emplace(order.token, now);
|
||||
auto [it, inserted] = seen.try_emplace(order.token, Seen{ now, now });
|
||||
if (!inserted) {
|
||||
const auto sinceFirst = now - it->second;
|
||||
// it->second tracks FIRST time seen; store poll pacing in a
|
||||
// parallel structure? One map is enough: after the first
|
||||
// pass, re-poll every interval for 2 h, then only every
|
||||
// 10 min, dropping to nothing after 7 days.
|
||||
using namespace std::chrono;
|
||||
if (sinceFirst > hours(24 * 7)) continue;
|
||||
if (sinceFirst > hours(2)) {
|
||||
// Coarse modulo pacing: only act on passes that land in
|
||||
// the first interval of every 10-minute window.
|
||||
const auto inWindow = duration_cast<seconds>(sinceFirst) % minutes(10);
|
||||
if (inWindow > gRail->PollInterval() * 2) continue;
|
||||
}
|
||||
const auto age = now - it->second.first;
|
||||
if (age > hours(24 * 7)) continue;
|
||||
const auto due = age > hours(2)
|
||||
? seconds(minutes(10))
|
||||
: rail->PollInterval();
|
||||
if (now - it->second.last < due) continue;
|
||||
it->second.last = now;
|
||||
}
|
||||
|
||||
// Paid, lapsed (the provider says the payment can never arrive),
|
||||
// or nothing to report — the shared step handles the transition.
|
||||
if (PollAndAdvance(order)) lastPoll.erase(order.token);
|
||||
if (PollAndAdvance(order)) seen.erase(order.token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -832,7 +1055,7 @@ int Serve(std::uint16_t port) {
|
|||
// The reconciler only exists when there is a rail to ask. jthread: the
|
||||
// stop token fires on destruction, so shutdown does not hang on a sleep.
|
||||
std::optional<std::jthread> reconciler;
|
||||
if (gRail) {
|
||||
if (gRails.Any()) {
|
||||
reconciler.emplace([](std::stop_token st) { ReconcilerLoop(st); });
|
||||
}
|
||||
|
||||
|
|
@ -859,9 +1082,10 @@ int Serve(std::uint16_t port) {
|
|||
|
||||
ListenerHTTP1 listener(port, std::move(routes), std::move(fallback));
|
||||
std::println("catcrafts-server: listening on 127.0.0.1:{} "
|
||||
"({} projects, {} posts, payments: {})",
|
||||
"({} projects, {} posts, payments: bank={} crypto={})",
|
||||
port, gContent.projects.size(), gContent.posts.size(),
|
||||
gRail ? gRail->Name() : "off");
|
||||
gRails.bank ? gRails.bank->Name() : "off",
|
||||
gRails.crypto ? gRails.crypto->Name() : "off");
|
||||
listener.Listen();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ 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.
|
||||
*/
|
||||
|
||||
// The Mollie payment rail.
|
||||
// The Mollie payment rail — the bank half of the checkout — plus the fake rail
|
||||
// the tests run on, and the roster that hands out both.
|
||||
//
|
||||
// Chosen over bunq.me after measuring bunq.me's limits (€500/transaction on
|
||||
// cards, no method for a non-EU buyer at phone prices — it is a P2P tool, not
|
||||
|
|
@ -14,22 +15,20 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// shop: iDEAL at a flat per-transaction fee, cards behind SCA/3DS, and a
|
||||
// hosted checkout so card data never touches this server.
|
||||
//
|
||||
// The API is refreshingly small next to bunq's: one bearer-token key, no
|
||||
// RSA signing, no session dance.
|
||||
// The API is small: one bearer-token key, no signing, no session dance.
|
||||
//
|
||||
// POST /v2/payments {amount, description, redirectUrl} -> id + checkout URL
|
||||
// GET /v2/payments/{id} -> status, method
|
||||
//
|
||||
// Trust direction is unchanged from the design rule: the ?redirect back to
|
||||
// the order page is ignored; an order becomes paid ONLY when an authenticated
|
||||
// GET says status=paid with a covering amount. One deliberate difference from
|
||||
// the bunq tab model: a Mollie payment can EXPIRE (canceled/expired/failed are
|
||||
// terminal), so the poll distinguishes Pending / Paid / Dead and the
|
||||
// reconciler lapses orders whose payment can never arrive.
|
||||
// Trust direction is the design rule: the ?redirect back to the order page is
|
||||
// ignored; an order becomes paid ONLY when an authenticated GET says
|
||||
// status=paid with a covering amount. A Mollie payment can EXPIRE
|
||||
// (canceled/expired/failed are terminal), so the poll distinguishes
|
||||
// Pending / Paid / Dead and the reconciler lapses orders whose payment can
|
||||
// never arrive.
|
||||
//
|
||||
// A test API key (test_…) works against the real endpoints from the moment a
|
||||
// Mollie account is created — verify with that before going live; unlike the
|
||||
// bunq client this one need not ship on faith.
|
||||
// Mollie account is created — verify with that before going live.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
|
@ -67,6 +66,33 @@ std::string JsonEscapeM(std::string_view s) {
|
|||
|
||||
} // namespace
|
||||
|
||||
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s) {
|
||||
// Exactly: 1*DIGIT ["." 1*2DIGIT]. Anything else — signs, exponents,
|
||||
// spaces, thousands separators — is rejected. Money parsing has no
|
||||
// "probably fine" mode. Both providers quote amounts as decimal strings,
|
||||
// so both come through here.
|
||||
if (s.empty() || s.size() > 15) return std::nullopt;
|
||||
std::int64_t units = 0;
|
||||
std::size_t i = 0;
|
||||
if (s[i] < '0' || s[i] > '9') return std::nullopt;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
units = units * 10 + (s[i] - '0');
|
||||
}
|
||||
std::int64_t cents = 0;
|
||||
if (i < s.size()) {
|
||||
if (s[i] != '.') return std::nullopt;
|
||||
++i;
|
||||
const std::size_t fracStart = i;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
cents = cents * 10 + (s[i] - '0');
|
||||
}
|
||||
const std::size_t digits = i - fracStart;
|
||||
if (i != s.size() || digits == 0 || digits > 2) return std::nullopt;
|
||||
if (digits == 1) cents *= 10;
|
||||
}
|
||||
return units * 100 + cents;
|
||||
}
|
||||
|
||||
std::optional<MolliePayment> ParseMolliePayment(std::string_view json) {
|
||||
auto doc = Json::Parse(json);
|
||||
if (!doc || !doc->IsObject()) return std::nullopt;
|
||||
|
|
@ -98,6 +124,50 @@ std::optional<MolliePayment> ParseMolliePayment(std::string_view json) {
|
|||
|
||||
namespace {
|
||||
|
||||
// ── the fake rail ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Exists so the ENTIRE order lifecycle — checkout, storage, status page,
|
||||
// reconciler, paid transition — runs in e2e with zero network. Payment links
|
||||
// point at a made-up URL; CheckPaid answers true once a marker file exists,
|
||||
// which the test creates when it wants "the customer has paid" to happen.
|
||||
//
|
||||
// It can stand in for EITHER slot, which is what lets the e2e suite drive the
|
||||
// bank and crypto paths through the same machinery without inventing a second
|
||||
// test double: what it proves is that the choice is carried from the form to
|
||||
// the ledger to the poll, and that is rail-independent by design.
|
||||
|
||||
class FakeRail final : public PaymentRail {
|
||||
public:
|
||||
FakeRail(std::filesystem::path marker, std::string name)
|
||||
: marker_(std::move(marker)), name_(std::move(name)) {}
|
||||
|
||||
std::optional<PaymentLink> CreateLink(std::int64_t, const std::string&,
|
||||
const std::string& redirectUrl) override {
|
||||
static std::atomic<std::int64_t> counter{1};
|
||||
PaymentLink link;
|
||||
link.payId = std::format("fake-{}", counter.fetch_add(1));
|
||||
// Checkout 303s the buyer to payUrl. The fake rail has no checkout to
|
||||
// send anyone to, so it points at the order page itself — which keeps
|
||||
// the browser flow usable in dev and the e2e redirect parseable.
|
||||
link.payUrl = redirectUrl;
|
||||
return link;
|
||||
}
|
||||
|
||||
std::optional<PaidStatus> CheckPaid(const std::string&, std::int64_t) override {
|
||||
std::error_code ec;
|
||||
return PaidStatus{
|
||||
std::filesystem::exists(marker_, ec) ? PayState::Paid : PayState::Pending,
|
||||
"fake" };
|
||||
}
|
||||
|
||||
std::string_view Name() const override { return name_; }
|
||||
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(1); }
|
||||
|
||||
private:
|
||||
std::filesystem::path marker_;
|
||||
std::string name_;
|
||||
};
|
||||
|
||||
class MollieRail final : public PaymentRail {
|
||||
public:
|
||||
explicit MollieRail(RailConfig cfg) : cfg_(std::move(cfg)) {}
|
||||
|
|
@ -199,15 +269,23 @@ private:
|
|||
|
||||
} // namespace
|
||||
|
||||
// Defined here rather than in the bunq unit so the rail roster has one home;
|
||||
// the bunq and fake constructors are declared by their own units.
|
||||
std::unique_ptr<PaymentRail> MakeBunqRail(const RailConfig& config);
|
||||
std::unique_ptr<PaymentRail> MakeFakeRail(const RailConfig& config);
|
||||
// The roster has one home, here; the CoinGate constructor is declared by its
|
||||
// own unit. An unrecognised mode is "off" rather than an error, and the caller
|
||||
// (main) is what refuses to start on a mode it did not expect — a rail that
|
||||
// silently half-exists would be worse than either.
|
||||
std::unique_ptr<PaymentRail> MakeCoingateRail(const RailConfig& config);
|
||||
|
||||
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config) {
|
||||
if (config.mode == "fake") return MakeFakeRail(config);
|
||||
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
|
||||
if (config.mode == "bunq") return MakeBunqRail(config);
|
||||
// The fake rail keeps the slot's own name so the ledger, the startup line
|
||||
// and the logs still say which half of the checkout ran in a test.
|
||||
if (config.mode == "fake") {
|
||||
return std::make_unique<FakeRail>(config.statePath, "fake");
|
||||
}
|
||||
if (config.mode == "fake-crypto") {
|
||||
return std::make_unique<FakeRail>(config.statePath, "fake-crypto");
|
||||
}
|
||||
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
|
||||
if (config.mode == "coingate") return MakeCoingateRail(config);
|
||||
return nullptr; // "off"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// {"type":"invoice", "id":..,"number":..} the number assignment, at paid
|
||||
// {"type":"notified","id":..,"what":..} the confirmation email left
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
|
|
@ -117,6 +121,10 @@ std::vector<OrderRecord> FoldLocked() {
|
|||
r.totalMinor = doc->Int("total_minor");
|
||||
r.vatIncluded = doc->Bool("vat_included");
|
||||
r.status = std::string(doc->Str("status", "awaiting_payment"));
|
||||
// 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"));
|
||||
r.payUrl = std::string(doc->Str("pay_url"));
|
||||
r.payId = std::string(doc->Str("pay_id"));
|
||||
if (r.token.empty()) continue;
|
||||
|
|
@ -165,14 +173,15 @@ bool CreateOrder(const OrderRecord& o) {
|
|||
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":"{}"}})",
|
||||
R"("status":"{}","pay_choice":"{}","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)));
|
||||
JsonEscape(o.status), JsonEscape(o.payChoice),
|
||||
JsonEscape(o.payUrl), JsonEscape(o.payId)));
|
||||
}
|
||||
|
||||
bool AppendOrderStatus(std::string_view token, std::string_view status,
|
||||
|
|
|
|||
|
|
@ -6,21 +6,31 @@ 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.
|
||||
*/
|
||||
|
||||
// Live shipping rates from Sendcloud, with the zone table as the floor.
|
||||
// Live shipping rates from Sendcloud. The only source of shipping prices.
|
||||
//
|
||||
// Shape: GET /api/v2/shipping_methods (basic auth) returns every method the
|
||||
// account can book, each with a per-country price list. One configured method
|
||||
// (matched by name substring) becomes a country -> cents table, cached to disk
|
||||
// and refreshed daily by a background thread the HTTP layer starts.
|
||||
// account can book, each with its weight range and a per-country price list.
|
||||
// The SAME carrier service appears once per weight band, so the configured
|
||||
// methods (matched by name substring) become a country -> bracket-ladder
|
||||
// table, cached to disk and refreshed daily by a background thread the HTTP
|
||||
// layer starts.
|
||||
//
|
||||
// Failure posture mirrors the rest of the build pipeline: Sendcloud being
|
||||
// down, slow, or unconfigured NEVER breaks checkout — the compiled-in zone
|
||||
// table (Catcrafts.Shared:Content) answers instead. A stale cached table
|
||||
// beats both, which is why the cache survives restarts.
|
||||
// Failure posture, and it is a real trade: there is no compiled-in fallback
|
||||
// any more. A country with no bracket, or a parcel heavier than every bracket,
|
||||
// is REFUSED at checkout. That is the honest answer — a rate the carrier does
|
||||
// not offer is a parcel that cannot be posted, and quoting one anyway sells an
|
||||
// order that has to be refunded or absorbed. The cost is that an empty table
|
||||
// means an unsellable shop, which is why the disk cache is load-bearing: it is
|
||||
// read at startup whether or not credentials exist, so an outage keeps selling
|
||||
// at the last known prices, and a hand-placed cache file is how dev and e2e
|
||||
// get a table with no account at all.
|
||||
//
|
||||
// Like the bunq rail, this code has not run against the real API — no
|
||||
// Like the CoinGate rail, this code has not run against the real API — no
|
||||
// credentials existed at build time. ParseSendcloudMethods is exercised by the
|
||||
// self-test against a canned response; the fetch around it is thin.
|
||||
// self-test against a canned response; the fetch around it is thin. The one
|
||||
// thing to verify against a live payload is the weight fields: this reads
|
||||
// `min_weight`/`max_weight` as kilogram strings, which is what the v2 docs
|
||||
// describe, and a method missing them is skipped rather than guessed at.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
|
@ -40,8 +50,8 @@ ShippingConfig gShipConfig;
|
|||
ShippingTable gShipTable;
|
||||
bool gShipConfigured = false;
|
||||
|
||||
// Same alphabet as the bunq helper; duplicated rather than shared because
|
||||
// each implementation unit keeps its internals to itself.
|
||||
// Duplicated rather than shared because each implementation unit keeps its
|
||||
// internals to itself.
|
||||
std::string Base64S(std::string_view in) {
|
||||
static constexpr char tbl[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
|
@ -69,6 +79,13 @@ std::string NowIsoS() {
|
|||
std::chrono::system_clock::now()));
|
||||
}
|
||||
|
||||
// Cache format: {"method":..,"fetched_at":..,"per_country":{"NL":[[2000,895],..]}}
|
||||
// where each pair is [maxWeightGrams, consumerCents]. Grams and cents, not the
|
||||
// kilograms and euros the API speaks, because everything downstream of the
|
||||
// parse is integer.
|
||||
//
|
||||
// This is also the documented way to run without an account: writing this file
|
||||
// by hand gives the server a complete rate table.
|
||||
void SaveCacheLocked() {
|
||||
if (gShipConfig.cachePath.empty()) return;
|
||||
std::ofstream out(gShipConfig.cachePath, std::ios::trunc | std::ios::binary);
|
||||
|
|
@ -76,8 +93,13 @@ void SaveCacheLocked() {
|
|||
out << std::format(R"({{"method":"{}","fetched_at":"{}","per_country":{{)",
|
||||
gShipTable.method, gShipTable.fetchedAt);
|
||||
bool first = true;
|
||||
for (const auto& [cc, minor] : gShipTable.perCountry) {
|
||||
out << std::format(R"({}"{}":{})", first ? "" : ",", cc, minor);
|
||||
for (const Money::ShipRates& row : gShipTable.perCountry) {
|
||||
out << std::format(R"({}"{}":[)", first ? "" : ",", row.cc);
|
||||
for (std::size_t i = 0; i < row.brackets.size(); ++i) {
|
||||
out << std::format("{}[{},{}]", i ? "," : "",
|
||||
row.brackets[i].maxWeightGrams, row.brackets[i].minor);
|
||||
}
|
||||
out << ']';
|
||||
first = false;
|
||||
}
|
||||
out << "}}\n";
|
||||
|
|
@ -95,21 +117,72 @@ void LoadCacheLocked() {
|
|||
t.fetchedAt = std::string(doc->Str("fetched_at"));
|
||||
if (const Json::Value* m = doc->Find("per_country"); m && m->IsObject()) {
|
||||
for (const auto& [k, v] : m->object) {
|
||||
if (v.type == Json::Type::Number && v.number > 0) {
|
||||
t.perCountry.emplace_back(k, static_cast<std::int64_t>(v.number));
|
||||
// A pre-brackets cache (country -> flat cents) parses fine as JSON
|
||||
// but is not a ladder, so it lands here as "not an array" and is
|
||||
// dropped. Correct: those numbers were a single unknown weight
|
||||
// band, and re-serving them would price parcels by guess. The next
|
||||
// refresh rewrites the file.
|
||||
if (!v.IsArray() || k.size() != 2) continue;
|
||||
Money::ShipRates row;
|
||||
row.cc = k;
|
||||
for (const Json::Value& b : v.array) {
|
||||
if (!b.IsArray() || b.array.size() != 2) continue;
|
||||
if (b.array[0].type != Json::Type::Number
|
||||
|| b.array[1].type != Json::Type::Number) continue;
|
||||
const auto grams = static_cast<std::int64_t>(b.array[0].number);
|
||||
const auto minor = static_cast<std::int64_t>(b.array[1].number);
|
||||
if (grams > 0 && minor > 0) row.brackets.push_back({ grams, minor });
|
||||
}
|
||||
if (!row.brackets.empty()) t.perCountry.push_back(std::move(row));
|
||||
}
|
||||
}
|
||||
if (!t.perCountry.empty()) gShipTable = std::move(t);
|
||||
}
|
||||
|
||||
// "2.001" (kilograms, as the API sends them) -> 2001 grams. Accepts a JSON
|
||||
// number too, in case the field is not always a string. Returns 0 for anything
|
||||
// unparseable, which the caller treats as "this method has no usable weight
|
||||
// range" and skips — a bracket with an invented ceiling is exactly the kind of
|
||||
// guess this module no longer makes.
|
||||
std::int64_t KgFieldToGrams(const Json::Value* v) {
|
||||
if (!v) return 0;
|
||||
if (v->type == Json::Type::Number) return std::llround(v->number * 1000.0);
|
||||
if (v->type != Json::Type::String) return 0;
|
||||
// Hand-rolled rather than from_chars<double>: the value is a fixed-point
|
||||
// decimal and this keeps it exact, the same reason money never touches a
|
||||
// float here.
|
||||
std::string_view s = v->string;
|
||||
std::int64_t whole = 0, frac = 0, scale = 1;
|
||||
std::size_t i = 0;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
whole = whole * 10 + (s[i] - '0');
|
||||
if (whole > 1'000'000) return 0; // absurd; treat as unusable
|
||||
}
|
||||
if (i == 0) return 0;
|
||||
if (i < s.size() && s[i] == '.') {
|
||||
++i;
|
||||
for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) {
|
||||
if (scale <= 100) { frac = frac * 10 + (s[i] - '0'); scale *= 10; }
|
||||
}
|
||||
}
|
||||
if (i != s.size()) return 0; // trailing junk
|
||||
while (scale <= 100) { frac *= 10; scale *= 10; } // normalise to 1/1000
|
||||
return whole * 1000 + frac;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// `methodName` is a comma-separated list of name substrings, merged in order
|
||||
// with FIRST MATCH PER COUNTRY winning. One method rarely covers a whole
|
||||
// market: the realistic setup is a courier inside Europe and post beyond
|
||||
// ("DPD Home,PostNL Parcels non-EU"), and the order encodes the preference —
|
||||
// a country served by both gets the earlier method's price.
|
||||
// with the FIRST FILTER TO COVER A COUNTRY winning it. One method rarely
|
||||
// covers a whole market: the realistic setup is a courier inside Europe and
|
||||
// post beyond ("DPD Home,PostNL Parcels non-EU"), and the order encodes the
|
||||
// preference — a country served by both gets the earlier filter's prices.
|
||||
//
|
||||
// Every method matching a filter contributes, NOT just the first. Sendcloud
|
||||
// publishes one entry per weight band of the same service, so the matches for
|
||||
// "DPD Home" are that service's ladder and taking only one of them would price
|
||||
// every parcel at whichever band the response happened to list first — the qty
|
||||
// 1 rate included.
|
||||
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName) {
|
||||
ShippingTable out;
|
||||
auto doc = Json::Parse(json);
|
||||
|
|
@ -131,39 +204,77 @@ ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view meth
|
|||
}
|
||||
}
|
||||
|
||||
auto ladderFor = [&](const std::string& cc) -> std::vector<Money::ShipBracket>& {
|
||||
for (Money::ShipRates& r : out.perCountry) {
|
||||
if (r.cc == cc) return r.brackets;
|
||||
}
|
||||
out.perCountry.push_back({ cc, {} });
|
||||
return out.perCountry.back().brackets;
|
||||
};
|
||||
|
||||
std::vector<std::string> names; // unique, for the operator log
|
||||
for (const std::string_view filter : filters) {
|
||||
// Snapshot of who is already covered: ownership is per FILTER, so a
|
||||
// later filter may not touch a country an earlier one priced, but the
|
||||
// bands within this filter must all reach the countries they cover.
|
||||
std::vector<std::string> owned;
|
||||
for (const Money::ShipRates& r : out.perCountry) owned.push_back(r.cc);
|
||||
|
||||
for (const Json::Value& method : methods->array) {
|
||||
if (!method.IsObject()) continue;
|
||||
const std::string_view name = method.Str("name");
|
||||
if (name.find(filter) == std::string_view::npos) continue;
|
||||
|
||||
if (!out.method.empty()) out.method += " + ";
|
||||
out.method += std::string(name);
|
||||
if (const Json::Value* countries = method.Find("countries");
|
||||
countries && countries->IsArray()) {
|
||||
for (const Json::Value& c : countries->array) {
|
||||
if (!c.IsObject()) continue;
|
||||
std::string cc(c.Str("iso_2"));
|
||||
if (cc.size() != 2) continue;
|
||||
// Earlier methods own their countries — a later method
|
||||
// never overrides.
|
||||
if (out.Find(cc) > 0) continue;
|
||||
// Sendcloud sends the price as a JSON number of euros.
|
||||
// Money stays integer everywhere else; this one boundary
|
||||
// rounds a decimal that is exact to the cent in a double
|
||||
// (shipping prices are far inside the safe range), and
|
||||
// llround guards the representation edge
|
||||
// (8.20*100 == 819.999...).
|
||||
const Json::Value* price = c.Find("price");
|
||||
if (!price || price->type != Json::Type::Number) continue;
|
||||
const std::int64_t minor = std::llround(price->number * 100.0);
|
||||
if (minor <= 0) continue;
|
||||
out.perCountry.emplace_back(std::move(cc), minor);
|
||||
// The band ceiling. A method that does not state one is unusable:
|
||||
// without it there is no way to know which parcels the price
|
||||
// covers, and assuming "any" is the guess this module exists to
|
||||
// avoid.
|
||||
const std::int64_t maxGrams = KgFieldToGrams(method.Find("max_weight"));
|
||||
if (maxGrams <= 0) continue;
|
||||
|
||||
if (std::ranges::find(names, name) == names.end()) names.emplace_back(name);
|
||||
|
||||
const Json::Value* countries = method.Find("countries");
|
||||
if (!countries || !countries->IsArray()) continue;
|
||||
for (const Json::Value& c : countries->array) {
|
||||
if (!c.IsObject()) continue;
|
||||
std::string cc(c.Str("iso_2"));
|
||||
if (cc.size() != 2) continue;
|
||||
if (std::ranges::find(owned, cc) != owned.end()) continue;
|
||||
// Sendcloud sends the price as a JSON number of euros.
|
||||
// Money stays integer everywhere else; this one boundary
|
||||
// rounds a decimal that is exact to the cent in a double
|
||||
// (shipping prices are far inside the safe range), and
|
||||
// llround guards the representation edge
|
||||
// (8.20*100 == 819.999...).
|
||||
const Json::Value* price = c.Find("price");
|
||||
if (!price || price->type != Json::Type::Number) continue;
|
||||
const std::int64_t minor = std::llround(price->number * 100.0);
|
||||
if (minor <= 0) continue;
|
||||
|
||||
std::vector<Money::ShipBracket>& ladder = ladderFor(cc);
|
||||
// Two methods under one filter can publish the same ceiling
|
||||
// (a service and its signed-for variant, say). Keep the
|
||||
// cheaper: both carry the parcel, so the dearer one is never
|
||||
// the right quote.
|
||||
auto same = std::ranges::find(ladder, maxGrams,
|
||||
&Money::ShipBracket::maxWeightGrams);
|
||||
if (same != ladder.end()) {
|
||||
same->minor = std::min(same->minor, minor);
|
||||
} else {
|
||||
ladder.push_back({ maxGrams, minor });
|
||||
}
|
||||
}
|
||||
break; // first method matching THIS filter wins; next filter
|
||||
}
|
||||
}
|
||||
|
||||
for (Money::ShipRates& r : out.perCountry) {
|
||||
std::ranges::sort(r.brackets, {}, &Money::ShipBracket::maxWeightGrams);
|
||||
}
|
||||
for (const std::string& n : names) {
|
||||
if (!out.method.empty()) out.method += " + ";
|
||||
out.method += n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -183,15 +294,25 @@ void ConfigureShipping(const ShippingConfig& config) {
|
|||
gShipTable.perCountry.size(),
|
||||
gShipTable.fetchedAt));
|
||||
}
|
||||
// Said loudly because it is not a degraded mode, it is a shop that cannot
|
||||
// take an order: with no rate table every checkout refuses. Not fatal —
|
||||
// the rest of the site is worth serving, and the refresh below may fix it
|
||||
// seconds later — but an operator who sees this and does nothing has a
|
||||
// storefront selling nothing.
|
||||
if (gShipTable.perCountry.empty()) {
|
||||
std::println(std::cerr,
|
||||
"shipping: NO RATE TABLE — checkout will refuse every order "
|
||||
"until Sendcloud answers{}",
|
||||
gShipConfigured ? "" : " (no credentials configured; a "
|
||||
"hand-written cache file also works)");
|
||||
}
|
||||
}
|
||||
|
||||
std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
|
||||
std::int64_t zoneEu, std::int64_t zoneWorld) {
|
||||
{
|
||||
std::lock_guard lock(gShipMutex);
|
||||
if (const std::int64_t live = gShipTable.Find(country); live > 0) return live;
|
||||
}
|
||||
return Money::ZoneShipping(zoneNl, zoneEu, zoneWorld, country);
|
||||
std::optional<std::int64_t> ShipCostFor(std::string_view country, std::int64_t grams) {
|
||||
std::lock_guard lock(gShipMutex);
|
||||
const std::int64_t rate = gShipTable.Find(country, grams);
|
||||
if (rate <= 0) return std::nullopt;
|
||||
return rate;
|
||||
}
|
||||
|
||||
ShippingTable CurrentShippingTable() {
|
||||
|
|
@ -200,7 +321,7 @@ ShippingTable CurrentShippingTable() {
|
|||
}
|
||||
|
||||
// Called by the HTTP layer's refresh thread. One authenticated GET; on any
|
||||
// failure the previous table (cached or zone fallback) simply stays.
|
||||
// failure the previous table (cached, or none at all) simply stays.
|
||||
void RefreshShippingTable() {
|
||||
ShippingConfig cfg;
|
||||
{
|
||||
|
|
@ -237,9 +358,11 @@ void RefreshShippingTable() {
|
|||
// VAT rate here (€7.13 -> €8.63; the difference is remitted, the cost
|
||||
// is covered), non-EU postage is zero-rated so cost is charged as-is.
|
||||
// Done once at table build — the cache stores consumer prices, so a
|
||||
// cache reload must not (and does not) gross up again.
|
||||
for (auto& [cc, minor] : t.perCountry) {
|
||||
if (Money::IsEuCountry(cc)) minor = Money::GrossFromNet(minor);
|
||||
// cache reload must not (and does not) gross up again. Every bracket
|
||||
// gets it, since any of them can be the one a parcel is quoted at.
|
||||
for (Money::ShipRates& row : t.perCountry) {
|
||||
if (!Money::IsEuCountry(row.cc)) continue;
|
||||
for (Money::ShipBracket& b : row.brackets) b.minor = Money::GrossFromNet(b.minor);
|
||||
}
|
||||
t.fetchedAt = NowIsoS();
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// catcrafts-server — the native product.
|
||||
//
|
||||
// Serves the server-rendered pages (crawlers and no-JS clients get real HTML),
|
||||
// runs the shop — orders, the bunq payment rail, the reconciler — and doubles
|
||||
// runs the shop — orders, the payment rails, the reconciler — and doubles
|
||||
// as the test harness for Catcrafts.Shared.
|
||||
//
|
||||
// The harness half is not filler. Catcrafts.Shared is the security boundary
|
||||
|
|
@ -704,6 +704,74 @@ void RunFormSelfTest() {
|
|||
Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(),
|
||||
"checkout: oversized colour rejected");
|
||||
|
||||
// The payment choice. Absent is a form that offered none (one rail
|
||||
// configured, or the no-JS fallback page) and the handler resolves it to
|
||||
// bank — the validator's job is only to refuse a word it does not know
|
||||
// rather than let it fall through to a default the buyer never picked.
|
||||
Check(validate(kGoodOrder).value.payChoice.empty(),
|
||||
"checkout: absent payment choice stays empty");
|
||||
Check(validate(std::string(kGoodOrder) + "&pay=bank").value.payChoice
|
||||
== Catcrafts::Form::kPayBank,
|
||||
"checkout: bank choice parsed");
|
||||
Check(validate(std::string(kGoodOrder) + "&pay=crypto").value.payChoice
|
||||
== Catcrafts::Form::kPayCrypto,
|
||||
"checkout: crypto choice parsed");
|
||||
{
|
||||
auto bogus = validate(std::string(kGoodOrder) + "&pay=free");
|
||||
Check(!bogus.Ok(), "checkout: unknown payment choice rejected");
|
||||
Check(bogus.errors.size() == 1 && bogus.errors[0].field == "pay",
|
||||
"checkout: the payment refusal hangs off the payment field");
|
||||
}
|
||||
|
||||
// Destinations the shop refuses. Well-formed, real country codes — the
|
||||
// refusal is policy, so it has to survive every spelling the form accepts,
|
||||
// and it must not spill onto other non-EU destinations.
|
||||
auto withCountry = [&](std::string_view cc) {
|
||||
return validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y&country="
|
||||
+ std::string(cc));
|
||||
};
|
||||
Check(!withCountry("US").Ok(), "checkout: US refused");
|
||||
Check(!withCountry("CA").Ok(), "checkout: CA refused");
|
||||
Check(!withCountry("us").Ok(), "checkout: lowercase US refused too");
|
||||
Check(withCountry("GB").Ok(), "checkout: other non-EU destinations still sell");
|
||||
Check(withCountry("NL").Ok(), "checkout: EU unaffected");
|
||||
{
|
||||
auto us = withCountry("US");
|
||||
Check(us.errors.size() == 1 && us.errors[0].field == "country",
|
||||
"checkout: refusal is a country error, nothing else");
|
||||
Check(us.errors[0].message == Catcrafts::Form::kNoSaleMessage,
|
||||
"checkout: refusal says where the shop does not sell");
|
||||
Check(us.value.country == "US", "checkout: refused country echoed back");
|
||||
}
|
||||
|
||||
// The shipping refusals. These are templates rather than plain strings
|
||||
// because the buy page fills the same ones client-side, so the substitution
|
||||
// has to work on both {cc} and {n} — a template that silently kept its
|
||||
// placeholder would ship "up to {n} per order" to a real buyer.
|
||||
{
|
||||
using namespace Catcrafts::Form;
|
||||
const std::string none = NoShippingMessage("BR");
|
||||
Check(none.find("BR") != std::string::npos
|
||||
&& none.find("{cc}") == std::string::npos,
|
||||
"shipping copy: the uncovered-country message names the country");
|
||||
const std::string heavy = TooHeavyMessage("JP", 3);
|
||||
Check(heavy.find("JP") != std::string::npos && heavy.find("3") != std::string::npos
|
||||
&& heavy.find("{n}") == std::string::npos,
|
||||
"shipping copy: the too-heavy message names the country and the limit");
|
||||
const std::string nofit = TooHeavyMessage("JP", 0);
|
||||
Check(nofit.find("JP") != std::string::npos
|
||||
&& nofit.find("up to") == std::string::npos,
|
||||
"shipping copy: with nothing fitting it does not promise a quantity");
|
||||
Check(FillShipMessage("{cc} {n} {cc}", "NL", 2) == "NL 2 NL",
|
||||
"shipping copy: every placeholder is filled, not just the first");
|
||||
// Both messages must offer the way out, since the shop is refusing
|
||||
// business it would otherwise take.
|
||||
Check(none.find("orders@catcrafts.net") != std::string::npos
|
||||
&& heavy.find("orders@catcrafts.net") != std::string::npos
|
||||
&& nofit.find("orders@catcrafts.net") != std::string::npos,
|
||||
"shipping copy: every refusal names a human to email");
|
||||
}
|
||||
|
||||
// A rejected field must still come back, or the visitor has to retype the
|
||||
// one thing they got wrong — the fastest way to lose a submission.
|
||||
auto rejected = validate("email=notanemail&name=Ada&street=Main%201&postal=1&city=y&country=NLD");
|
||||
|
|
@ -746,12 +814,49 @@ void RunMoneySelfTest() {
|
|||
Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)");
|
||||
Check(ZoneFor("NL") == Zone::Nl, "zone: home");
|
||||
Check(ZoneFor("DE") == Zone::Eu, "zone: eu");
|
||||
Check(ZoneFor("CA") == Zone::World, "zone: world");
|
||||
Check(ZoneFor("GB") == Zone::World, "zone: world");
|
||||
|
||||
// ── destinations the shop refuses ─────────────────────────────────
|
||||
// Zones still classify US and CA (the arithmetic is destination-blind, and
|
||||
// keeping it that way means one policy switch, not two); the sale is what
|
||||
// stops, in SellsTo.
|
||||
Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused");
|
||||
Check(SellsTo("NL") && SellsTo("DE"), "policy: EU sells");
|
||||
Check(SellsTo("GB") && SellsTo("CH") && SellsTo("AU"),
|
||||
"policy: the rest of the world still sells");
|
||||
Check(SellsTo("us"), "policy: matched on the normalised code, like membership");
|
||||
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify");
|
||||
|
||||
// ── carrier weight brackets ───────────────────────────────────────
|
||||
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
|
||||
// 20 kg, with the 20 kg band deliberately CHEAPER than the 10 kg one —
|
||||
// real carrier tariffs do that, and picking the tightest band rather than
|
||||
// the cheapest one that carries the parcel would overcharge for it.
|
||||
{
|
||||
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 1650 },
|
||||
{ 20000, 1490 } };
|
||||
Check(RateFor(ladder, 700) == 895, "brackets: one unit takes the 2 kg band");
|
||||
Check(RateFor(ladder, 2000) == 895, "brackets: the ceiling is inclusive");
|
||||
Check(RateFor(ladder, 2001) == 1490,
|
||||
"brackets: cheapest band that CARRIES it, not the tightest");
|
||||
Check(RateFor(ladder, 20001) == 0, "brackets: above every band is no price");
|
||||
Check(RateFor({}, 700) == 0, "brackets: an uncovered country has no price");
|
||||
|
||||
Check(MaxUnitsFor(ladder, 700) == 28, "brackets: units that fit one parcel");
|
||||
Check(MaxUnitsFor(ladder, 25000) == 0,
|
||||
"brackets: a unit heavier than every band fits nothing");
|
||||
Check(MaxUnitsFor(ladder, 0) == 0, "brackets: no weight, no answer");
|
||||
Check(MaxUnitsFor({}, 700) == 0, "brackets: no ladder, nothing fits");
|
||||
|
||||
// The table-level lookups the handler and the page both go through.
|
||||
const std::vector<ShipRates> table{ { "NL", ladder }, { "JP", { { 2000, 4250 } } } };
|
||||
Check(RateFor(LadderFor(table, "NL"), 700) == 895, "table: NL priced");
|
||||
Check(RateFor(LadderFor(table, "JP"), 2100) == 0,
|
||||
"table: JP has one light band, so two units are unshippable");
|
||||
Check(LadderFor(table, "BR").empty(), "table: unlisted country is empty");
|
||||
}
|
||||
|
||||
// ── order totals ──────────────────────────────────────────────────
|
||||
Check(ZoneShipping(1500, 2500, 5500, "NL") == 1500, "ship: NL zone");
|
||||
Check(ZoneShipping(1500, 2500, 5500, "DE") == 2500, "ship: EU zone");
|
||||
Check(ZoneShipping(1500, 2500, 5500, "CA") == 5500, "ship: world zone");
|
||||
|
||||
// NL: gross + shipping, VAT included in both.
|
||||
auto nl = ComputeTotals(58000, 1, 1500, "NL");
|
||||
|
|
@ -765,17 +870,17 @@ void RunMoneySelfTest() {
|
|||
"totals: EU");
|
||||
|
||||
// Export: net goods, world shipping, no VAT.
|
||||
auto ca = ComputeTotals(58000, 1, 5500, "CA");
|
||||
Check(ca.goods == 47934 && ca.shipping == 5500 && ca.total == 53434,
|
||||
auto gb = ComputeTotals(58000, 1, 5500, "GB");
|
||||
Check(gb.goods == 47934 && gb.shipping == 5500 && gb.total == 53434,
|
||||
"totals: export");
|
||||
Check(!ca.vatIncluded && ca.vatCharged == 0, "totals: export carries no VAT");
|
||||
Check(!gb.vatIncluded && gb.vatCharged == 0, "totals: export carries no VAT");
|
||||
|
||||
// Quantity: the export net is derived from the LINE total, not per unit —
|
||||
// per-unit rounding times qty would differ by a cent here, and the JS
|
||||
// preview mirrors this exact formula.
|
||||
auto ca2 = ComputeTotals(57500, 2, 5500, "CA");
|
||||
Check(ca2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
|
||||
Check(ca2.goods == 95041, "totals: 2× green export net exact");
|
||||
auto gb2 = ComputeTotals(57500, 2, 5500, "GB");
|
||||
Check(gb2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
|
||||
Check(gb2.goods == 95041, "totals: 2× green export net exact");
|
||||
auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
|
||||
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
|
||||
|
||||
|
|
@ -814,7 +919,17 @@ void RunMoneySelfTest() {
|
|||
// with its ONE offer — built from the same integers the
|
||||
// checkout charges.
|
||||
{
|
||||
auto pp = Views::RenderProduct(pr, Rates{});
|
||||
// The listing's shipping block is now carrier data, so the
|
||||
// render needs a table. US is priced here on purpose: the
|
||||
// carrier will happily quote it and the shop still must not
|
||||
// advertise it.
|
||||
const std::vector<ShipRates> feedTable{
|
||||
{ "NL", { { 2000, 895 } } },
|
||||
{ "DE", { { 2000, 995 } } },
|
||||
{ "GB", { { 2000, 2450 } } },
|
||||
{ "US", { { 2000, 1794 } } },
|
||||
};
|
||||
auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
|
||||
auto ld = Json::Parse(pp.meta.jsonLd);
|
||||
bool variantsOk = false;
|
||||
if (ld && ld->IsObject()) {
|
||||
|
|
@ -838,6 +953,24 @@ void RunMoneySelfTest() {
|
|||
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
|
||||
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||
"schema: variants carry shipping, returns, sku and group id");
|
||||
// The published rates ARE the carrier's, at one unit's weight.
|
||||
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
|
||||
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
|
||||
"schema: shipping rates come from the carrier table");
|
||||
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
|
||||
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos,
|
||||
"schema: a refused destination is never advertised, priced or not");
|
||||
|
||||
// No table: no shipping claim. The listing loses the merchant
|
||||
// block rather than inventing a rate — the whole point of
|
||||
// dropping the zone fallback.
|
||||
auto bare = Views::RenderProduct(pr, Rates{});
|
||||
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
|
||||
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
|
||||
"schema: with no carrier table the offer publishes no shipping");
|
||||
Check(Json::Parse(bare.meta.jsonLd).has_value()
|
||||
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||
"schema: and the rest of the record still parses");
|
||||
}
|
||||
}
|
||||
Check(!Content::Projects().empty(), "content: projects present");
|
||||
|
|
@ -931,51 +1064,92 @@ void RunMoneySelfTest() {
|
|||
}
|
||||
|
||||
// ── the Sendcloud response parser ─────────────────────────────────
|
||||
// Weights are the kilogram strings the API sends; every Find() below asks
|
||||
// for a parcel weight, because a price without a weight is not a thing this
|
||||
// table has any more.
|
||||
{
|
||||
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"Other Method","countries":[{"iso_2":"NL","price":1.00}]},
|
||||
{"name":"DHL For You Home","countries":[
|
||||
{"name":"Other Method","min_weight":"0.001","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":1.00}]},
|
||||
{"name":"DHL For You Home","min_weight":"0.001","max_weight":"2.000",
|
||||
"countries":[
|
||||
{"iso_2":"NL","price":6.25},
|
||||
{"iso_2":"DE","price":8.20},
|
||||
{"iso_2":"CA","price":42.50},
|
||||
{"iso_2":"XX","price":0},
|
||||
{"iso_2":"TOOLONG","price":5.00}]}]})", "DHL For You");
|
||||
{"iso_2":"TOOLONG","price":5.00}]},
|
||||
{"name":"DHL For You Home","min_weight":"2.000","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":9.95},{"iso_2":"DE","price":13.40}]},
|
||||
{"name":"DHL For You Home","countries":[{"iso_2":"BE","price":1.00}]}]})",
|
||||
"DHL For You");
|
||||
Check(table.method == "DHL For You Home", "sendcloud: method matched by substring");
|
||||
Check(table.Find("NL") == 625, "sendcloud: NL price to cents");
|
||||
Check(table.Find("DE") == 820, "sendcloud: 8.20 rounds exactly");
|
||||
Check(table.Find("CA") == 4250, "sendcloud: CA price");
|
||||
Check(table.Find("XX") == 0, "sendcloud: zero price dropped");
|
||||
Check(table.Find("TOOLONG") == 0, "sendcloud: malformed iso dropped");
|
||||
Check(table.Find("NL", 700) == 625, "sendcloud: NL price to cents");
|
||||
Check(table.Find("DE", 700) == 820, "sendcloud: 8.20 rounds exactly");
|
||||
Check(table.Find("CA", 700) == 4250, "sendcloud: CA price");
|
||||
Check(table.Find("XX", 700) == 0, "sendcloud: zero price dropped");
|
||||
Check(table.Find("TOOLONG", 700) == 0, "sendcloud: malformed iso dropped");
|
||||
// The bug the old parser had: it stopped at the FIRST matching method,
|
||||
// so every parcel was priced at whichever band came first and the
|
||||
// heavier bands were invisible.
|
||||
Check(table.Find("NL", 2100) == 995 && table.Find("DE", 2100) == 1340,
|
||||
"sendcloud: every weight band of a matched method is kept");
|
||||
Check(table.Find("NL", 11000) == 0,
|
||||
"sendcloud: past the heaviest band there is no price");
|
||||
Check(table.Find("BE", 700) == 0,
|
||||
"sendcloud: a method with no weight range is unusable, not unlimited");
|
||||
Check(Server::ParseSendcloudMethods("garbage", "x").perCountry.empty(),
|
||||
"sendcloud: malformed payload yields nothing");
|
||||
|
||||
// Comma-separated merge: courier for Europe, post for the world; the
|
||||
// earlier method keeps any country both cover.
|
||||
// earlier FILTER keeps any country both cover — including that
|
||||
// country's heavier bands, which must not leak in from the later one.
|
||||
const auto merged = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"DPD Home","countries":[
|
||||
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000","countries":[
|
||||
{"iso_2":"NL","price":7.13},{"iso_2":"DE","price":10.49}]},
|
||||
{"name":"PostNL Parcels non-EU","countries":[
|
||||
{"name":"PostNL Parcels non-EU","min_weight":"0.001","max_weight":"2.000",
|
||||
"countries":[
|
||||
{"iso_2":"CA","price":23.95},{"iso_2":"US","price":17.94},
|
||||
{"iso_2":"DE","price":99.99}]}]})",
|
||||
{"iso_2":"DE","price":99.99}]},
|
||||
{"name":"PostNL Parcels non-EU","min_weight":"2.000","max_weight":"20.000",
|
||||
"countries":[{"iso_2":"CA","price":48.10},{"iso_2":"DE","price":99.99}]}]})",
|
||||
"DPD Home, PostNL Parcels non-EU");
|
||||
Check(merged.Find("NL") == 713 && merged.Find("CA") == 2395,
|
||||
"sendcloud: merged table covers both methods");
|
||||
Check(merged.Find("DE") == 1049,
|
||||
"sendcloud: earlier method wins a shared country");
|
||||
Check(merged.Find("NL", 700) == 713 && merged.Find("CA", 700) == 2395,
|
||||
"sendcloud: merged table covers both filters");
|
||||
Check(merged.Find("CA", 5000) == 4810, "sendcloud: heavier band from the later filter");
|
||||
Check(merged.Find("DE", 700) == 1049,
|
||||
"sendcloud: earlier filter wins a shared country");
|
||||
Check(merged.Find("DE", 12000) == 0,
|
||||
"sendcloud: and owns it outright — no band from the loser");
|
||||
Check(merged.method == "DPD Home + PostNL Parcels non-EU",
|
||||
"sendcloud: merged method names recorded");
|
||||
"sendcloud: merged method names recorded, deduplicated per band");
|
||||
|
||||
// Two services under one filter publishing the same ceiling: the
|
||||
// cheaper is the only sensible quote, since both carry the parcel.
|
||||
const auto dup = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":9.00}]},
|
||||
{"name":"DPD Home Signed","min_weight":"0.001","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":7.50}]}]})", "DPD Home");
|
||||
Check(dup.Find("NL", 700) == 750, "sendcloud: duplicate band keeps the cheaper");
|
||||
}
|
||||
|
||||
// ── indicative conversion ─────────────────────────────────────────
|
||||
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
|
||||
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
|
||||
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
|
||||
auto ca$ = CurrencyFor("CA");
|
||||
Check(ca$.has_value() && ca$->code == "CAD", "fx: CA -> CAD");
|
||||
auto gbp = CurrencyFor("GB");
|
||||
Check(gbp.has_value() && gbp->code == "GBP", "fx: GB -> GBP");
|
||||
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
|
||||
Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion");
|
||||
if (ca$) {
|
||||
Check(FormatIndicative(*ca$, 920) == "≈ CA$920", "fx: display form");
|
||||
if (gbp) {
|
||||
Check(FormatIndicative(*gbp, 920) == "≈ £920", "fx: display form");
|
||||
}
|
||||
// A country the shop refuses gets no localised price either — the two
|
||||
// tables are kept consistent on purpose, so this is a real invariant and
|
||||
// not a coincidence of the current list.
|
||||
for (const std::string_view cc : NoSaleCountries()) {
|
||||
Check(!CurrencyFor(cc).has_value(),
|
||||
"fx: refused destinations have no display currency", cc);
|
||||
}
|
||||
|
||||
// ── order tokens and references ───────────────────────────────────
|
||||
|
|
@ -989,7 +1163,7 @@ void RunMoneySelfTest() {
|
|||
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
|
||||
"reference: derived and uppercased");
|
||||
|
||||
// ── the wire-amount parser (bunq responses) ───────────────────────
|
||||
// ── the wire-amount parser (both providers quote strings) ─────────
|
||||
using Server::ParseAmountToMinor;
|
||||
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
|
||||
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
|
||||
|
|
@ -1033,6 +1207,98 @@ void RunMoneySelfTest() {
|
|||
"mollie: missing id rejected");
|
||||
}
|
||||
|
||||
// ── the CoinGate order parser ─────────────────────────────────────
|
||||
//
|
||||
// The id is a JSON NUMBER at CoinGate, which is the one shape difference
|
||||
// from Mollie that could silently produce an empty payment id — an order
|
||||
// that can never be polled. Both spellings are pinned here.
|
||||
{
|
||||
const auto c1 = Server::ParseCoingateOrder(R"({
|
||||
"id":538,"status":"new","title":"CC-ABCDEF catcrafts.net",
|
||||
"price_amount":"578.30","price_currency":"EUR","receive_currency":"EUR",
|
||||
"payment_url":"https://pay.coingate.com/invoice/abc-123"})");
|
||||
Check(c1.has_value(), "coingate: new order parses");
|
||||
if (c1) {
|
||||
Check(c1->id == "538", "coingate: numeric id becomes decimal text");
|
||||
Check(c1->status == "new", "coingate: status");
|
||||
Check(c1->priceMinor == 57830, "coingate: price to cents");
|
||||
Check(c1->payUrl == "https://pay.coingate.com/invoice/abc-123",
|
||||
"coingate: payment url");
|
||||
Check(c1->payCurrency.empty(), "coingate: no coin picked yet");
|
||||
}
|
||||
const auto c2 = Server::ParseCoingateOrder(R"({
|
||||
"id":"539","status":"paid","pay_currency":"BTC",
|
||||
"price_amount":"578.3","price_currency":"EUR"})");
|
||||
Check(c2 && c2->id == "539", "coingate: string id also accepted");
|
||||
Check(c2 && c2->status == "paid" && c2->payCurrency == "BTC",
|
||||
"coingate: paid order carries the coin");
|
||||
Check(c2 && c2->priceMinor == 57830,
|
||||
"coingate: one-decimal amount is still cents");
|
||||
const auto c3 = Server::ParseCoingateOrder(R"({
|
||||
"id":540,"status":"paid","price_amount":"578.30","price_currency":"USD"})");
|
||||
Check(c3 && c3->priceMinor == 0, "coingate: non-EUR amount refuses to count");
|
||||
Check(!Server::ParseCoingateOrder("garbage").has_value(),
|
||||
"coingate: malformed payload rejected");
|
||||
Check(!Server::ParseCoingateOrder(R"({"status":"new"})").has_value(),
|
||||
"coingate: missing id rejected");
|
||||
Check(!Server::ParseCoingateOrder(R"({"id":541})").has_value(),
|
||||
"coingate: missing status rejected");
|
||||
}
|
||||
|
||||
// ── request provenance ────────────────────────────────────────────
|
||||
//
|
||||
// The rate limiter keys on this, so getting the WRONG end of the header
|
||||
// is not a cosmetic bug: the leftmost entry is client-controlled, and
|
||||
// trusting it would hand every attacker an endless supply of identities.
|
||||
{
|
||||
using Server::ClientAddressFromForwarded;
|
||||
Check(ClientAddressFromForwarded("203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: single entry");
|
||||
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: rightmost entry wins");
|
||||
// The attack this exists to defeat: a client that sends its own header
|
||||
// to look like a different peer. Caddy appends the truth on the right.
|
||||
Check(ClientAddressFromForwarded("1.1.1.1, 2.2.2.2, 203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: spoofed prefix ignored");
|
||||
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: padding trimmed");
|
||||
Check(ClientAddressFromForwarded("2001:db8::1") == "2001:db8::1",
|
||||
"forwarded: ipv6 passes through");
|
||||
Check(ClientAddressFromForwarded("").empty(), "forwarded: empty stays empty");
|
||||
// No header at all means nothing proxied this request; the caller must
|
||||
// see an empty peer and fall back to the global budget.
|
||||
Check(ClientAddressFromForwarded("198.51.100.4, ").empty(),
|
||||
"forwarded: empty last entry is no peer");
|
||||
}
|
||||
|
||||
{
|
||||
using Server::OriginAllowed;
|
||||
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net"),
|
||||
"origin: same origin allowed");
|
||||
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net/"),
|
||||
"origin: trailing slash on the base normalised");
|
||||
// A non-browser client (curl, the e2e suite) sends no Origin and
|
||||
// cannot be a cross-site forgery — there is no session to ride on.
|
||||
Check(OriginAllowed("", "https://catcrafts.net"), "origin: absent allowed");
|
||||
Check(!OriginAllowed("https://evil.example", "https://catcrafts.net"),
|
||||
"origin: foreign origin refused");
|
||||
// Neither a subdomain nor a lookalike is us.
|
||||
Check(!OriginAllowed("https://catcrafts.net.evil.example", "https://catcrafts.net"),
|
||||
"origin: suffix lookalike refused");
|
||||
Check(!OriginAllowed("https://shop.catcrafts.net", "https://catcrafts.net"),
|
||||
"origin: subdomain refused");
|
||||
// Scheme is part of an origin: http is not https.
|
||||
Check(!OriginAllowed("http://catcrafts.net", "https://catcrafts.net"),
|
||||
"origin: scheme mismatch refused");
|
||||
// A sandboxed iframe posts Origin: null. Present, and not us.
|
||||
Check(!OriginAllowed("null", "https://catcrafts.net"), "origin: null refused");
|
||||
Check(!OriginAllowed("https://catcrafts.net", ""),
|
||||
"origin: unconfigured base refuses rather than accepts all");
|
||||
// dev.sh serves on localhost and sets --redirect-base to match.
|
||||
Check(OriginAllowed("http://localhost:8080", "http://localhost:8080"),
|
||||
"origin: dev localhost base matches");
|
||||
}
|
||||
|
||||
// ── the invoice builder ───────────────────────────────────────────
|
||||
{
|
||||
Server::OrderRecord o;
|
||||
|
|
@ -1071,7 +1337,7 @@ void RunMoneySelfTest() {
|
|||
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
|
||||
|
||||
o.vatIncluded = false;
|
||||
o.buyer.country = "CA";
|
||||
o.buyer.country = "GB";
|
||||
o.goodsMinor = 93107;
|
||||
o.shippingMinor = 2395;
|
||||
o.totalMinor = 95502;
|
||||
|
|
@ -1123,7 +1389,7 @@ void RunMoneySelfTest() {
|
|||
|
||||
// The export wording mirrors the invoice's VAT treatment.
|
||||
o.vatIncluded = false;
|
||||
o.buyer.country = "CA";
|
||||
o.buyer.country = "GB";
|
||||
o.totalMinor = 95502;
|
||||
const std::string exMail = Server::BuildOrderConfirmationEmail(
|
||||
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
|
||||
|
|
@ -1305,19 +1571,20 @@ int main(int argc, char** argv) {
|
|||
// /var/lib/catcrafts, which is deliberately NOT the web root — that
|
||||
// directory is publicly served and wiped by rsync --delete each deploy.
|
||||
std::filesystem::path ordersPath = "orders.jsonl";
|
||||
// Payment rail selection. Flags beat environment beats default. The
|
||||
// default is "whichever provider has a key, off otherwise" so a box
|
||||
// with no credentials serves the whole site minus checkout instead of
|
||||
// refusing to start. Mollie outranks bunq: bunq.me's per-method limits
|
||||
// (€500/card, nothing for non-EU buyers) disqualified it as the
|
||||
// checkout; the client is kept for a possible future account sweep.
|
||||
// Payment rail selection, one slot per payment choice the buyer gets.
|
||||
// Flags beat environment beats default, and the default for each slot
|
||||
// is "the provider whose key is set, off otherwise" — so a box with no
|
||||
// credentials serves the whole site minus checkout instead of refusing
|
||||
// to start, and a box with only one key offers only that one method.
|
||||
//
|
||||
// bank MOLLIE_API_KEY iDEAL, cards, transfer
|
||||
// crypto COINGATE_API_KEY on-chain and Lightning, settled to EUR
|
||||
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
|
||||
const char* bunqKey = std::getenv("BUNQ_API_KEY");
|
||||
std::string railMode = mollieKey && *mollieKey ? "mollie"
|
||||
: bunqKey && *bunqKey ? "bunq"
|
||||
: "off";
|
||||
bool bunqSandbox = [] {
|
||||
const char* v = std::getenv("BUNQ_SANDBOX");
|
||||
const char* coingateKey = std::getenv("COINGATE_API_KEY");
|
||||
std::string railMode = mollieKey && *mollieKey ? "mollie" : "off";
|
||||
std::string cryptoMode = coingateKey && *coingateKey ? "coingate" : "off";
|
||||
bool coingateSandbox = [] {
|
||||
const char* v = std::getenv("COINGATE_SANDBOX");
|
||||
return v && std::string_view(v) == "1";
|
||||
}();
|
||||
std::filesystem::path railState;
|
||||
|
|
@ -1336,12 +1603,10 @@ int main(int argc, char** argv) {
|
|||
ordersPath = a.substr(9);
|
||||
} else if (a.starts_with("--rail=")) {
|
||||
railMode = a.substr(7);
|
||||
} else if (a.starts_with("--bunq=")) {
|
||||
railMode = a.substr(7); // legacy alias for --rail=
|
||||
} else if (a.starts_with("--crypto-rail=")) {
|
||||
cryptoMode = a.substr(14);
|
||||
} else if (a.starts_with("--rail-state=")) {
|
||||
railState = a.substr(13);
|
||||
} else if (a.starts_with("--bunq-state=")) {
|
||||
railState = a.substr(13); // legacy alias for --rail-state=
|
||||
} else if (a.starts_with("--redirect-base=")) {
|
||||
redirectBase = a.substr(16);
|
||||
} else {
|
||||
|
|
@ -1400,30 +1665,51 @@ int main(int argc, char** argv) {
|
|||
return 2;
|
||||
}
|
||||
|
||||
// The rail. State (bunq session context, or the fake rail's paid
|
||||
// marker; Mollie needs none) defaults next to the orders file — same
|
||||
// directory, same lifecycle, same backup.
|
||||
// The rails. State (only the fake rail has any — its paid marker;
|
||||
// neither real provider needs a session or a keypair) defaults next to
|
||||
// the orders file: same directory, same lifecycle, same backup.
|
||||
if (railState.empty()) {
|
||||
railState = ordersPath;
|
||||
railState += (railMode == "fake") ? ".fake-paid" : ".bunq-state.json";
|
||||
railState += ".fake-paid";
|
||||
}
|
||||
Server::RailConfig railCfg;
|
||||
railCfg.mode = railMode;
|
||||
railCfg.apiKey = railMode == "mollie" ? (mollieKey ? mollieKey : "")
|
||||
: railMode == "bunq" ? (bunqKey ? bunqKey : "")
|
||||
: "";
|
||||
railCfg.sandbox = bunqSandbox;
|
||||
railCfg.statePath = railState;
|
||||
railCfg.redirectBase = redirectBase;
|
||||
std::unique_ptr<Server::PaymentRail> rail = Server::MakeRail(railCfg);
|
||||
if ((railMode == "mollie" || railMode == "bunq") && railCfg.apiKey.empty()) {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: --rail={} but its API key env is not set — "
|
||||
"refusing to start with a rail that cannot work", railMode);
|
||||
// A mode whose credential is missing is a misconfiguration, not a
|
||||
// reason to quietly serve a checkout that 502s at the last step. Both
|
||||
// slots are checked the same way, and both name the env var they want.
|
||||
auto build = [&](const std::string& mode, const char* key, const char* keyName,
|
||||
bool sandbox, std::unique_ptr<Server::PaymentRail>& out) -> bool {
|
||||
Server::RailConfig cfg;
|
||||
cfg.mode = mode;
|
||||
cfg.apiKey = key ? key : "";
|
||||
cfg.sandbox = sandbox;
|
||||
cfg.statePath = railState;
|
||||
cfg.redirectBase = redirectBase;
|
||||
const bool needsKey = mode == "mollie" || mode == "coingate";
|
||||
if (needsKey && cfg.apiKey.empty()) {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: rail '{}' selected but {} is not set — "
|
||||
"refusing to start with a rail that cannot work",
|
||||
mode, keyName);
|
||||
return false;
|
||||
}
|
||||
out = Server::MakeRail(cfg);
|
||||
// "off" is a legitimate choice and yields no rail; a mode nobody
|
||||
// recognises silently would too, which is how a typo becomes a
|
||||
// shop that quietly stops taking one kind of money.
|
||||
if (!out && mode != "off") {
|
||||
std::println(std::cerr, "catcrafts-server: unknown rail '{}'", mode);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
Server::PaymentRails rails;
|
||||
if (!build(railMode, mollieKey, "MOLLIE_API_KEY", false, rails.bank)) return 2;
|
||||
if (!build(cryptoMode, coingateKey, "COINGATE_API_KEY", coingateSandbox,
|
||||
rails.crypto)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
Server::ConfigurePayments(std::move(rail), redirectBase);
|
||||
Server::ConfigurePayments(std::move(rails), redirectBase);
|
||||
|
||||
// Invoice signing: the GPG key uid/fingerprint; GNUPGHOME decides the
|
||||
// keyring. Unset means unsigned dev invoices with a visible marker.
|
||||
|
|
@ -1442,9 +1728,11 @@ int main(int argc, char** argv) {
|
|||
Server::ConfigureMail(std::move(mailCfg));
|
||||
}
|
||||
|
||||
// Sendcloud is optional: without credentials the compiled-in zone
|
||||
// table prices all shipping, which is exactly how dev and e2e run.
|
||||
// With credentials the refresh thread fetches per-country rates.
|
||||
// Sendcloud is the ONLY source of shipping prices: no credentials and
|
||||
// no cached table means checkout refuses every order (loudly logged at
|
||||
// startup). Dev and e2e get a table by writing the cache file next to
|
||||
// the orders file by hand — same format the refresh writes, so no test
|
||||
// hook exists for this and none can drift from production.
|
||||
Server::ShippingConfig shipCfg;
|
||||
if (const char* v = std::getenv("SENDCLOUD_PUBLIC_KEY")) shipCfg.publicKey = v;
|
||||
if (const char* v = std::getenv("SENDCLOUD_SECRET_KEY")) shipCfg.secretKey = v;
|
||||
|
|
@ -1457,7 +1745,7 @@ int main(int argc, char** argv) {
|
|||
}
|
||||
|
||||
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
|
||||
// the escape hatch for a payment bunq confirmed out-of-band (or a refund):
|
||||
// the escape hatch for a payment confirmed out-of-band (or a refund):
|
||||
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
|
||||
if (!args.empty() && args[0] == "--orders") {
|
||||
std::filesystem::path file = "orders.jsonl";
|
||||
|
|
@ -1499,14 +1787,21 @@ int main(int argc, char** argv) {
|
|||
std::println("orders: {}", orders.size());
|
||||
if (orders.empty()) return 0;
|
||||
std::println("");
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
||||
"reference", "status", "total", "cc", "colour", "qty", "via",
|
||||
"created", "token");
|
||||
// `pay` is the rail the order was created on, `via` what actually
|
||||
// settled it. Both, because they answer different questions: an order
|
||||
// stuck awaiting needs the first (which provider's dashboard to open),
|
||||
// and a paid one needs the second (whether the money can still be
|
||||
// pulled back — cards can, iDEAL and crypto cannot).
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
|
||||
"reference", "status", "total", "cc", "colour", "qty", "pay",
|
||||
"via", "created", "token");
|
||||
for (const auto& o : orders) {
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
|
||||
o.reference, o.status, Money::FormatMinor(o.totalMinor),
|
||||
o.buyer.country, o.color.empty() ? "-" : o.color,
|
||||
o.quantity, o.paidVia.empty() ? "-" : o.paidVia,
|
||||
o.quantity,
|
||||
o.payChoice.empty() ? "-" : o.payChoice,
|
||||
o.paidVia.empty() ? "-" : o.paidVia,
|
||||
o.createdAt, o.token);
|
||||
}
|
||||
return 0;
|
||||
|
|
@ -1514,10 +1809,12 @@ int main(int argc, char** argv) {
|
|||
|
||||
std::println("catcrafts-server: --selftest | --render <path> | --routes | --sitemap | --feed\n"
|
||||
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
|
||||
" [--rail=off|fake|mollie|bunq] [--rail-state=FILE] [--redirect-base=URL]\n"
|
||||
" [--rail=off|fake|mollie] [--crypto-rail=off|fake-crypto|coingate]\n"
|
||||
" [--rail-state=FILE] [--redirect-base=URL]\n"
|
||||
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
|
||||
"\n"
|
||||
"environment: MOLLIE_API_KEY (test_… or live_…), BUNQ_API_KEY, BUNQ_SANDBOX=1,\n"
|
||||
"environment: MOLLIE_API_KEY (test_… or live_…) selects the bank rail,\n"
|
||||
" COINGATE_API_KEY the crypto rail, COINGATE_SANDBOX=1,\n"
|
||||
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
|
||||
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ 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.
|
||||
*/
|
||||
|
||||
// The native server: server-rendered pages, order storage, and the bunq
|
||||
// payment rail.
|
||||
// The native server: server-rendered pages, order storage, and the payment
|
||||
// rails.
|
||||
//
|
||||
// Unlike Catcrafts.Shared this module is host-only and may import whatever it
|
||||
// needs — Crafter.Network here, OpenSSL for request signing. The division of
|
||||
// labour is that Shared decides what the markup IS and Server decides how it
|
||||
// reaches a socket, where orders live, and how money moves.
|
||||
// needs — Crafter.Network here. The division of labour is that Shared decides
|
||||
// what the markup IS and Server decides how it reaches a socket, where orders
|
||||
// live, and how money moves.
|
||||
|
||||
export module Catcrafts.Server;
|
||||
import std;
|
||||
|
|
@ -65,9 +65,14 @@ export namespace Catcrafts::Server {
|
|||
std::int64_t totalMinor = 0;
|
||||
bool vatIncluded = false;
|
||||
std::string status = "awaiting_payment"; // -> paid -> shipped | cancelled
|
||||
std::string payChoice; // Form::kPayBank | Form::kPayCrypto; which
|
||||
// rail issued the link, and so which one
|
||||
// may confirm it. Always set: checkout
|
||||
// normalises before writing.
|
||||
std::string payUrl; // the provider's hosted checkout link
|
||||
std::string payId; // provider payment id ("tr_…" at Mollie)
|
||||
std::string paidVia; // method that settled it ("ideal", "creditcard")
|
||||
std::string payId; // provider payment id ("tr_…" at Mollie,
|
||||
// a decimal order id at CoinGate)
|
||||
std::string paidVia; // method that settled it ("ideal", "bitcoin")
|
||||
std::string invoiceNumber; // "<customer-uuid>-<n>", set at paid
|
||||
std::string invoicedAt; // ISO 8601 of the invoice event
|
||||
std::string confirmationSentAt; // ISO 8601 of the confirmation-email
|
||||
|
|
@ -186,8 +191,12 @@ export namespace Catcrafts::Server {
|
|||
//
|
||||
// A rail turns "this order wants €X" into a URL a buyer can pay at, and
|
||||
// answers "has it been paid?". Everything else — storage, rendering,
|
||||
// reconciling — is rail-agnostic, which is what will let a crypto rail
|
||||
// slot in later without reshaping orders.
|
||||
// reconciling — is rail-agnostic, which is what lets two of them run side
|
||||
// by side without reshaping orders.
|
||||
|
||||
// The buyer's choice at checkout is Form::kPayBank / Form::kPayCrypto —
|
||||
// defined in Shared because the form emits those strings and this module
|
||||
// stores them, and one wire format deserves one definition.
|
||||
|
||||
struct PaymentLink {
|
||||
std::string payUrl;
|
||||
|
|
@ -195,13 +204,14 @@ export namespace Catcrafts::Server {
|
|||
};
|
||||
|
||||
// What a poll learned about one payment. Pending and Dead are different
|
||||
// answers on purpose: a Mollie payment EXPIRES (unlike a bunq tab), and an
|
||||
// order whose payment can never arrive should lapse rather than sit
|
||||
// "awaiting" forever.
|
||||
// answers on purpose: both providers EXPIRE unpaid orders — Mollie after
|
||||
// its own window, CoinGate after two hours (twenty minutes once a coin is
|
||||
// picked) — and an order whose payment can never arrive should lapse
|
||||
// rather than sit "awaiting" forever.
|
||||
enum class PayState { Pending, Paid, Dead };
|
||||
struct PaidStatus {
|
||||
PayState state = PayState::Pending;
|
||||
std::string method; // "ideal" | "creditcard" | "banktransfer" | …
|
||||
std::string method; // "ideal" | "creditcard" | "bitcoin" | …
|
||||
};
|
||||
|
||||
class PaymentRail {
|
||||
|
|
@ -224,16 +234,37 @@ export namespace Catcrafts::Server {
|
|||
};
|
||||
|
||||
struct RailConfig {
|
||||
std::string mode; // "off" | "fake" | "mollie" | "bunq"
|
||||
std::string apiKey; // mollie: live_… or test_…; bunq: its key
|
||||
bool sandbox = false; // bunq only: public-api.sandbox.bunq.com
|
||||
std::filesystem::path statePath; // bunq: session context; fake: paid marker
|
||||
std::string mode; // "off" | "fake" | "mollie" | "coingate"
|
||||
std::string apiKey; // mollie: live_… or test_…; coingate: its token
|
||||
bool sandbox = false; // coingate: api-sandbox.coingate.com
|
||||
std::filesystem::path statePath; // fake: the paid marker
|
||||
std::string redirectBase = "https://catcrafts.net";
|
||||
};
|
||||
|
||||
// nullptr for mode "off" — the shop then renders but refuses checkout.
|
||||
// nullptr for mode "off" — that slot then offers no payment choice.
|
||||
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config);
|
||||
|
||||
// The two slots a buyer chooses between. Either may be null, which is how
|
||||
// a shop with only one provider configured offers only that one: the
|
||||
// checkout form renders the choices that exist, so a page can never
|
||||
// advertise a way to pay the server would then refuse.
|
||||
struct PaymentRails {
|
||||
std::unique_ptr<PaymentRail> bank; // Mollie: iDEAL, cards, transfer
|
||||
std::unique_ptr<PaymentRail> crypto; // CoinGate: on-chain and Lightning
|
||||
|
||||
bool Any() const { return bank != nullptr || crypto != nullptr; }
|
||||
// The rail that owns a stored order, by its recorded choice. Total on
|
||||
// purpose: anything that is not the crypto choice is the bank one, so
|
||||
// a hand-edited or truncated ledger line resolves somewhere safe
|
||||
// instead of nowhere. Null when that slot is not configured, and the
|
||||
// caller must then leave the order alone rather than ask the other
|
||||
// provider about an id it never issued.
|
||||
PaymentRail* For(std::string_view choice) const {
|
||||
if (choice == Form::kPayCrypto) return crypto.get();
|
||||
return bank.get();
|
||||
}
|
||||
};
|
||||
|
||||
// Parsed essentials of a Mollie /v2/payments object. Exported so the
|
||||
// self-test can drive the parser with canned responses — the HTTP around
|
||||
// it is thin.
|
||||
|
|
@ -246,66 +277,135 @@ export namespace Catcrafts::Server {
|
|||
};
|
||||
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
|
||||
|
||||
// Exact decimal-string-to-minor-units parser for amounts coming back from
|
||||
// the bunq API ("614.00" -> 61400). Rejects anything that is not a plain
|
||||
// non-negative decimal with at most two fraction digits — no floats touch
|
||||
// money on the way in either. Exported for the self-test.
|
||||
// Parsed essentials of a CoinGate /api/v2/orders object. Same shape and
|
||||
// same reason as MolliePayment: the parser is the part worth testing.
|
||||
//
|
||||
// `id` is a JSON NUMBER on the wire ("id":538) rather than a string, so it
|
||||
// is rendered to decimal here and travels through the ledger as text like
|
||||
// every other payment id.
|
||||
struct CoingateOrder {
|
||||
std::string id;
|
||||
std::string status; // new|pending|confirming|paid|invalid|
|
||||
// expired|canceled|refunded|partially_refunded
|
||||
std::string payCurrency; // the coin the shopper picked; empty until then
|
||||
std::string payUrl; // the hosted invoice, present while payable
|
||||
std::int64_t priceMinor = 0; // price_amount, and only when EUR
|
||||
};
|
||||
std::optional<CoingateOrder> ParseCoingateOrder(std::string_view json);
|
||||
|
||||
// Exact decimal-string-to-minor-units parser for the amounts both provider
|
||||
// APIs quote as strings ("614.00" -> 61400). Rejects anything that is not
|
||||
// a plain non-negative decimal with at most two fraction digits — no
|
||||
// floats touch money on the way in either. Exported for the self-test.
|
||||
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
|
||||
|
||||
// ── shipping rates ────────────────────────────────────────────────
|
||||
//
|
||||
// Live per-country rates from Sendcloud's shipping_methods API, cached to
|
||||
// a state file and refreshed daily by a background thread. The compiled-in
|
||||
// zone table (Catcrafts.Shared:Content) remains the fallback for any country the carrier table
|
||||
// does not cover — and the whole feature when no credentials exist, so
|
||||
// the shop never depends on Sendcloud being up.
|
||||
// Live per-country, per-weight-bracket rates from Sendcloud's
|
||||
// shipping_methods API, cached to a state file and refreshed daily by a
|
||||
// background thread. This table is the SOLE source of shipping prices:
|
||||
// there is no compiled-in fallback, because a destination Sendcloud has no
|
||||
// rate for is a destination the shop cannot actually post a parcel to, and
|
||||
// inventing a price for it only sells an order that then has to be
|
||||
// refunded or absorbed.
|
||||
//
|
||||
// The consequence is deliberate and load-bearing: with no table, checkout
|
||||
// refuses everything. The disk cache is therefore the resilience layer
|
||||
// rather than an optimisation — it is written on every successful fetch,
|
||||
// read unconditionally at startup (credentials or not, which is also how
|
||||
// dev and e2e get a table), and a Sendcloud outage merely means the last
|
||||
// known prices keep selling.
|
||||
|
||||
struct ShippingConfig {
|
||||
std::string publicKey; // SENDCLOUD_PUBLIC_KEY
|
||||
std::string secretKey; // SENDCLOUD_SECRET_KEY
|
||||
std::string methodName; // substring match on the method name
|
||||
std::filesystem::path cachePath; // survives restarts
|
||||
std::filesystem::path cachePath; // survives restarts; also the dev seed
|
||||
};
|
||||
|
||||
// Country -> price in cents, EUR. Empty when nothing loaded.
|
||||
// Country -> weight-bracket ladder, prices in cents EUR, already grossed up
|
||||
// to consumer prices. Empty when nothing loaded, which means "cannot ship
|
||||
// anywhere" and is reported loudly at startup.
|
||||
struct ShippingTable {
|
||||
std::string method; // the matched Sendcloud method name
|
||||
std::string method; // the matched Sendcloud method name(s)
|
||||
std::string fetchedAt; // ISO 8601, for the operator
|
||||
std::vector<std::pair<std::string, std::int64_t>> perCountry;
|
||||
std::vector<Money::ShipRates> perCountry;
|
||||
|
||||
std::int64_t Find(std::string_view cc) const {
|
||||
for (const auto& [k, v] : perCountry) {
|
||||
if (k == cc) return v;
|
||||
}
|
||||
return 0;
|
||||
// The rate for a parcel of `grams`, or 0 when this table cannot ship it
|
||||
// to `cc` at all. The bracket rule lives in Money so the page's total
|
||||
// preview picks the identical bracket.
|
||||
std::int64_t Find(std::string_view cc, std::int64_t grams) const {
|
||||
return Money::RateFor(Money::LadderFor(perCountry, cc), grams);
|
||||
}
|
||||
|
||||
// Units of `unitGrams` that fit the heaviest bracket for `cc`; 0 when
|
||||
// the destination is uncovered. This is the real quantity ceiling.
|
||||
std::int64_t MaxUnits(std::string_view cc, std::int64_t unitGrams) const {
|
||||
return Money::MaxUnitsFor(Money::LadderFor(perCountry, cc), unitGrams);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse a Sendcloud /api/v2/shipping_methods response into a table, taking
|
||||
// the first method whose name contains `methodName` (case-sensitive).
|
||||
// Parse a Sendcloud /api/v2/shipping_methods response into a table.
|
||||
// Exported for the self-test — the network fetch is thin around this.
|
||||
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName);
|
||||
|
||||
// Install the config and start using it. Safe to skip entirely.
|
||||
// Install the config and start using it. Loads the cache even without
|
||||
// credentials, so a hand-placed cache file is a complete rate table.
|
||||
void ConfigureShipping(const ShippingConfig& config);
|
||||
|
||||
// One fetch attempt; failure leaves the previous table standing. The HTTP
|
||||
// layer's background thread calls this on start and daily after.
|
||||
void RefreshShippingTable();
|
||||
|
||||
// The rate the checkout charges for `country`: the live table's price if
|
||||
// present, the product's zone fallback otherwise.
|
||||
std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
|
||||
std::int64_t zoneEu, std::int64_t zoneWorld);
|
||||
// The rate the checkout charges to send `grams` to `country`, or nullopt
|
||||
// when no bracket covers it — the caller must then refuse the order rather
|
||||
// than substitute a number.
|
||||
std::optional<std::int64_t> ShipCostFor(std::string_view country,
|
||||
std::int64_t grams);
|
||||
|
||||
// A snapshot of the live table for embedding into the checkout preview —
|
||||
// the page must show the same numbers the server will charge.
|
||||
ShippingTable CurrentShippingTable();
|
||||
|
||||
// Install the rail used by Serve()'s checkout handler and reconciler.
|
||||
// Call before Serve. Passing nullptr disables checkout. (Rates travel with
|
||||
// the content — LoadContent reads rates.json.)
|
||||
void ConfigurePayments(std::unique_ptr<PaymentRail> rail, std::string redirectBase);
|
||||
// Install the rails used by Serve()'s checkout handler and reconciler.
|
||||
// Call before Serve. Two null rails disables checkout entirely. (Rates
|
||||
// travel with the content — LoadContent reads rates.json.)
|
||||
void ConfigurePayments(PaymentRails rails, std::string redirectBase);
|
||||
|
||||
// Whether the crypto slot is live, for the renderer: the checkout form
|
||||
// offers the crypto choice only when something can actually serve it.
|
||||
bool CryptoPaymentAvailable();
|
||||
|
||||
// ── request provenance ────────────────────────────────────────────
|
||||
//
|
||||
// Two questions a reverse-proxied process has to answer carefully, both
|
||||
// pure string work, both exported for the self-test.
|
||||
|
||||
// The client address as the reverse proxy saw it, from X-Forwarded-For.
|
||||
//
|
||||
// Caddy APPENDS the real peer to whatever X-Forwarded-For the client sent,
|
||||
// so the value reads "<anything the client claimed>, <real peer>" and only
|
||||
// the RIGHTMOST entry is trustworthy. Taking the leftmost — the usual
|
||||
// mistake — would hand every client an unlimited supply of free rate-limit
|
||||
// identities, which is worse than not limiting at all.
|
||||
//
|
||||
// Trustworthy only because nothing but Caddy can reach this listener: it
|
||||
// binds loopback and the Caddyfile says in as many words not to expose the
|
||||
// port. Empty in, empty out — no header means nothing proxied this request
|
||||
// (dev, e2e, a direct curl), and the caller must fall back to the global
|
||||
// limit rather than invent a peer.
|
||||
std::string_view ClientAddressFromForwarded(std::string_view forwarded);
|
||||
|
||||
// Whether a state-changing POST may proceed, given its Origin header.
|
||||
//
|
||||
// A MISSING Origin is allowed: browsers have sent it on form POSTs for
|
||||
// years, so a request without one is a non-browser client (curl, the e2e
|
||||
// suite), and a non-browser client cannot be a cross-site forgery — there
|
||||
// is no victim's session to ride on. A PRESENT but mismatched Origin is
|
||||
// precisely the forgery case, and that is refused. "null" — a sandboxed
|
||||
// iframe or a privacy-stripped origin — is refused too: it is present, and
|
||||
// it is not us.
|
||||
bool OriginAllowed(std::string_view origin, std::string_view redirectBase);
|
||||
|
||||
// Bind and serve until killed. Blocks. Starts the payment reconciler
|
||||
// thread when a rail is configured.
|
||||
|
|
|
|||
Loading…
Reference in a new issue