/* 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. */ // Decoding bunq's payment list into credits. The HTTP around this is thin; the // decoding is where a mistake costs money and says nothing, which is the same // reason ParseEthCallUint and ParseEurcChains are pinned here rather than // trusted to integration. // // Two properties carry real weight: // // * The SIGN. bunq quotes an outgoing payment as a negative value, and the // shop sends refunds quoting the very reference of the order they refund. // Lose the minus and a refund pays for the order it reversed. // * The METHOD. `Payment.type` decides whether an order is safe to post: a // SEPA credit transfer is final, a card payment can be reversed for // months. Collapsing them to "paid" is how a chargeback becomes a // shipped parcel. import std; import Catcrafts.Shared; import Catcrafts.Server; using namespace Catcrafts; namespace { int failures = 0; void Check(bool ok, std::string_view what, std::string_view got = {}) { if (ok) return; ++failures; std::println(std::cerr, "FAIL: {}{}{}", what, got.empty() ? "" : " got: ", got); } // The shape bunq actually answers with: everything wrapped in Response, one // object per entry keyed by type. constexpr std::string_view kList = R"({ "Response": [ {"Payment": { "id": 4155551, "type": "EBA_SCT", "description": "CC-2B6457 catcrafts.net", "amount": {"currency": "EUR", "value": "57.38"} }}, {"Payment": { "id": 4155552, "type": "IDEAL", "description": "donation cc2b6457", "amount": {"currency": "EUR", "value": "5.00"} }}, {"Payment": { "id": 4155553, "type": "EBA_SCT", "description": "supplier invoice", "amount": {"currency": "EUR", "value": "-513.00"} }}, {"Payment": { "id": 4155554, "type": "FIS", "description": "card payment", "amount": {"currency": "USD", "value": "20.00"} }} ] })"; } // namespace int main() { const std::vector credits = Server::ParseBunqPayments(kList); // The USD entry is dropped: counting 20 of something else as 20 euro is // the kind of bug that only shows up as a shortfall nobody can explain. Check(credits.size() == 3, "only the euro payments are decoded", std::format("{}", credits.size())); if (credits.size() == 3) { Check(credits[0].id == "4155551", "the bank's own id is kept, for dedupe", credits[0].id); Check(credits[0].reference == "CC-2B6457 catcrafts.net", "the description is the remittance text, verbatim", credits[0].reference); Check(credits[0].amountMinor == 5738, "57.38 decodes to 5738 minor units", std::format("{}", credits[0].amountMinor)); Check(credits[0].method == "sepa", "EBA_SCT is a plain SEPA transfer", credits[0].method); Check(credits[1].method == "ideal", "IDEAL is carried as its own method", credits[1].method); // The load-bearing one. Check(credits[2].amountMinor == -51300, "an outgoing payment keeps its minus sign", std::format("{}", credits[2].amountMinor)); } // End to end through the matcher: the outgoing line must not pay for // anything, and the two incoming ones must sum. { const Server::TransferMatch m = Server::MatchCredits(credits, "CC-2B6457"); Check(m.paidMinor == 6238, "the two incoming credits sum and the outgoing one is ignored", std::format("{}", m.paidMinor)); Check(m.count == 2, "two credits matched", std::format("{}", m.count)); } // ── the method mapping, pinned individually ─────────────────────── Check(Server::BunqMethodFor("EBA_SCT") == "sepa", "EBA_SCT -> sepa"); Check(Server::BunqMethodFor("IDEAL") == "ideal", "IDEAL -> ideal"); Check(Server::BunqMethodFor("FIS") == "card", "FIS -> card (reversible!)"); Check(Server::BunqMethodFor("BUNQ") == "bunq", "BUNQ -> bunq"); Check(Server::BunqMethodFor("SWIFT") == "swift", "SWIFT -> swift"); Check(Server::BunqMethodFor("EBA_SDD") == "directdebit", "EBA_SDD -> directdebit"); // An unknown type reaches the ledger verbatim rather than as a comfortable // guess: the `via` column should show what bunq said, so a new payment // type is visible instead of silently filed as an ordinary transfer. Check(Server::BunqMethodFor("SOMETHING_NEW") == "SOMETHING_NEW", "an unknown type is passed through, not guessed at"); Check(Server::BunqMethodFor("") == "bank", "an absent type falls back to 'bank'"); // ── signed amount parsing ───────────────────────────────────────── Check(Server::ParseSignedAmountToMinor("0.01") == 1, "one cent"); Check(Server::ParseSignedAmountToMinor("-0.01") == -1, "minus one cent"); Check(Server::ParseSignedAmountToMinor("57.4") == 5740, "one decimal is tenths"); Check(Server::ParseSignedAmountToMinor("665") == 66500, "no decimal point"); Check(!Server::ParseSignedAmountToMinor("1.234").has_value(), "three decimals is not money"); Check(!Server::ParseSignedAmountToMinor("1,00").has_value(), "a comma decimal is refused rather than guessed"); Check(!Server::ParseSignedAmountToMinor("1e2").has_value(), "no exponents"); Check(!Server::ParseSignedAmountToMinor("").has_value(), "empty is not zero"); Check(!Server::ParseSignedAmountToMinor("-").has_value(), "a bare sign is not zero"); Check(!Server::ParseSignedAmountToMinor(" 1.00").has_value(), "no leading space"); // ── malformed input yields nothing, never a wrong number ────────── Check(Server::ParseBunqPayments("").empty(), "empty input decodes to nothing"); Check(Server::ParseBunqPayments("not json").empty(), "garbage decodes to nothing"); Check(Server::ParseBunqPayments(R"({"Response":[]})").empty(), "an empty account decodes to nothing"); Check(Server::ParseBunqPayments(R"({"Response":"nope"})").empty(), "a Response that is not an array decodes to nothing"); // A payment whose amount will not parse is SKIPPED, not counted as zero: // an unparseable amount means we do not know what arrived. Check(Server::ParseBunqPayments( R"({"Response":[{"Payment":{"id":1,"type":"EBA_SCT","description":"x",)" R"("amount":{"currency":"EUR","value":"1.234"}}}]})").empty(), "an unparseable amount is skipped rather than read as zero"); // ── the callback shape ──────────────────────────────────────────── // // bunq does not sign these, so the parser is the only thing between an // arbitrary POST body and a line in the credits file. It must therefore be // strict about what it accepts and must never invent a field. { constexpr std::string_view kHook = R"({ "NotificationUrl": { "category": "MUTATION", "object": {"Payment": { "id": 4155999, "type": "EBA_SCT", "description": "CC-2B6457", "amount": {"currency": "EUR", "value": "570.43"} }} } })"; const auto c = Server::ParseBunqCallback(kHook); Check(c.has_value(), "a MUTATION callback decodes to a credit"); if (c) { Check(c->id == "4155999", "the payment id is kept", c->id); Check(c->amountMinor == 57043, "the amount decodes", std::format("{}", c->amountMinor)); Check(c->reference == "CC-2B6457", "the description is the reference", c->reference); Check(c->method == "sepa", "the type maps to a via", c->method); } // An id is what deduplication rests on, and bunq RETRIES callbacks. A // credit with no id could therefore be applied twice, so it must be // refused outright rather than stored with a blank. Check(!Server::ParseBunqCallback(R"({"NotificationUrl":{"object":{"Payment":{)" R"("type":"EBA_SCT","description":"x",)" R"("amount":{"currency":"EUR","value":"1.00"}}}}})").has_value(), "a payment with no id is refused, because retries need one"); // Everything that is not a payment notification: bunq sends several // categories, and none of the others may produce a credit. for (const std::string_view junk : { std::string_view(""), std::string_view("not json"), std::string_view(R"({})"), std::string_view(R"({"NotificationUrl":{}})"), std::string_view(R"({"NotificationUrl":{"object":{}}})"), std::string_view(R"({"NotificationUrl":{"object":{"Payment":{}}}})"), // A Payment whose amount is another currency, or unparseable. std::string_view(R"({"NotificationUrl":{"object":{"Payment":{"id":1,)" R"("amount":{"currency":"USD","value":"1.00"}}}}})"), std::string_view(R"({"NotificationUrl":{"object":{"Payment":{"id":1,)" R"("amount":{"currency":"EUR","value":"1.234"}}}}})"), }) { Check(!Server::ParseBunqCallback(junk).has_value(), "a body with no usable euro payment yields nothing", junk); } // The sign survives here too: an outgoing payment must not be able to // arrive through the webhook as income. const auto out = Server::ParseBunqCallback( R"({"NotificationUrl":{"object":{"Payment":{"id":7,"type":"EBA_SCT",)" R"("description":"supplier","amount":{"currency":"EUR","value":"-99.00"}}}}})"); Check(out.has_value() && out->amountMinor == -9900, "an outgoing callback keeps its sign for the caller to reject", out ? std::format("{}", out->amountMinor) : "nullopt"); } // ── appending, and refusing to append twice ─────────────────────── // // bunq retries a callback about six times, and a periodic pull re-reads an // overlapping window, so the same payment WILL arrive more than once. If a // second copy landed in the file it would double the money against one // reference — enough to settle a part-paid order whose balance never came. { const std::filesystem::path dir = std::filesystem::temp_directory_path() / "cc-bunq-append-test"; std::error_code ec; std::filesystem::remove_all(dir, ec); std::filesystem::create_directories(dir, ec); const std::filesystem::path credits = dir / "credits.jsonl"; Server::BankCredit c; c.id = "999"; c.reference = "CC-2B6457"; c.amountMinor = 2500; c.method = "sepa"; Check(Server::AppendCreditTo(credits, c), "the first append succeeds"); Check(!Server::AppendCreditTo(credits, c), "the same payment id is refused the second time"); auto src = Server::MakeFileCreditSource(credits); const auto all = src->Recent(); Check(all && all->size() == 1, "the file holds exactly one copy", all ? std::format("{}", all->size()) : "nullopt"); if (all) { Check(Server::MatchCredits(*all, "CC-2B6457").paidMinor == 2500, "so the order sees 25.00 once, not twice"); } // A credit with no id cannot be deduplicated and must be refused. Server::BankCredit noId = c; noId.id.clear(); Check(!Server::AppendCreditTo(credits, noId), "a credit with no id is refused"); std::filesystem::remove_all(dir, ec); } if (failures != 0) { std::println(std::cerr, "{} check(s) failed", failures); return 1; } std::println("ShouldParseBunqPayments: all checks passed"); return 0; }