All checks were successful
Deploy / build-deploy (push) Successful in 3m11s
260 lines
12 KiB
C++
260 lines
12 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.
|
|
*/
|
|
|
|
// 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");
|
|
|
|
// ── 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;
|
|
}
|