This commit is contained in:
parent
7098ac75cb
commit
df91762271
29 changed files with 3079 additions and 838 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue