/* 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"); if (failures != 0) { std::println(std::cerr, "{} check(s) failed", failures); return 1; } std::println("ShouldParseBunqPayments: all checks passed"); return 0; }