catcrafts.net/tests/ShouldParseBunqPayments/main.cpp

157 lines
6.9 KiB
C++
Raw Normal View History

2026-08-20 20:15:47 +02:00
/*
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<Server::BankCredit> 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;
}