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

@ -162,20 +162,24 @@ int main(int argc, char** argv) {
// directory is publicly served and wiped by rsync --delete each deploy.
std::filesystem::path ordersPath = "orders.jsonl";
// Payment rail selection, one slot per payment choice the buyer gets.
// Flags beat environment beats default, and the default for each slot
// is "the provider whose key is set, off otherwise" — so a box with no
// credentials serves the whole site minus checkout instead of refusing
// to start, and a box with only one key offers only that one method.
// Flags beat environment beats default, and a slot whose configuration
// is absent is simply off — so a box with nothing configured serves the
// whole site minus checkout instead of refusing to start, and a box
// with one rail configured offers only that one method.
//
// bank MOLLIE_API_KEY iDEAL, cards, transfer
// crypto EURC_CHAINS self-hosted EURC, no processor, no key
// bank TRANSFER_IBAN SEPA transfer to our own account
// crypto EURC_CHAINS self-hosted EURC, on our own addresses
//
// The crypto slot is selected by the presence of a chains FILE rather
// than a credential: the self-hosted rail has no credential, which is
// the feature.
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
// NEITHER slot is selected by a credential, and that is the point
// rather than an accident. Both rails are self-hosted, so each is
// selected by naming where the money lands: there is no provider to
// authenticate to, and therefore no key anyone can revoke. The shop
// ran on a hosted provider until 2026-08-20, when it closed the
// account after a risk review with no appeal and took every payment
// method with it. These two rails are the answer to that.
const char* transferIban = std::getenv("TRANSFER_IBAN");
const char* eurcChains = std::getenv("EURC_CHAINS");
std::string railMode = mollieKey && *mollieKey ? "mollie" : "off";
std::string railMode = transferIban && *transferIban ? "transfer" : "off";
std::string cryptoMode = eurcChains && *eurcChains ? "eurc" : "off";
std::filesystem::path railState;
std::string redirectBase = [] {
@ -306,24 +310,66 @@ int main(int argc, char** argv) {
}
}
auto build = [&](const std::string& mode, const char* key, const char* keyName,
// The bank-transfer rail's configuration. Credential-free like the EURC
// rail — there is no provider to authenticate to, only our own account
// to name — so TRANSFER_IBAN is what SELECTS it, for the same reason
// EURC_CHAINS selects the crypto slot: a value that appeared by
// convention rather than by intent must not switch a payment method on.
// The credits file is derived, though, because it is state rather than
// intent, and it hangs off the orders path like everything else.
std::filesystem::path transferCreditsPath;
if (const char* v = std::getenv("TRANSFER_CREDITS"); v && *v) {
transferCreditsPath = v;
} else {
transferCreditsPath = ordersPath;
transferCreditsPath += ".transfer-credits.jsonl";
}
int transferPollSeconds = 60;
if (const char* v = std::getenv("TRANSFER_POLL_SECONDS"); v && *v) {
const std::string_view sv(v);
int parsed = 0;
if (std::from_chars(sv.data(), sv.data() + sv.size(), parsed).ec == std::errc{}
&& parsed > 0 && parsed <= 3600) {
transferPollSeconds = parsed;
} else {
std::println(std::cerr,
"catcrafts-server: TRANSFER_POLL_SECONDS='{}' is not a "
"sane second count — refusing to start", sv);
return 2;
}
}
int transferWindowHours = 14 * 24;
if (const char* v = std::getenv("TRANSFER_WINDOW_HOURS"); v && *v) {
const std::string_view s(v);
int parsed = 0;
if (std::from_chars(s.data(), s.data() + s.size(), parsed).ec == std::errc{}
&& parsed > 0 && parsed <= 24 * 90) {
transferWindowHours = parsed;
} else {
std::println(std::cerr,
"catcrafts-server: TRANSFER_WINDOW_HOURS='{}' is not a "
"sane hour count — refusing to start", s);
return 2;
}
}
auto build = [&](const std::string& mode,
std::unique_ptr<Server::PaymentRail>& out) -> bool {
Server::RailConfig cfg;
cfg.mode = mode;
cfg.apiKey = key ? key : "";
cfg.statePath = railState;
cfg.redirectBase = redirectBase;
cfg.eurcChainsPath = eurcChainsPath;
cfg.eurcPoolPath = eurcPoolPath;
cfg.eurcWindowHours = eurcWindowHours;
const bool needsKey = mode == "mollie";
if (needsKey && cfg.apiKey.empty()) {
std::println(std::cerr,
"catcrafts-server: rail '{}' selected but {} is not set — "
"refusing to start with a rail that cannot work",
mode, keyName);
return false;
if (const char* v = std::getenv("TRANSFER_IBAN"); v) cfg.transferIban = v;
if (const char* v = std::getenv("TRANSFER_BENEFICIARY"); v) {
cfg.transferBeneficiary = v;
}
if (const char* v = std::getenv("TRANSFER_BIC"); v) cfg.transferBic = v;
cfg.transferCreditsPath = transferCreditsPath;
cfg.transferPollSeconds = transferPollSeconds;
cfg.transferWindowHours = transferWindowHours;
out = Server::MakeRail(cfg);
// "off" is a legitimate choice and yields no rail; a mode nobody
// recognises silently would too, which is how a typo becomes a
@ -333,7 +379,7 @@ int main(int argc, char** argv) {
// and must not be answered the same way (see below).
if (!out && mode != "off") {
static constexpr std::string_view kKnown[] = {
"mollie", "eurc", "fake", "fake-crypto"
"eurc", "transfer", "fake", "fake-crypto"
};
const bool known = std::ranges::find(kKnown, mode) != std::end(kKnown);
if (!known) {
@ -373,10 +419,10 @@ int main(int argc, char** argv) {
};
Server::PaymentRails rails;
if (!build(railMode, mollieKey, "MOLLIE_API_KEY", rails.bank)) return 2;
if (!build(railMode, rails.bank)) return 2;
// The crypto slot carries no credential at all; what it needs instead
// rode in on cfg.eurc* above.
if (!build(cryptoMode, nullptr, "", rails.crypto)) return 2;
if (!build(cryptoMode, rails.crypto)) return 2;
Server::ConfigurePayments(std::move(rails), redirectBase);
@ -413,6 +459,78 @@ int main(int argc, char** argv) {
return Server::Serve(port);
}
// --pull-credits: read the bank account once and append anything new to the
// credits file the transfer rail settles from. Prints how many arrived.
//
// A SEPARATE ENTRY POINT ON PURPOSE, and the reason is the whole point of
// the design. A bunq API key can initiate payments — bunq has no read-only
// scope — so the project's rule is that it never lives on the public host.
// Run this on a trusted machine on a timer, ship the credits file over, and
// the server settles orders while holding no credential that can move a
// cent. Configuring BUNQ_API_KEY on the server works too and is simpler,
// but it is strictly worse and this program will say so when it starts.
//
// catcrafts-server --pull-credits [--orders FILE] [--credits FILE]
if (!args.empty() && args[0] == "--pull-credits") {
std::filesystem::path ordersPath = "orders.jsonl";
std::filesystem::path creditsPath;
std::filesystem::path statePath;
for (std::size_t i = 1; i < args.size(); ++i) {
const std::string_view a = args[i];
auto next = [&]() -> std::string {
return (i + 1 < args.size()) ? std::string(args[++i]) : std::string{};
};
if (a == "--orders") ordersPath = next();
else if (a == "--credits") creditsPath = next();
else if (a == "--state") statePath = next();
}
if (creditsPath.empty()) {
if (const char* v = std::getenv("TRANSFER_CREDITS"); v && *v) {
creditsPath = v;
} else {
creditsPath = ordersPath;
creditsPath += ".transfer-credits.jsonl";
}
}
if (statePath.empty()) {
if (const char* v = std::getenv("BUNQ_STATE"); v && *v) {
statePath = v;
} else {
statePath = ordersPath;
statePath += ".bunq-context.json";
}
}
Server::BunqConfig bunq;
if (const char* v = std::getenv("BUNQ_API_KEY"); v) bunq.apiKey = v;
if (const char* v = std::getenv("TRANSFER_IBAN"); v) bunq.iban = v;
if (const char* v = std::getenv("BUNQ_PERMITTED_IPS"); v) bunq.permittedIps = v;
if (bunq.apiKey.empty()) {
std::println(std::cerr,
"catcrafts-server: BUNQ_API_KEY is not set — nothing to pull "
"with. This command reads the bank account; it never pays "
"anyone.");
return 2;
}
bunq.statePath = statePath;
std::unique_ptr<Server::CreditSource> source = Server::MakeBunqCreditSource(bunq);
if (!source) return 2;
const std::optional<int> added =
Server::PullCreditsInto(*source, creditsPath);
if (!added) {
// Distinct from "nothing new": a timer that cannot tell these
// apart will report success while the shop silently stops
// noticing payments.
std::println(std::cerr,
"catcrafts-server: could not read the account — nothing was "
"written; the credits file still holds what it did");
return 1;
}
std::println("pulled {} new credit(s) into {}", *added, creditsPath.string());
return 0;
}
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
// the escape hatch for a payment confirmed out-of-band (or a refund):
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
@ -478,15 +596,24 @@ int main(int argc, char** argv) {
std::println("catcrafts-server: --render <path> | --routes | --sitemap | --feed\n"
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
" [--rail=off|fake|mollie]\n"
" [--rail=off|fake|transfer]\n"
" [--crypto-rail=off|fake-crypto|eurc]\n"
" [--rail-state=FILE] [--redirect-base=URL]\n"
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
" --pull-credits [--orders FILE] [--credits FILE] [--state FILE]\n"
"\n"
"environment: MOLLIE_API_KEY (test_… or live_…) selects the bank rail.\n"
" EURC_CHAINS=FILE selects the self-hosted crypto rail (no key:\n"
" that is the point); EURC_POOL=FILE of receiving addresses,\n"
" default <orders>.eurc-addresses, EURC_WINDOW_HOURS (24).\n"
"environment: TRANSFER_IBAN selects the bank-transfer rail (no key: that is\n"
" the point), with TRANSFER_BENEFICIARY the account-holder name\n"
" EXACTLY as the bank holds it — payers' banks name-check it —\n"
" TRANSFER_CREDITS=FILE (default <orders>.transfer-credits.jsonl)\n"
" and TRANSFER_WINDOW_HOURS (336).\n"
" EURC_CHAINS=FILE selects the self-hosted crypto rail (also no\n"
" key); EURC_POOL=FILE of receiving addresses, default\n"
" <orders>.eurc-addresses, EURC_WINDOW_HOURS (24).\n"
" BUNQ_API_KEY + BUNQ_PERMITTED_IPS, BUNQ_STATE are for\n"
" --pull-credits. That key CAN MOVE MONEY (bunq has no read-only\n"
" scope), so run --pull-credits on a trusted machine and ship the\n"
" credits file here, rather than setting it on this host.\n"
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
return 0;