/* 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 #include #include #include #include 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 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(c) < 0x20) { out += std::format("\\u{:04x}", static_cast(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 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 CreateLink(std::int64_t, const std::string&, const std::string& redirectUrl) override { static std::atomic 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 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 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 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(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(len)); BIO_free(bio); return SaveState(); } bool LoadKey() { if (key_) return true; BIO* bio = BIO_new_mem_buf(privateKeyPem_.data(), static_cast(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(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(body.data()), body.size()) != 1) break; std::vector sig(len); if (EVP_DigestSign(ctx, sig.data(), &len, reinterpret_cast(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 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( host_, static_cast(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 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 client_; std::unique_ptr 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 MakeFakeRail(const RailConfig& config) { return std::make_unique(config.statePath); } std::unique_ptr MakeBunqRail(const RailConfig& config) { return std::make_unique(config); } } // namespace Catcrafts::Server