bank tranfer fix
All checks were successful
Deploy / build-deploy (push) Successful in 3m11s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 23:33:50 +02:00
commit aaa7a8ce99
10 changed files with 543 additions and 8 deletions

View file

@ -148,6 +148,109 @@ int main() {
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;