562 lines
23 KiB
C++
562 lines
23 KiB
C++
/*
|
|
catcrafts.net
|
|
Copyright (C) 2026 Catcrafts
|
|
|
|
The source code of this website is made available for viewing purposes only.
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
*/
|
|
|
|
// 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
|