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