All checks were successful
Deploy / build-deploy (push) Successful in 3m11s
629 lines
27 KiB
C++
629 lines
27 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 bunq credit source: reads the shop's OWN bank account and reports the
|
|
// incoming credits, which is all the transfer rail needs to settle an order.
|
|
//
|
|
// This is a READER. It never moves money, and that asymmetry is the only
|
|
// reason it can exist at all — see the key-policy note below, which is the
|
|
// most important thing in this file.
|
|
//
|
|
// Descended from the bunq PAYMENT rail this repo carried until 2026-08-14
|
|
// (deleted in 70668af): the four-call onboarding dance, the RSA body signing
|
|
// and the context file are that code, kept because they were already proven
|
|
// against the live API. What changed is the question asked at the end. The old
|
|
// rail created bunq.me tabs and polled them; that route is closed to a webshop
|
|
// on volume grounds (a €1,500/week ceiling across all of bunq.me, and no way
|
|
// to disable its 2.5% card path), so this one reads the account's payment list
|
|
// instead and lets the reference do the matching.
|
|
//
|
|
// 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) -> which account to read
|
|
// payment (per poll) -> the credits
|
|
//
|
|
// State persists in ONE json file so the first four never repeat: bunq allows
|
|
// as few as TEN calls PER DAY to the setup endpoints, so a client that
|
|
// re-onboarded on every start would lock itself out by lunchtime. Delete the
|
|
// file and it re-onboards from the API key.
|
|
//
|
|
// ── THE KEY POLICY, which this file cannot enforce on its own ─────────
|
|
//
|
|
// A bunq API key can INITIATE PAYMENTS. bunq offers no read-only scope, so
|
|
// there is no such thing as a key that can only do what this file does. The
|
|
// standing rule for this project is therefore that the bunq key does NOT live
|
|
// on the internet-facing box.
|
|
//
|
|
// That rule and this code are compatible, but only in one deployment shape:
|
|
// run this as `catcrafts-server --pull-credits` on a trusted machine, on a
|
|
// timer, and ship the resulting credits file to the server, which reads it
|
|
// with the file source and holds no key at all. The server then cannot be made
|
|
// to move money even if it is fully compromised.
|
|
//
|
|
// Configuring BUNQ_API_KEY on the server itself also works and is one less
|
|
// moving part, but it puts a payment-capable credential on a public host. The
|
|
// startup path says so out loud rather than letting it pass unnoticed.
|
|
|
|
module;
|
|
#include <openssl/bio.h>
|
|
#include <openssl/evp.h>
|
|
#include <openssl/pem.h>
|
|
module Catcrafts.Server;
|
|
|
|
import std;
|
|
import Catcrafts.Shared;
|
|
import Crafter.Network;
|
|
|
|
namespace Catcrafts::Server {
|
|
|
|
namespace {
|
|
|
|
std::string Base64B(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 EscB(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 RandomHexB(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;
|
|
}
|
|
|
|
// bunq wraps everything: {"Response":[{"Id":{…}},{"Token":{…}}]}. Find the
|
|
// first object under `key` anywhere in that array.
|
|
const Json::Value* InResponse(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> ParseSignedAmountToMinor(std::string_view s) {
|
|
// bunq quotes an OUTGOING payment as a negative value ("-25.00"), and the
|
|
// shared money parser rejects a sign by design — it guards amounts we
|
|
// choose, where a minus would be nonsense. Here the sign is information:
|
|
// it is what separates a customer paying us from us paying a supplier, and
|
|
// dropping it would let a refund look like income. So the sign is peeled
|
|
// off here and the magnitude goes through the strict parser unchanged.
|
|
bool negative = false;
|
|
if (!s.empty() && (s.front() == '-' || s.front() == '+')) {
|
|
negative = s.front() == '-';
|
|
s.remove_prefix(1);
|
|
}
|
|
const std::optional<std::int64_t> magnitude = ParseAmountToMinor(s);
|
|
if (!magnitude) return std::nullopt;
|
|
return negative ? -*magnitude : *magnitude;
|
|
}
|
|
|
|
std::string_view BunqMethodFor(std::string_view paymentType) {
|
|
// bunq's Payment.type, mapped to the ledger's `via` vocabulary. The
|
|
// distinction earns its keep at dispatch time: a SEPA credit transfer is
|
|
// final, while a card payment can be reversed for months, so these are not
|
|
// interchangeable labels for "money arrived".
|
|
if (paymentType == "EBA_SCT") return "sepa";
|
|
if (paymentType == "IDEAL") return "ideal";
|
|
if (paymentType == "FIS") return "card";
|
|
if (paymentType == "BUNQ") return "bunq";
|
|
if (paymentType == "SWIFT") return "swift";
|
|
if (paymentType == "EBA_SDD") return "directdebit";
|
|
// An unknown type still settles — the money is on the account either way —
|
|
// but it reaches the ledger verbatim so the `via` column shows what bunq
|
|
// actually said instead of a comfortable guess.
|
|
return paymentType.empty() ? std::string_view("bank") : paymentType;
|
|
}
|
|
|
|
std::vector<BankCredit> ParseBunqPayments(std::string_view json) {
|
|
std::vector<BankCredit> out;
|
|
const auto doc = Json::Parse(json);
|
|
if (!doc || !doc->IsObject()) return out;
|
|
const Json::Value* resp = doc->Find("Response");
|
|
if (!resp || !resp->IsArray()) return out;
|
|
for (const Json::Value& item : resp->array) {
|
|
if (!item.IsObject()) continue;
|
|
const Json::Value* p = item.Find("Payment");
|
|
if (!p || !p->IsObject()) continue;
|
|
const Json::Value* amount = p->Find("amount");
|
|
if (!amount || !amount->IsObject()) continue;
|
|
// Only euro amounts can pay a euro order. A foreign-currency credit is
|
|
// skipped rather than counted at face value, which would silently
|
|
// treat 25 of something else as 25 euro.
|
|
if (amount->Str("currency") != "EUR") continue;
|
|
const std::optional<std::int64_t> minor =
|
|
ParseSignedAmountToMinor(amount->Str("value"));
|
|
if (!minor) continue;
|
|
BankCredit c;
|
|
c.id = std::format("{}", p->Int("id"));
|
|
c.reference = std::string(p->Str("description"));
|
|
c.amountMinor = *minor;
|
|
c.method = std::string(BunqMethodFor(p->Str("type")));
|
|
out.push_back(std::move(c));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::optional<BankCredit> ParseBunqCallback(std::string_view json) {
|
|
const auto doc = Json::Parse(json);
|
|
if (!doc || !doc->IsObject()) return std::nullopt;
|
|
const Json::Value* note = doc->Find("NotificationUrl");
|
|
if (!note || !note->IsObject()) return std::nullopt;
|
|
const Json::Value* object = note->Find("object");
|
|
if (!object || !object->IsObject()) return std::nullopt;
|
|
const Json::Value* p = object->Find("Payment");
|
|
if (!p || !p->IsObject()) return std::nullopt;
|
|
|
|
const Json::Value* amount = p->Find("amount");
|
|
if (!amount || !amount->IsObject()) return std::nullopt;
|
|
// Only euro can pay a euro order; see ParseBunqPayments for why a
|
|
// foreign-currency credit is skipped rather than counted at face value.
|
|
if (amount->Str("currency") != "EUR") return std::nullopt;
|
|
const std::optional<std::int64_t> minor =
|
|
ParseSignedAmountToMinor(amount->Str("value"));
|
|
if (!minor) return std::nullopt;
|
|
|
|
BankCredit c;
|
|
c.id = std::format("{}", p->Int("id"));
|
|
c.reference = std::string(p->Str("description"));
|
|
c.amountMinor = *minor;
|
|
c.method = std::string(BunqMethodFor(p->Str("type")));
|
|
// An id is what deduplication rests on. A callback without one cannot be
|
|
// deduplicated, so accepting it would let a retry credit the same money
|
|
// twice — refuse instead.
|
|
if (c.id.empty() || c.id == "0") return std::nullopt;
|
|
return c;
|
|
}
|
|
|
|
namespace {
|
|
|
|
class BunqCreditSource final : public CreditSource {
|
|
public:
|
|
BunqCreditSource(std::string apiKey, std::filesystem::path statePath,
|
|
std::string permittedIps, int count, std::string wantIban)
|
|
: apiKey_(std::move(apiKey)), statePath_(std::move(statePath)),
|
|
permittedIps_(std::move(permittedIps)), count_(count),
|
|
wantIban_(std::move(wantIban)) {}
|
|
|
|
std::optional<std::vector<BankCredit>> Recent() override {
|
|
std::lock_guard lock(mutex_);
|
|
if (!EnsureSession()) return std::nullopt;
|
|
// One page is enough and more would be worse: the reconciler only ever
|
|
// asks about orders inside their payment window, so a credit old enough
|
|
// to fall off this page is old enough to be settled by hand anyway.
|
|
// Paging the whole account history every minute would also spend the
|
|
// per-method rate limit on data nothing reads.
|
|
const auto doc = Call("GET", std::format(
|
|
"/v1/user/{}/monetary-account/{}/payment?count={}", userId_, accountId_,
|
|
count_), {});
|
|
if (!doc) return std::nullopt;
|
|
return ParseBunqPayments(raw_);
|
|
}
|
|
|
|
std::string_view Name() const override { return "bunq"; }
|
|
|
|
private:
|
|
// ── state, so onboarding happens once ─────────────────────────────
|
|
|
|
void LoadState() {
|
|
std::ifstream in(statePath_, std::ios::binary);
|
|
if (!in) return;
|
|
std::ostringstream buf;
|
|
buf << in.rdbuf();
|
|
const 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() {
|
|
std::ofstream out(statePath_, std::ios::trunc | std::ios::binary);
|
|
if (!out) {
|
|
std::println(std::cerr, "bunq: cannot write context {}", statePath_.string());
|
|
return false;
|
|
}
|
|
out << std::format(
|
|
R"({{"private_key_pem":"{}","installation_token":"{}",)"
|
|
R"("device_registered":{},"session_token":"{}","user_id":{},)"
|
|
R"("account_id":{}}})",
|
|
EscB(privateKeyPem_), EscB(installationToken_), deviceRegistered_,
|
|
EscB(sessionToken_), userId_, accountId_);
|
|
out.flush();
|
|
// The file holds a private key. Narrow it even though the directory
|
|
// should already be private: defence in depth costs one syscall.
|
|
std::error_code ec;
|
|
std::filesystem::permissions(statePath_,
|
|
std::filesystem::perms::owner_read
|
|
| std::filesystem::perms::owner_write,
|
|
ec);
|
|
return static_cast<bool>(out);
|
|
}
|
|
|
|
// ── the keypair bunq's installation call demands ──────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
// bunq stopped REQUIRING body signatures years ago, but the keypair has to
|
|
// exist for installation anyway and a signed request is valid whether or
|
|
// not the server checks, so every body is signed.
|
|
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 = Base64B(sig);
|
|
} while (false);
|
|
EVP_MD_CTX_free(ctx);
|
|
return out;
|
|
}
|
|
|
|
// ── transport ─────────────────────────────────────────────────────
|
|
|
|
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>(
|
|
kHost, static_cast<std::uint16_t>(443),
|
|
Crafter::TLSClientCredentials{});
|
|
}
|
|
Crafter::HTTPRequest req;
|
|
req.method = std::string(method);
|
|
req.path = path;
|
|
req.authority = kHost;
|
|
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"] = RandomHexB(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') {
|
|
// 429 gets named, because the cure is different from every
|
|
// other failure: bunq allows only a few reads per second per
|
|
// method, and setup endpoints as few as ten per DAY.
|
|
if (res.status == "429") {
|
|
std::println(std::cerr,
|
|
"bunq: {} {} -> 429 rate limited; reads are capped at "
|
|
"a few per second and setup calls at ~10/day, so back "
|
|
"off rather than retrying in a loop", method, path);
|
|
} else {
|
|
std::println(std::cerr, "bunq: {} {} -> {} {}", method, path,
|
|
res.status, res.body.substr(0, 200));
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
// Kept so the caller can re-parse into domain types without this
|
|
// layer knowing about them.
|
|
raw_ = res.body;
|
|
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 call, with ONE automatic re-session on 401. Sessions expire
|
|
// server-side on a schedule the bunq app controls, so expiry is routine
|
|
// and must not surface as a payment 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, each step at most once ────────────────────────────
|
|
|
|
bool EnsureSession() {
|
|
if (!loaded_) { LoadState(); loaded_ = true; }
|
|
if (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":"{}"}})",
|
|
EscB(PublicKeyPem()));
|
|
const auto doc = DoCall("POST", "/v1/installation", body, {});
|
|
if (!doc) return false;
|
|
const Json::Value* token = InResponse(*doc, "Token");
|
|
if (!token) return false;
|
|
installationToken_ = std::string(token->Str("token"));
|
|
if (installationToken_.empty()) return false;
|
|
SaveState();
|
|
}
|
|
|
|
if (!deviceRegistered_) {
|
|
// permitted_ips decides what a leaked key is worth. bunq has no
|
|
// read-only scope, so this key can move money: pinning it to the
|
|
// one address that should ever use it is the difference between a
|
|
// leak being survivable and being catastrophic. "*" is accepted
|
|
// but announced, because silently unpinning a payment-capable
|
|
// credential is exactly the kind of default nobody revisits.
|
|
if (permittedIps_ == "*") {
|
|
std::println(std::cerr,
|
|
"bunq: registering this device with permitted_ips=* — a "
|
|
"leaked key would then work from anywhere. Set "
|
|
"BUNQ_PERMITTED_IPS to this machine's egress address to "
|
|
"pin it (the registration is once-only, so changing it "
|
|
"later means deleting the context file).");
|
|
}
|
|
std::string ipList;
|
|
for (const auto part : std::views::split(permittedIps_, ',')) {
|
|
const std::string_view ip(part.begin(), part.end());
|
|
if (ip.empty()) continue;
|
|
if (!ipList.empty()) ipList += ",";
|
|
ipList += std::format("\"{}\"", EscB(ip));
|
|
}
|
|
if (ipList.empty()) ipList = "\"*\"";
|
|
const std::string body = std::format(
|
|
R"({{"description":"catcrafts.net credit reader","secret":"{}",)"
|
|
R"("permitted_ips":[{}]}})", EscB(apiKey_), ipList);
|
|
const 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":"{}"}})", EscB(apiKey_));
|
|
const auto doc = DoCall("POST", "/v1/session-server", body,
|
|
installationToken_);
|
|
if (!doc) return false;
|
|
const Json::Value* token = InResponse(*doc, "Token");
|
|
if (!token) return false;
|
|
sessionToken_ = std::string(token->Str("token"));
|
|
// Which user object comes back depends on the account type, so try
|
|
// each rather than assuming this is a company account.
|
|
for (const std::string_view k : { "UserPerson", "UserCompany", "UserApiKey" }) {
|
|
if (const Json::Value* u = InResponse(*doc, k)) {
|
|
userId_ = u->Int("id");
|
|
break;
|
|
}
|
|
}
|
|
if (sessionToken_.empty() || userId_ == 0) return false;
|
|
SaveState();
|
|
}
|
|
|
|
if (accountId_ == 0) {
|
|
const 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;
|
|
// With more than one account, guessing is how the shop ends up
|
|
// reconciling against savings. Name the wanted one by IBAN when
|
|
// there is a choice, and refuse rather than pick.
|
|
std::vector<std::pair<std::int64_t, std::string>> active;
|
|
for (const Json::Value& item : resp->array) {
|
|
const Json::Value* acc = item.Find("MonetaryAccountBank");
|
|
if (!acc || !acc->IsObject()) continue;
|
|
if (acc->Str("status") != "ACTIVE") continue;
|
|
std::string iban;
|
|
if (const Json::Value* aliases = acc->Find("alias");
|
|
aliases && aliases->IsArray()) {
|
|
for (const Json::Value& a : aliases->array) {
|
|
if (a.IsObject() && a.Str("type") == "IBAN") {
|
|
iban = std::string(a.Str("value"));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
active.emplace_back(acc->Int("id"), std::move(iban));
|
|
}
|
|
if (active.empty()) {
|
|
std::println(std::cerr, "bunq: no active account found");
|
|
return false;
|
|
}
|
|
if (!wantIban_.empty()) {
|
|
for (const auto& [id, iban] : active) {
|
|
if (Matches(iban, wantIban_)) { accountId_ = id; break; }
|
|
}
|
|
if (accountId_ == 0) {
|
|
std::println(std::cerr,
|
|
"bunq: no active account matches TRANSFER_IBAN; the "
|
|
"key sees {} account(s)", active.size());
|
|
return false;
|
|
}
|
|
} else if (active.size() == 1) {
|
|
accountId_ = active.front().first;
|
|
} else {
|
|
std::println(std::cerr,
|
|
"bunq: this key sees {} active accounts and no "
|
|
"TRANSFER_IBAN was given to choose between them — "
|
|
"refusing to guess which one the shop is paid into",
|
|
active.size());
|
|
return false;
|
|
}
|
|
SaveState();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// IBANs are compared ignoring spacing and case: what a human pastes into
|
|
// configuration and what the API returns differ cosmetically far more
|
|
// often than they differ in substance.
|
|
static bool Matches(std::string_view a, std::string_view b) {
|
|
const auto fold = [](std::string_view s) {
|
|
std::string out;
|
|
for (const char c : s) {
|
|
if (c == ' ' || c == '\t') continue;
|
|
out += static_cast<char>(c >= 'a' && c <= 'z' ? c - 'a' + 'A' : c);
|
|
}
|
|
return out;
|
|
};
|
|
return !a.empty() && fold(a) == fold(b);
|
|
}
|
|
|
|
struct PkeyDeleter {
|
|
void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); }
|
|
};
|
|
|
|
static constexpr const char* kHost = "api.bunq.com";
|
|
|
|
std::string apiKey_;
|
|
std::filesystem::path statePath_;
|
|
std::string permittedIps_;
|
|
int count_ = 50;
|
|
std::string wantIban_;
|
|
|
|
std::mutex mutex_;
|
|
std::unique_ptr<Crafter::ClientHTTP1> client_;
|
|
std::unique_ptr<EVP_PKEY, PkeyDeleter> key_;
|
|
std::string raw_;
|
|
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
|
|
|
|
std::unique_ptr<CreditSource> MakeBunqCreditSource(const BunqConfig& config) {
|
|
if (config.apiKey.empty()) return nullptr;
|
|
if (config.statePath.empty()) {
|
|
std::println(std::cerr, "bunq: no context path given");
|
|
return nullptr;
|
|
}
|
|
return std::make_unique<BunqCreditSource>(
|
|
config.apiKey, config.statePath,
|
|
config.permittedIps.empty() ? std::string("*") : config.permittedIps,
|
|
config.count > 0 ? config.count : 50, config.iban);
|
|
}
|
|
|
|
} // namespace Catcrafts::Server
|