Replaced mollie
All checks were successful
Deploy / build-deploy (push) Successful in 3m47s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 20:15:47 +02:00
commit df91762271
29 changed files with 3079 additions and 838 deletions

View file

@ -78,8 +78,9 @@ export namespace Catcrafts::Server {
// normalises before writing.
std::string payUrl; // the provider's hosted checkout link;
// for the EURC rail, the order page itself
std::string payId; // provider payment id ("tr_…" at Mollie,
// "<address>@<deadline>" at the EURC rail)
std::string payId; // the rail's own handle on the payment:
// "<reference>@<deadline>" for a bank
// transfer, "<address>@<deadline>" for EURC
std::string paidVia; // method that settled it ("ideal", "bitcoin")
std::string paidAt; // ISO 8601 of the FIRST paid event; empty =
// never paid. A later cancel (a refund)
@ -198,6 +199,28 @@ export namespace Catcrafts::Server {
std::string NewOrderToken();
std::string ReferenceFromToken(std::string_view token);
// The same reference again, as an ISO 11649 structured creditor reference:
// "RF" + two ISO 7064 mod-97-10 check digits + the CC-style body. Derived
// from the same token for the same reason, so the two can never disagree.
//
// Why it is worth the arithmetic: Dutch consumer banking gives this a
// dedicated payment-reference field, and the PAYER'S OWN BANK verifies the
// check digits before the transfer leaves. A correct reference travels as
// structured remittance information; a mistyped one is caught at the other
// end rather than arriving here as money nobody can match to an order.
// That is what makes an unattended bank-transfer reconciler trustworthy —
// exact match on a validated key instead of a substring hunt through
// free text a human retyped.
std::string CreditorReferenceFromToken(std::string_view token);
// Whether `s` is a well-formed ISO 11649 reference: the RF prefix, a length
// within the standard, an alphanumeric body and check digits that verify.
// Exported because this is where a mistake is invisible — a generator that
// computes the digits wrong still produces something that LOOKS like a
// reference, and every payer's bank would then reject it while our own
// logs showed nothing wrong at all.
bool IsValidCreditorReference(std::string_view s);
// ── the order confirmation email ──────────────────────────────────
//
// A paid order gets ONE email: the confirmation, with the clearsigned
@ -254,9 +277,11 @@ export namespace Catcrafts::Server {
// What a poll learned about one payment. Pending and Dead are different
// answers on purpose: an unpaid order does not stay payable forever —
// Mollie expires its payments after its own window, and the EURC rail
// closes its own (24 hours by default) — and an order whose payment can
// never arrive should lapse rather than sit "awaiting" forever.
// each rail closes its own window (14 days for a bank transfer, 24 hours
// for EURC) — and an order whose payment can never arrive should lapse
// rather than sit "awaiting" forever. Neither window means bounced money:
// the account and the address stay ours, so a late payment still lands and
// is settled by hand.
enum class PayState { Pending, Paid, Dead };
struct PaidStatus {
PayState state = PayState::Pending;
@ -282,10 +307,27 @@ export namespace Catcrafts::Server {
std::string note; // optional display hint ("lowest fees")
};
struct PayInstructions {
std::string address; // where the money goes
std::string amount; // decimal token amount ("570.43")
std::string address; // where the money goes: a token address, or an IBAN
std::string amount; // decimal amount ("570.43")
std::int64_t deadlineUnix = 0;
std::vector<PayChainOption> chains;
// ── bank-transfer rails only; empty for on-chain ones ──────────
//
// The beneficiary name is NOT decoration. Since 2025-10-09 every
// euro-area transfer is checked by Verification of Payee, and the
// payer sees a mismatch warning at the moment of payment if the name
// they were given does not match the one holding the IBAN. So this
// must be the name the BANK holds, character for character, not the
// trading name — a well-meaning "Catcrafts" where the bank says
// something else scares buyers off at the last step.
std::string beneficiary;
// The structured creditor reference the payer must quote, which is
// what makes the incoming money matchable to this order.
std::string reference;
// Empty unless configured; only a payer sending from outside SEPA
// needs it. See RailConfig::transferBic.
std::string bic;
};
class PaymentRail {
@ -317,8 +359,8 @@ export namespace Catcrafts::Server {
};
struct RailConfig {
std::string mode; // "off" | "fake" | "mollie" | "eurc"
std::string apiKey; // mollie: live_… or test_…
std::string mode; // "off" | "fake" | "fake-crypto"
// | "transfer" | "eurc"
std::filesystem::path statePath; // fake: the paid marker
std::string redirectBase = "https://catcrafts.net";
@ -330,6 +372,33 @@ export namespace Catcrafts::Server {
std::filesystem::path eurcChainsPath;
std::filesystem::path eurcPoolPath;
int eurcWindowHours = 24; // 0 or less means the 24h default
// transfer: also credential-free. What it needs is where the money
// goes and what the payer must be told — see PayInstructions on why
// the beneficiary name is load-bearing rather than cosmetic.
std::string transferIban;
std::string transferBeneficiary;
// Optional. Inside SEPA an IBAN is sufficient and has been since 2016,
// so this is shown only when set, and labelled for the case that
// actually needs it: a payer whose bank is outside SEPA and who is
// sending by SWIFT, where the form asks for a BIC and cannot proceed
// without one. Rendering it unconditionally would invite every Dutch
// buyer to type a field their bank does not want.
std::string transferBic;
std::filesystem::path transferCreditsPath;
// How often the reconciler asks this rail about an order. 60 s suits a
// real bank: money does not arrive faster than that even under instant
// payments, and the rail shares ONE account read across a whole sweep
// so the cadence is about politeness rather than cost. Configurable
// because a suite driving a local credits FILE has nobody to be polite
// to, and a 60 s wait per assertion makes a test unusable.
int transferPollSeconds = 60;
// A bank transfer has no provider-side expiry, so this window is
// purely ours: how long an order waits before it is treated as
// abandoned. Generous on purpose, and lapsing is NOT bounced money —
// the IBAN stays ours and a late payment still arrives, to be settled
// by hand. Same semantics as the EURC window.
int transferWindowHours = 14 * 24;
};
// nullptr for mode "off" — that slot then offers no payment choice.
@ -340,7 +409,7 @@ export namespace Catcrafts::Server {
// checkout form renders the choices that exist, so a page can never
// advertise a way to pay the server would then refuse.
struct PaymentRails {
std::unique_ptr<PaymentRail> bank; // Mollie: iDEAL, cards, transfer
std::unique_ptr<PaymentRail> bank; // SEPA transfer to our own account
std::unique_ptr<PaymentRail> crypto; // EURC: self-hosted, on-chain
bool Any() const { return bank != nullptr || crypto != nullptr; }
@ -356,22 +425,13 @@ export namespace Catcrafts::Server {
}
};
// Parsed essentials of a Mollie /v2/payments object. Exported so the
// self-test can drive the parser with canned responses — the HTTP around
// it is thin.
struct MolliePayment {
std::string id;
std::string status; // open|pending|authorized|paid|canceled|expired|failed
std::string method; // may be empty until the payer picks one
std::string checkoutUrl; // present while payable
std::int64_t amountMinor = 0;
};
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
// Exact decimal-string-to-minor-units parser for the amounts Mollie's API
// quotes as strings ("614.00" -> 61400). Rejects anything that is not
// a plain non-negative decimal with at most two fraction digits — no
// floats touch money on the way in either. Exported for the self-test.
// Exact decimal-string-to-minor-units parser for amounts that arrive as
// strings ("614.00" -> 61400), which is how every external source quotes
// them. Rejects anything that is not a plain non-negative decimal with at
// most two fraction digits — no floats touch money on the way in, and a
// sign is refused here so it cannot quietly halve a total (see
// ParseSignedAmountToMinor, which handles the one case where the sign is
// meaningful). Exported for the self-test.
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
// One chain the EURC rail watches. Every field is configuration because
@ -431,6 +491,125 @@ export namespace Catcrafts::Server {
// was given.
std::unique_ptr<PaymentRail> MakeEurcRail(const RailConfig& config);
// ── the bank-transfer rail ────────────────────────────────────────
//
// The other self-hosted rail: the buyer sends a plain SEPA transfer to our
// own IBAN quoting the order's creditor reference, and the rail settles the
// order when a matching credit shows up on the account. No provider stands
// in the payment path, which is the entire point — the only third party is
// the bank the money was always going to land in anyway.
// One incoming credit on the account, reduced to what matching needs.
struct BankCredit {
std::string id; // the bank's own payment id; dedupe and logs
std::string reference; // remittance information, as the bank has it
std::int64_t amountMinor = 0;
std::string method; // ledger `via`: "sepa", "ideal", …
};
// What the credits say about one order.
struct TransferMatch {
std::int64_t paidMinor = 0; // summed over every credit carrying the reference
int count = 0; // how many credits carried it
std::string method; // the method of the last matching credit
// Set when a matching credit's remittance text ALSO carries something
// shaped like a second order reference. One transfer quoting two
// references cannot be attributed by a per-order matcher, and at that
// point a human should look rather than two orders both settling on the
// same money. Advisory: the caller logs it, it does not block.
bool ambiguous = false;
};
// Sum the credits that quote `reference`, in whatever form the payer typed
// it. Pure, and exported for the self-test, because this is the decision
// that releases goods — the same reason ParseEthCallUint is exported.
//
// Matching is on the reference BODY ("CC2B6457") after reducing both sides
// to upper-case alphanumerics. That one choice covers every form a payer
// might quote — "CC-2B6457", "cc2b6457", or the full structured
// "RF70CC2B6457" — because the body is a substring of all of them, and it
// survives whatever spacing a bank puts in the field. The RF check digits
// deliberately do NO work here: their job was done at the payer's own bank,
// which refuses a mistyped reference before the transfer ever leaves.
TransferMatch MatchCredits(std::span<const BankCredit> credits,
std::string_view reference);
// Where a transfer rail gets its incoming credits. One implementation talks
// to the bank; the suites use a file-backed one so the whole rail — payment
// instructions, matching, settlement, the window — runs with no network.
class CreditSource {
public:
virtual ~CreditSource() = default;
// nullopt = the bank could not be reached. NEVER an empty vector for
// that case: "no credits yet" and "cannot ask" must not look alike, or
// an outage would read as a shop full of unpaid orders.
virtual std::optional<std::vector<BankCredit>> Recent() = 0;
virtual std::string_view Name() const = 0;
};
// The rail itself. Takes its credit source so the bank is swappable — a
// deliberate hedge, since the account that reconciles the shop is also the
// shop's bank account, and replacing one adapter must not mean rewriting
// the rail.
std::unique_ptr<PaymentRail> MakeTransferRail(const RailConfig& config,
std::unique_ptr<CreditSource> credits);
// A CreditSource reading newline-delimited JSON from a file, one credit per
// line: {"id":…,"reference":…,"amount_minor":…,"method":…}. This is how the
// suites drive real settlement, and how an operator can settle a transfer
// by hand without touching the ledger. A missing file is an EMPTY list, not
// a failure: no transfers yet is a normal state.
std::unique_ptr<CreditSource> MakeFileCreditSource(std::filesystem::path path);
// ── the bunq credit source ────────────────────────────────────────
struct BunqConfig {
std::string apiKey; // BUNQ_API_KEY; can MOVE MONEY, see below
std::filesystem::path statePath; // keypair + tokens, 0600
// Which account, when the key can see more than one. Compared to the
// account's IBAN ignoring spacing and case; without it a key that sees
// several active accounts is a refusal rather than a guess, because
// guessing means reconciling the shop against its savings.
std::string iban;
// Registered with bunq ONCE, at device-server time. bunq has no
// read-only key scope, so this is the only thing standing between a
// leaked key and someone spending the balance. "*" works and is
// announced loudly; an explicit egress address is what should be used.
std::string permittedIps;
int count = 50; // payments per read
};
// nullptr when no key is configured. NOTE the deployment rule that goes
// with this: a bunq key can initiate payments and bunq offers no read-only
// scope, so the project's standing policy is that it does NOT live on the
// internet-facing host. Run `--pull-credits` on a trusted machine and ship
// the credits file to the server, which then holds no credential at all.
std::unique_ptr<CreditSource> MakeBunqCreditSource(const BunqConfig& config);
// bunq's payment list -> credits. Exported for the self-test: the money
// decisions downstream are only as good as this decoding, and the HTTP
// around it is thin. Same reasoning as ParseEthCallUint.
std::vector<BankCredit> ParseBunqPayments(std::string_view json);
// Amount strings as bunq quotes them, INCLUDING the leading minus an
// outgoing payment carries. Exported because the sign is the difference
// between income and a refund, and getting it wrong would let a refund pay
// for the order it reversed.
std::optional<std::int64_t> ParseSignedAmountToMinor(std::string_view s);
// bunq's Payment.type -> the ledger's `via` vocabulary. Exported so the
// suite can pin the mapping that decides whether an order is safe to ship:
// "sepa" is final, "card" can be reversed for months.
std::string_view BunqMethodFor(std::string_view paymentType);
// One pull: read the account and append every credit not already in the
// file to it, newest last. Returns the number appended, or nullopt if the
// bank could not be reached. This is what `--pull-credits` runs, and it is
// deliberately a separate entry point from the rail so the machine holding
// the key need not be the machine serving the shop.
std::optional<int> PullCreditsInto(CreditSource& source,
const std::filesystem::path& creditsPath);
// ── shipping rates ────────────────────────────────────────────────
//
// Live per-country, per-weight-bracket rates from Sendcloud's
@ -508,6 +687,12 @@ export namespace Catcrafts::Server {
// offers the crypto choice only when something can actually serve it.
bool CryptoPaymentAvailable();
// The same question for the bank slot. It exists because the asymmetry
// was an outage: when the bank rail went away the form kept rendering a
// pre-selected "Bank or card" option that checkout could only refuse with
// a 503. Every renderer that knows must pass both.
bool BankPaymentAvailable();
// ── request provenance ────────────────────────────────────────────
//
// Two questions a reverse-proxied process has to answer carefully, both