bank tranfer fix
All checks were successful
Deploy / build-deploy (push) Successful in 3m11s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 23:33:50 +02:00
commit aaa7a8ce99
10 changed files with 543 additions and 8 deletions

View file

@ -192,6 +192,37 @@ std::vector<BankCredit> ParseBunqPayments(std::string_view json) {
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 {

View file

@ -75,6 +75,13 @@ std::string gCssHref = "/styles.css";
PaymentRails gRails;
std::string gRedirectBase = "https://catcrafts.net";
// Where the bunq callback writes, and the secret path that authorises it.
// Both empty means the endpoint does not exist at all — an unconfigured
// webhook must not answer, or the shop would carry a public write-ish endpoint
// nobody asked for.
std::filesystem::path gCreditsPath;
std::string gWebhookPath;
// The bank-derived aggregates for /financials live in Catcrafts.Server-
// Financials.cpp, which owns their file. They are read through
// CurrentFinancials() per request rather than cached: unlike the content
@ -637,6 +644,65 @@ std::string NowRfc2822() {
// the POST/redirect/GET pattern, and it matters for a real form: a rendered
// POST response means reloading re-submits, and the back button re-posts. The
// redirect leaves the browser on a GET it can safely repeat.
// The bunq callback. See ParseBunqCallback for the security model; the short
// version is that bunq does NOT sign these, so this endpoint's only defences
// are the ones around it: a secret path segment, a source-IP allowlist in
// Caddy for bunq's published range, and the fact that no parcel leaves without
// a human. It therefore does the least it possibly can — decode one payment
// and append it to the credits file — and makes no settlement decision at all.
// The reconciler settles from that file exactly as it does from a pulled one,
// so a forged callback can at worst manufacture a credit line, never bypass
// the reference match or the covering-amount rule.
HTTPResponse HandleBunqCallback(const HTTPRequest& req) {
HTTPResponse res;
res.headers["content-type"] = "text/plain; charset=utf-8";
// Never cache, never index, and say nothing useful in the body: this URL
// is a shared secret, so every answer is the same two characters.
res.headers["cache-control"] = "no-store";
res.headers["x-robots-tag"] = "noindex, nofollow";
if (req.method != "POST") {
res.status = "405";
res.headers["allow"] = "POST";
res.body = "no\n";
return res;
}
const std::optional<BankCredit> credit = ParseBunqCallback(req.body);
if (!credit) {
// A callback shape this cannot decode is NOT an error to shout about
// with a 4xx: bunq sends several notification categories, and only
// some carry a Payment. Answer 200 so bunq stops retrying something
// that will never decode, and log it so a genuinely new shape is
// visible rather than silently dropped.
std::println(std::cerr, "bunq callback: no usable payment in body ({} bytes)",
req.body.size());
res.status = "200";
res.body = "ok\n";
return res;
}
// Outgoing money cannot pay for an order, and the matcher ignores it
// anyway — so refuse to write the shop's own supplier payments into a file
// that lives on a public-facing host.
if (credit->amountMinor <= 0) {
res.status = "200";
res.body = "ok\n";
return res;
}
if (AppendCreditTo(gCreditsPath, *credit)) {
std::println(std::cerr, "bunq callback: credited {} via {} (id {})",
Money::FormatMinor(credit->amountMinor), credit->method,
credit->id);
}
// 200 even on a duplicate or a write we skipped: a duplicate IS success
// from bunq's side, and making it retry would achieve nothing.
res.status = "200";
res.body = "ok\n";
return res;
}
HTTPResponse HandleCheckout(const HTTPRequest& req, const Route& route) {
HTTPResponse res;
@ -1064,6 +1130,29 @@ void ConfigurePayments(PaymentRails rails, std::string redirectBase) {
if (!redirectBase.empty()) gRedirectBase = std::move(redirectBase);
}
void ConfigureBunqCallback(std::filesystem::path creditsPath, std::string secretPath) {
// Both or neither. A secret with nowhere to write, or a path to write with
// no secret guarding it, are each worse than not having the endpoint: the
// first answers requests it cannot act on, the second answers everyone.
if (creditsPath.empty() || secretPath.empty()) return;
// A short secret is not a secret. Refuse rather than serve a guessable
// write endpoint just because someone set the variable to "hook".
if (secretPath.size() < 24) {
std::println(std::cerr,
"bunq callback: BUNQ_WEBHOOK_PATH is too short to be secret "
"({} chars, want 24+) — the endpoint is NOT enabled",
secretPath.size());
return;
}
if (secretPath.front() != '/') secretPath.insert(secretPath.begin(), '/');
gCreditsPath = std::move(creditsPath);
gWebhookPath = std::move(secretPath);
// Deliberately does NOT log the path: it would land in the journal, and
// from there in any log shipping or analytics that reads it.
std::println(std::cerr, "bunq callback: enabled on a secret path ({} chars)",
gWebhookPath.size());
}
bool CryptoPaymentAvailable() { return gRails.crypto != nullptr; }
bool BankPaymentAvailable() { return gRails.bank != nullptr; }
@ -1280,6 +1369,31 @@ int Serve(std::uint16_t port) {
};
auto fallback = [](const HTTPRequest& req) -> HTTPResponse {
// The bunq callback, before anything else looks at the path. It is
// matched here rather than added to the shared route table on purpose:
// ParseRoute is shared with the wasm frontend, and a secret URL has no
// business being compiled into a bundle served to browsers.
//
// Compared in CONSTANT TIME. This is a shared secret in a URL, so a
// timing oracle on a byte-by-byte compare would let it be recovered
// one character at a time — and unlike a password there is no rate
// limit or lockout behind it.
// POST only, and non-POST deliberately falls through to ordinary page
// handling rather than answering 405. A distinctive answer here would
// be an oracle: a GET returning 405 where every other unknown URL
// returns 404 confirms a guessed path is the right one, which is
// exactly the signal a secret-in-the-URL scheme cannot afford to give.
// bunq only ever POSTs, so nothing legitimate is lost.
if (!gWebhookPath.empty() && req.method == "POST") {
const std::string_view path = PathWithoutQueryHTTP(req.path);
if (path.size() == gWebhookPath.size()) {
unsigned char diff = 0;
for (std::size_t i = 0; i < path.size(); ++i) {
diff |= static_cast<unsigned char>(path[i] ^ gWebhookPath[i]);
}
if (diff == 0) return HandleBunqCallback(req);
}
}
// A POST to a product page is a checkout submission.
if (req.method == "POST") {
const Route route = ParseRoute(PathWithoutQueryHTTP(req.path));

View file

@ -369,6 +369,37 @@ std::unique_ptr<CreditSource> MakeFileCreditSource(std::filesystem::path path) {
return std::make_unique<FileCreditSource>(std::move(path));
}
bool AppendCreditTo(const std::filesystem::path& creditsPath,
const BankCredit& credit) {
if (credit.id.empty()) return false;
// Deduplicate on the bank's own payment id. bunq retries a callback about
// six times, and a periodic --pull-credits reads an overlapping window, so
// the SAME payment arrives more than once by design. Appending it twice
// would double the money against one reference — enough, on a part-paid
// order, to settle it without the balance ever arriving.
{
std::ifstream in(creditsPath, std::ios::binary);
std::string line;
while (std::getline(in, line)) {
if (line.empty()) continue;
const auto doc = Json::Parse(line);
if (!doc || !doc->IsObject()) continue;
if (doc->Str("id") == credit.id) return false;
}
}
std::ofstream out(creditsPath, std::ios::app | std::ios::binary);
if (!out) {
std::println(std::cerr, "transfer: cannot append to {}", creditsPath.string());
return false;
}
out << std::format(
R"({{"id":"{}","reference":"{}","amount_minor":{},"method":"{}"}})" "\n",
EscT(credit.id), EscT(credit.reference), credit.amountMinor,
EscT(credit.method));
out.flush();
return static_cast<bool>(out);
}
std::optional<int> PullCreditsInto(CreditSource& source,
const std::filesystem::path& creditsPath) {
const std::optional<std::vector<BankCredit>> fresh = source.Recent();

View file

@ -406,12 +406,28 @@ int main(int argc, char** argv) {
// warning is loud, the listening line below reports
// crypto=off, and tools/enable-eurc.sh refuses to call an
// enable successful without the rail's own load line.
std::println(std::cerr,
"catcrafts-server: WARNING: the '{}' rail could not load "
"its chains file ({}) or address pool ({}) — see above. "
"CONTINUING WITHOUT IT: that payment choice is off and "
"the rest of the site is unaffected.",
mode, eurcChainsPath.string(), eurcPoolPath.string());
// Name the right files. This message used to describe EURC's
// chains file and address pool whichever rail had failed,
// which is actively misleading for the transfer rail: the
// likeliest way IT fails is a bad BUNQ_API_KEY, and being
// told to look at a chains file sends the reader away from
// the actual cause.
if (mode == "transfer") {
std::println(std::cerr,
"catcrafts-server: WARNING: the 'transfer' rail could "
"not load — see above. Usual causes: TRANSFER_IBAN or "
"TRANSFER_BENEFICIARY unset, or a BUNQ_API_KEY that "
"bunq refused. CONTINUING WITHOUT IT: bank transfer is "
"off, so checkout offers only the other rail, and the "
"rest of the site is unaffected.");
} else {
std::println(std::cerr,
"catcrafts-server: WARNING: the '{}' rail could not load "
"its chains file ({}) or address pool ({}) — see above. "
"CONTINUING WITHOUT IT: that payment choice is off and "
"the rest of the site is unaffected.",
mode, eurcChainsPath.string(), eurcPoolPath.string());
}
out.reset();
return true;
}
@ -425,6 +441,12 @@ int main(int argc, char** argv) {
if (!build(cryptoMode, rails.crypto)) return 2;
Server::ConfigurePayments(std::move(rails), redirectBase);
// The bunq callback, which exists because the API key's IP allowlist
// forbids this host from asking bunq anything. Off unless a secret path
// is configured.
if (const char* v = std::getenv("BUNQ_WEBHOOK_PATH"); v && *v) {
Server::ConfigureBunqCallback(transferCreditsPath, v);
}
// Invoice signing: the GPG key uid/fingerprint; GNUPGHOME decides the
// keyring. Unset means unsigned dev invoices with a visible marker.
@ -496,7 +518,14 @@ int main(int argc, char** argv) {
if (const char* v = std::getenv("BUNQ_STATE"); v && *v) {
statePath = v;
} else {
statePath = ordersPath;
// Hung off the CREDITS file, not the orders file, because that
// is what MakeRail does — and the two MUST agree. They did not
// at first, and the cost is not cosmetic: a machine that ran
// both this command and the server would onboard twice against
// bunq, and bunq allows as few as TEN setup calls per DAY. Two
// conventions for one file is a way to spend that budget on
// nothing.
statePath = creditsPath;
statePath += ".bunq-context.json";
}
}
@ -562,7 +591,18 @@ int main(int argc, char** argv) {
std::println(std::cerr, "could not append to {}", file.string());
return 1;
}
if (status == "paid") Server::AssignInvoiceNumber(token, now);
// Never for a donation, exactly as the automatic paid transition
// refuses: nothing was supplied, so there is no invoice, and a
// number burned on one leaves a gap-shaped question in a
// customer's series. This manual path was missing the guard and
// minted a number for the donation CC-16083E on 2026-08-20 while
// settling a stuck test payment by hand. That number is spent and
// an append-only ledger cannot recall it, which is exactly why
// the check belongs on every path that can transition to paid,
// not only the one the reconciler takes.
if (status == "paid" && !order->donation) {
Server::AssignInvoiceNumber(token, now);
}
std::println("{}: {} -> {}", order->reference, order->status, status);
return 0;
};

View file

@ -602,6 +602,43 @@ export namespace Catcrafts::Server {
// "sepa" is final, "card" can be reversed for months.
std::string_view BunqMethodFor(std::string_view paymentType);
// ── the bunq callback (webhook) ───────────────────────────────────
//
// bunq pushes a notification when the account changes, which is the only
// way this shop learns about money when the API key's IP allowlist forbids
// the web host from asking. Shape:
//
// {"NotificationUrl":{"category":"MUTATION",
// "object":{"Payment":{…the same Payment object…}}}}
//
// SECURITY, stated plainly because the design depends on understanding it:
// **bunq does not sign these.** Verified against doc.bunq.com — no HMAC, no
// server signature; certificate pinning authenticates US to bunq, not bunq
// to us. So the body is an unauthenticated claim that money arrived, and
// the only things standing between it and the ledger are transport-level:
// a source-IP allowlist for bunq's published range (185.40.108.0/22, which
// bunq warns may change), a secret path segment, and the fact that nothing
// ships without a human. Treat a callback as evidence exactly as strong as
// those controls, and keep a periodic --pull-credits as the backstop:
// bunq retries roughly six times and then drops the notification forever,
// so a backend that was down during a deploy loses that payment silently.
std::optional<BankCredit> ParseBunqCallback(std::string_view json);
// Append one credit to the file the transfer rail reads, unless an entry
// with the same id is already there. Returns false on a write failure or a
// duplicate — the caller answers the webhook 200 either way, because a
// duplicate is a SUCCESS from bunq's point of view and retrying it would
// achieve nothing.
bool AppendCreditTo(const std::filesystem::path& creditsPath,
const BankCredit& credit);
// Enable the bunq callback endpoint. Both arguments are required and the
// secret must be at least 24 characters, or the endpoint stays off — see
// ParseBunqCallback for why the path IS the authentication here, and why
// that is only acceptable alongside Caddy's source-IP allowlist.
void ConfigureBunqCallback(std::filesystem::path creditsPath,
std::string secretPath);
// One pull: read the account and append every credit not already in the
// file to it, newest last. Returns the number appended, or nullopt if the
// bank could not be reached. This is what `--pull-credits` runs, and it is