coingate
All checks were successful
Deploy / build-deploy (push) Successful in 3m10s

This commit is contained in:
Jorijn van der Graaf 2026-08-13 23:34:19 +02:00
commit 70668af8f5
20 changed files with 2354 additions and 1048 deletions

View file

@ -9,7 +9,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
// catcrafts-server — the native product.
//
// Serves the server-rendered pages (crawlers and no-JS clients get real HTML),
// runs the shop — orders, the bunq payment rail, the reconciler — and doubles
// runs the shop — orders, the payment rails, the reconciler — and doubles
// as the test harness for Catcrafts.Shared.
//
// The harness half is not filler. Catcrafts.Shared is the security boundary
@ -704,6 +704,74 @@ void RunFormSelfTest() {
Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(),
"checkout: oversized colour rejected");
// The payment choice. Absent is a form that offered none (one rail
// configured, or the no-JS fallback page) and the handler resolves it to
// bank — the validator's job is only to refuse a word it does not know
// rather than let it fall through to a default the buyer never picked.
Check(validate(kGoodOrder).value.payChoice.empty(),
"checkout: absent payment choice stays empty");
Check(validate(std::string(kGoodOrder) + "&pay=bank").value.payChoice
== Catcrafts::Form::kPayBank,
"checkout: bank choice parsed");
Check(validate(std::string(kGoodOrder) + "&pay=crypto").value.payChoice
== Catcrafts::Form::kPayCrypto,
"checkout: crypto choice parsed");
{
auto bogus = validate(std::string(kGoodOrder) + "&pay=free");
Check(!bogus.Ok(), "checkout: unknown payment choice rejected");
Check(bogus.errors.size() == 1 && bogus.errors[0].field == "pay",
"checkout: the payment refusal hangs off the payment field");
}
// Destinations the shop refuses. Well-formed, real country codes — the
// refusal is policy, so it has to survive every spelling the form accepts,
// and it must not spill onto other non-EU destinations.
auto withCountry = [&](std::string_view cc) {
return validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y&country="
+ std::string(cc));
};
Check(!withCountry("US").Ok(), "checkout: US refused");
Check(!withCountry("CA").Ok(), "checkout: CA refused");
Check(!withCountry("us").Ok(), "checkout: lowercase US refused too");
Check(withCountry("GB").Ok(), "checkout: other non-EU destinations still sell");
Check(withCountry("NL").Ok(), "checkout: EU unaffected");
{
auto us = withCountry("US");
Check(us.errors.size() == 1 && us.errors[0].field == "country",
"checkout: refusal is a country error, nothing else");
Check(us.errors[0].message == Catcrafts::Form::kNoSaleMessage,
"checkout: refusal says where the shop does not sell");
Check(us.value.country == "US", "checkout: refused country echoed back");
}
// The shipping refusals. These are templates rather than plain strings
// because the buy page fills the same ones client-side, so the substitution
// has to work on both {cc} and {n} — a template that silently kept its
// placeholder would ship "up to {n} per order" to a real buyer.
{
using namespace Catcrafts::Form;
const std::string none = NoShippingMessage("BR");
Check(none.find("BR") != std::string::npos
&& none.find("{cc}") == std::string::npos,
"shipping copy: the uncovered-country message names the country");
const std::string heavy = TooHeavyMessage("JP", 3);
Check(heavy.find("JP") != std::string::npos && heavy.find("3") != std::string::npos
&& heavy.find("{n}") == std::string::npos,
"shipping copy: the too-heavy message names the country and the limit");
const std::string nofit = TooHeavyMessage("JP", 0);
Check(nofit.find("JP") != std::string::npos
&& nofit.find("up to") == std::string::npos,
"shipping copy: with nothing fitting it does not promise a quantity");
Check(FillShipMessage("{cc} {n} {cc}", "NL", 2) == "NL 2 NL",
"shipping copy: every placeholder is filled, not just the first");
// Both messages must offer the way out, since the shop is refusing
// business it would otherwise take.
Check(none.find("orders@catcrafts.net") != std::string::npos
&& heavy.find("orders@catcrafts.net") != std::string::npos
&& nofit.find("orders@catcrafts.net") != std::string::npos,
"shipping copy: every refusal names a human to email");
}
// A rejected field must still come back, or the visitor has to retype the
// one thing they got wrong — the fastest way to lose a submission.
auto rejected = validate("email=notanemail&name=Ada&street=Main%201&postal=1&city=y&country=NLD");
@ -746,12 +814,49 @@ void RunMoneySelfTest() {
Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)");
Check(ZoneFor("NL") == Zone::Nl, "zone: home");
Check(ZoneFor("DE") == Zone::Eu, "zone: eu");
Check(ZoneFor("CA") == Zone::World, "zone: world");
Check(ZoneFor("GB") == Zone::World, "zone: world");
// ── destinations the shop refuses ─────────────────────────────────
// Zones still classify US and CA (the arithmetic is destination-blind, and
// keeping it that way means one policy switch, not two); the sale is what
// stops, in SellsTo.
Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused");
Check(SellsTo("NL") && SellsTo("DE"), "policy: EU sells");
Check(SellsTo("GB") && SellsTo("CH") && SellsTo("AU"),
"policy: the rest of the world still sells");
Check(SellsTo("us"), "policy: matched on the normalised code, like membership");
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify");
// ── carrier weight brackets ───────────────────────────────────────
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
// 20 kg, with the 20 kg band deliberately CHEAPER than the 10 kg one —
// real carrier tariffs do that, and picking the tightest band rather than
// the cheapest one that carries the parcel would overcharge for it.
{
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 1650 },
{ 20000, 1490 } };
Check(RateFor(ladder, 700) == 895, "brackets: one unit takes the 2 kg band");
Check(RateFor(ladder, 2000) == 895, "brackets: the ceiling is inclusive");
Check(RateFor(ladder, 2001) == 1490,
"brackets: cheapest band that CARRIES it, not the tightest");
Check(RateFor(ladder, 20001) == 0, "brackets: above every band is no price");
Check(RateFor({}, 700) == 0, "brackets: an uncovered country has no price");
Check(MaxUnitsFor(ladder, 700) == 28, "brackets: units that fit one parcel");
Check(MaxUnitsFor(ladder, 25000) == 0,
"brackets: a unit heavier than every band fits nothing");
Check(MaxUnitsFor(ladder, 0) == 0, "brackets: no weight, no answer");
Check(MaxUnitsFor({}, 700) == 0, "brackets: no ladder, nothing fits");
// The table-level lookups the handler and the page both go through.
const std::vector<ShipRates> table{ { "NL", ladder }, { "JP", { { 2000, 4250 } } } };
Check(RateFor(LadderFor(table, "NL"), 700) == 895, "table: NL priced");
Check(RateFor(LadderFor(table, "JP"), 2100) == 0,
"table: JP has one light band, so two units are unshippable");
Check(LadderFor(table, "BR").empty(), "table: unlisted country is empty");
}
// ── order totals ──────────────────────────────────────────────────
Check(ZoneShipping(1500, 2500, 5500, "NL") == 1500, "ship: NL zone");
Check(ZoneShipping(1500, 2500, 5500, "DE") == 2500, "ship: EU zone");
Check(ZoneShipping(1500, 2500, 5500, "CA") == 5500, "ship: world zone");
// NL: gross + shipping, VAT included in both.
auto nl = ComputeTotals(58000, 1, 1500, "NL");
@ -765,17 +870,17 @@ void RunMoneySelfTest() {
"totals: EU");
// Export: net goods, world shipping, no VAT.
auto ca = ComputeTotals(58000, 1, 5500, "CA");
Check(ca.goods == 47934 && ca.shipping == 5500 && ca.total == 53434,
auto gb = ComputeTotals(58000, 1, 5500, "GB");
Check(gb.goods == 47934 && gb.shipping == 5500 && gb.total == 53434,
"totals: export");
Check(!ca.vatIncluded && ca.vatCharged == 0, "totals: export carries no VAT");
Check(!gb.vatIncluded && gb.vatCharged == 0, "totals: export carries no VAT");
// Quantity: the export net is derived from the LINE total, not per unit —
// per-unit rounding times qty would differ by a cent here, and the JS
// preview mirrors this exact formula.
auto ca2 = ComputeTotals(57500, 2, 5500, "CA");
Check(ca2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
Check(ca2.goods == 95041, "totals: 2× green export net exact");
auto gb2 = ComputeTotals(57500, 2, 5500, "GB");
Check(gb2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
Check(gb2.goods == 95041, "totals: 2× green export net exact");
auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
@ -814,7 +919,17 @@ void RunMoneySelfTest() {
// with its ONE offer — built from the same integers the
// checkout charges.
{
auto pp = Views::RenderProduct(pr, Rates{});
// The listing's shipping block is now carrier data, so the
// render needs a table. US is priced here on purpose: the
// carrier will happily quote it and the shop still must not
// advertise it.
const std::vector<ShipRates> feedTable{
{ "NL", { { 2000, 895 } } },
{ "DE", { { 2000, 995 } } },
{ "GB", { { 2000, 2450 } } },
{ "US", { { 2000, 1794 } } },
};
auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
auto ld = Json::Parse(pp.meta.jsonLd);
bool variantsOk = false;
if (ld && ld->IsObject()) {
@ -838,6 +953,24 @@ void RunMoneySelfTest() {
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: variants carry shipping, returns, sku and group id");
// The published rates ARE the carrier's, at one unit's weight.
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
"schema: shipping rates come from the carrier table");
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos,
"schema: a refused destination is never advertised, priced or not");
// No table: no shipping claim. The listing loses the merchant
// block rather than inventing a rate — the whole point of
// dropping the zone fallback.
auto bare = Views::RenderProduct(pr, Rates{});
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
"schema: with no carrier table the offer publishes no shipping");
Check(Json::Parse(bare.meta.jsonLd).has_value()
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: and the rest of the record still parses");
}
}
Check(!Content::Projects().empty(), "content: projects present");
@ -931,51 +1064,92 @@ void RunMoneySelfTest() {
}
// ── the Sendcloud response parser ─────────────────────────────────
// Weights are the kilogram strings the API sends; every Find() below asks
// for a parcel weight, because a price without a weight is not a thing this
// table has any more.
{
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
{"name":"Other Method","countries":[{"iso_2":"NL","price":1.00}]},
{"name":"DHL For You Home","countries":[
{"name":"Other Method","min_weight":"0.001","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":1.00}]},
{"name":"DHL For You Home","min_weight":"0.001","max_weight":"2.000",
"countries":[
{"iso_2":"NL","price":6.25},
{"iso_2":"DE","price":8.20},
{"iso_2":"CA","price":42.50},
{"iso_2":"XX","price":0},
{"iso_2":"TOOLONG","price":5.00}]}]})", "DHL For You");
{"iso_2":"TOOLONG","price":5.00}]},
{"name":"DHL For You Home","min_weight":"2.000","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":9.95},{"iso_2":"DE","price":13.40}]},
{"name":"DHL For You Home","countries":[{"iso_2":"BE","price":1.00}]}]})",
"DHL For You");
Check(table.method == "DHL For You Home", "sendcloud: method matched by substring");
Check(table.Find("NL") == 625, "sendcloud: NL price to cents");
Check(table.Find("DE") == 820, "sendcloud: 8.20 rounds exactly");
Check(table.Find("CA") == 4250, "sendcloud: CA price");
Check(table.Find("XX") == 0, "sendcloud: zero price dropped");
Check(table.Find("TOOLONG") == 0, "sendcloud: malformed iso dropped");
Check(table.Find("NL", 700) == 625, "sendcloud: NL price to cents");
Check(table.Find("DE", 700) == 820, "sendcloud: 8.20 rounds exactly");
Check(table.Find("CA", 700) == 4250, "sendcloud: CA price");
Check(table.Find("XX", 700) == 0, "sendcloud: zero price dropped");
Check(table.Find("TOOLONG", 700) == 0, "sendcloud: malformed iso dropped");
// The bug the old parser had: it stopped at the FIRST matching method,
// so every parcel was priced at whichever band came first and the
// heavier bands were invisible.
Check(table.Find("NL", 2100) == 995 && table.Find("DE", 2100) == 1340,
"sendcloud: every weight band of a matched method is kept");
Check(table.Find("NL", 11000) == 0,
"sendcloud: past the heaviest band there is no price");
Check(table.Find("BE", 700) == 0,
"sendcloud: a method with no weight range is unusable, not unlimited");
Check(Server::ParseSendcloudMethods("garbage", "x").perCountry.empty(),
"sendcloud: malformed payload yields nothing");
// Comma-separated merge: courier for Europe, post for the world; the
// earlier method keeps any country both cover.
// earlier FILTER keeps any country both cover — including that
// country's heavier bands, which must not leak in from the later one.
const auto merged = Server::ParseSendcloudMethods(R"({"shipping_methods":[
{"name":"DPD Home","countries":[
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000","countries":[
{"iso_2":"NL","price":7.13},{"iso_2":"DE","price":10.49}]},
{"name":"PostNL Parcels non-EU","countries":[
{"name":"PostNL Parcels non-EU","min_weight":"0.001","max_weight":"2.000",
"countries":[
{"iso_2":"CA","price":23.95},{"iso_2":"US","price":17.94},
{"iso_2":"DE","price":99.99}]}]})",
{"iso_2":"DE","price":99.99}]},
{"name":"PostNL Parcels non-EU","min_weight":"2.000","max_weight":"20.000",
"countries":[{"iso_2":"CA","price":48.10},{"iso_2":"DE","price":99.99}]}]})",
"DPD Home, PostNL Parcels non-EU");
Check(merged.Find("NL") == 713 && merged.Find("CA") == 2395,
"sendcloud: merged table covers both methods");
Check(merged.Find("DE") == 1049,
"sendcloud: earlier method wins a shared country");
Check(merged.Find("NL", 700) == 713 && merged.Find("CA", 700) == 2395,
"sendcloud: merged table covers both filters");
Check(merged.Find("CA", 5000) == 4810, "sendcloud: heavier band from the later filter");
Check(merged.Find("DE", 700) == 1049,
"sendcloud: earlier filter wins a shared country");
Check(merged.Find("DE", 12000) == 0,
"sendcloud: and owns it outright — no band from the loser");
Check(merged.method == "DPD Home + PostNL Parcels non-EU",
"sendcloud: merged method names recorded");
"sendcloud: merged method names recorded, deduplicated per band");
// Two services under one filter publishing the same ceiling: the
// cheaper is the only sensible quote, since both carry the parcel.
const auto dup = Server::ParseSendcloudMethods(R"({"shipping_methods":[
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":9.00}]},
{"name":"DPD Home Signed","min_weight":"0.001","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":7.50}]}]})", "DPD Home");
Check(dup.Find("NL", 700) == 750, "sendcloud: duplicate band keeps the cheaper");
}
// ── indicative conversion ─────────────────────────────────────────
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
auto ca$ = CurrencyFor("CA");
Check(ca$.has_value() && ca$->code == "CAD", "fx: CA -> CAD");
auto gbp = CurrencyFor("GB");
Check(gbp.has_value() && gbp->code == "GBP", "fx: GB -> GBP");
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion");
if (ca$) {
Check(FormatIndicative(*ca$, 920) == "≈ CA$920", "fx: display form");
if (gbp) {
Check(FormatIndicative(*gbp, 920) == "≈ £920", "fx: display form");
}
// A country the shop refuses gets no localised price either — the two
// tables are kept consistent on purpose, so this is a real invariant and
// not a coincidence of the current list.
for (const std::string_view cc : NoSaleCountries()) {
Check(!CurrencyFor(cc).has_value(),
"fx: refused destinations have no display currency", cc);
}
// ── order tokens and references ───────────────────────────────────
@ -989,7 +1163,7 @@ void RunMoneySelfTest() {
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
"reference: derived and uppercased");
// ── the wire-amount parser (bunq responses) ───────────────────────
// ── the wire-amount parser (both providers quote strings) ─────────
using Server::ParseAmountToMinor;
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
@ -1033,6 +1207,98 @@ void RunMoneySelfTest() {
"mollie: missing id rejected");
}
// ── the CoinGate order parser ─────────────────────────────────────
//
// The id is a JSON NUMBER at CoinGate, which is the one shape difference
// from Mollie that could silently produce an empty payment id — an order
// that can never be polled. Both spellings are pinned here.
{
const auto c1 = Server::ParseCoingateOrder(R"({
"id":538,"status":"new","title":"CC-ABCDEF catcrafts.net",
"price_amount":"578.30","price_currency":"EUR","receive_currency":"EUR",
"payment_url":"https://pay.coingate.com/invoice/abc-123"})");
Check(c1.has_value(), "coingate: new order parses");
if (c1) {
Check(c1->id == "538", "coingate: numeric id becomes decimal text");
Check(c1->status == "new", "coingate: status");
Check(c1->priceMinor == 57830, "coingate: price to cents");
Check(c1->payUrl == "https://pay.coingate.com/invoice/abc-123",
"coingate: payment url");
Check(c1->payCurrency.empty(), "coingate: no coin picked yet");
}
const auto c2 = Server::ParseCoingateOrder(R"({
"id":"539","status":"paid","pay_currency":"BTC",
"price_amount":"578.3","price_currency":"EUR"})");
Check(c2 && c2->id == "539", "coingate: string id also accepted");
Check(c2 && c2->status == "paid" && c2->payCurrency == "BTC",
"coingate: paid order carries the coin");
Check(c2 && c2->priceMinor == 57830,
"coingate: one-decimal amount is still cents");
const auto c3 = Server::ParseCoingateOrder(R"({
"id":540,"status":"paid","price_amount":"578.30","price_currency":"USD"})");
Check(c3 && c3->priceMinor == 0, "coingate: non-EUR amount refuses to count");
Check(!Server::ParseCoingateOrder("garbage").has_value(),
"coingate: malformed payload rejected");
Check(!Server::ParseCoingateOrder(R"({"status":"new"})").has_value(),
"coingate: missing id rejected");
Check(!Server::ParseCoingateOrder(R"({"id":541})").has_value(),
"coingate: missing status rejected");
}
// ── request provenance ────────────────────────────────────────────
//
// The rate limiter keys on this, so getting the WRONG end of the header
// is not a cosmetic bug: the leftmost entry is client-controlled, and
// trusting it would hand every attacker an endless supply of identities.
{
using Server::ClientAddressFromForwarded;
Check(ClientAddressFromForwarded("203.0.113.7") == "203.0.113.7",
"forwarded: single entry");
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
"forwarded: rightmost entry wins");
// The attack this exists to defeat: a client that sends its own header
// to look like a different peer. Caddy appends the truth on the right.
Check(ClientAddressFromForwarded("1.1.1.1, 2.2.2.2, 203.0.113.7") == "203.0.113.7",
"forwarded: spoofed prefix ignored");
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
"forwarded: padding trimmed");
Check(ClientAddressFromForwarded("2001:db8::1") == "2001:db8::1",
"forwarded: ipv6 passes through");
Check(ClientAddressFromForwarded("").empty(), "forwarded: empty stays empty");
// No header at all means nothing proxied this request; the caller must
// see an empty peer and fall back to the global budget.
Check(ClientAddressFromForwarded("198.51.100.4, ").empty(),
"forwarded: empty last entry is no peer");
}
{
using Server::OriginAllowed;
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net"),
"origin: same origin allowed");
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net/"),
"origin: trailing slash on the base normalised");
// A non-browser client (curl, the e2e suite) sends no Origin and
// cannot be a cross-site forgery — there is no session to ride on.
Check(OriginAllowed("", "https://catcrafts.net"), "origin: absent allowed");
Check(!OriginAllowed("https://evil.example", "https://catcrafts.net"),
"origin: foreign origin refused");
// Neither a subdomain nor a lookalike is us.
Check(!OriginAllowed("https://catcrafts.net.evil.example", "https://catcrafts.net"),
"origin: suffix lookalike refused");
Check(!OriginAllowed("https://shop.catcrafts.net", "https://catcrafts.net"),
"origin: subdomain refused");
// Scheme is part of an origin: http is not https.
Check(!OriginAllowed("http://catcrafts.net", "https://catcrafts.net"),
"origin: scheme mismatch refused");
// A sandboxed iframe posts Origin: null. Present, and not us.
Check(!OriginAllowed("null", "https://catcrafts.net"), "origin: null refused");
Check(!OriginAllowed("https://catcrafts.net", ""),
"origin: unconfigured base refuses rather than accepts all");
// dev.sh serves on localhost and sets --redirect-base to match.
Check(OriginAllowed("http://localhost:8080", "http://localhost:8080"),
"origin: dev localhost base matches");
}
// ── the invoice builder ───────────────────────────────────────────
{
Server::OrderRecord o;
@ -1071,7 +1337,7 @@ void RunMoneySelfTest() {
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
o.vatIncluded = false;
o.buyer.country = "CA";
o.buyer.country = "GB";
o.goodsMinor = 93107;
o.shippingMinor = 2395;
o.totalMinor = 95502;
@ -1123,7 +1389,7 @@ void RunMoneySelfTest() {
// The export wording mirrors the invoice's VAT treatment.
o.vatIncluded = false;
o.buyer.country = "CA";
o.buyer.country = "GB";
o.totalMinor = 95502;
const std::string exMail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
@ -1305,19 +1571,20 @@ int main(int argc, char** argv) {
// /var/lib/catcrafts, which is deliberately NOT the web root — that
// directory is publicly served and wiped by rsync --delete each deploy.
std::filesystem::path ordersPath = "orders.jsonl";
// Payment rail selection. Flags beat environment beats default. The
// default is "whichever provider has a key, off otherwise" so a box
// with no credentials serves the whole site minus checkout instead of
// refusing to start. Mollie outranks bunq: bunq.me's per-method limits
// (€500/card, nothing for non-EU buyers) disqualified it as the
// checkout; the client is kept for a possible future account sweep.
// 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.
//
// bank MOLLIE_API_KEY iDEAL, cards, transfer
// crypto COINGATE_API_KEY on-chain and Lightning, settled to EUR
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
const char* bunqKey = std::getenv("BUNQ_API_KEY");
std::string railMode = mollieKey && *mollieKey ? "mollie"
: bunqKey && *bunqKey ? "bunq"
: "off";
bool bunqSandbox = [] {
const char* v = std::getenv("BUNQ_SANDBOX");
const char* coingateKey = std::getenv("COINGATE_API_KEY");
std::string railMode = mollieKey && *mollieKey ? "mollie" : "off";
std::string cryptoMode = coingateKey && *coingateKey ? "coingate" : "off";
bool coingateSandbox = [] {
const char* v = std::getenv("COINGATE_SANDBOX");
return v && std::string_view(v) == "1";
}();
std::filesystem::path railState;
@ -1336,12 +1603,10 @@ int main(int argc, char** argv) {
ordersPath = a.substr(9);
} else if (a.starts_with("--rail=")) {
railMode = a.substr(7);
} else if (a.starts_with("--bunq=")) {
railMode = a.substr(7); // legacy alias for --rail=
} else if (a.starts_with("--crypto-rail=")) {
cryptoMode = a.substr(14);
} else if (a.starts_with("--rail-state=")) {
railState = a.substr(13);
} else if (a.starts_with("--bunq-state=")) {
railState = a.substr(13); // legacy alias for --rail-state=
} else if (a.starts_with("--redirect-base=")) {
redirectBase = a.substr(16);
} else {
@ -1400,30 +1665,51 @@ int main(int argc, char** argv) {
return 2;
}
// The rail. State (bunq session context, or the fake rail's paid
// marker; Mollie needs none) defaults next to the orders file — same
// directory, same lifecycle, same backup.
// The rails. State (only the fake rail has any — its paid marker;
// neither real provider needs a session or a keypair) defaults next to
// the orders file: same directory, same lifecycle, same backup.
if (railState.empty()) {
railState = ordersPath;
railState += (railMode == "fake") ? ".fake-paid" : ".bunq-state.json";
railState += ".fake-paid";
}
Server::RailConfig railCfg;
railCfg.mode = railMode;
railCfg.apiKey = railMode == "mollie" ? (mollieKey ? mollieKey : "")
: railMode == "bunq" ? (bunqKey ? bunqKey : "")
: "";
railCfg.sandbox = bunqSandbox;
railCfg.statePath = railState;
railCfg.redirectBase = redirectBase;
std::unique_ptr<Server::PaymentRail> rail = Server::MakeRail(railCfg);
if ((railMode == "mollie" || railMode == "bunq") && railCfg.apiKey.empty()) {
std::println(std::cerr,
"catcrafts-server: --rail={} but its API key env is not set — "
"refusing to start with a rail that cannot work", railMode);
// A mode whose credential is missing is a misconfiguration, not a
// reason to quietly serve a checkout that 502s at the last step. Both
// slots are checked the same way, and both name the env var they want.
auto build = [&](const std::string& mode, const char* key, const char* keyName,
bool sandbox, std::unique_ptr<Server::PaymentRail>& out) -> bool {
Server::RailConfig cfg;
cfg.mode = mode;
cfg.apiKey = key ? key : "";
cfg.sandbox = sandbox;
cfg.statePath = railState;
cfg.redirectBase = redirectBase;
const bool needsKey = mode == "mollie" || mode == "coingate";
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;
}
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
// shop that quietly stops taking one kind of money.
if (!out && mode != "off") {
std::println(std::cerr, "catcrafts-server: unknown rail '{}'", mode);
return false;
}
return true;
};
Server::PaymentRails rails;
if (!build(railMode, mollieKey, "MOLLIE_API_KEY", false, rails.bank)) return 2;
if (!build(cryptoMode, coingateKey, "COINGATE_API_KEY", coingateSandbox,
rails.crypto)) {
return 2;
}
Server::ConfigurePayments(std::move(rail), redirectBase);
Server::ConfigurePayments(std::move(rails), redirectBase);
// Invoice signing: the GPG key uid/fingerprint; GNUPGHOME decides the
// keyring. Unset means unsigned dev invoices with a visible marker.
@ -1442,9 +1728,11 @@ int main(int argc, char** argv) {
Server::ConfigureMail(std::move(mailCfg));
}
// Sendcloud is optional: without credentials the compiled-in zone
// table prices all shipping, which is exactly how dev and e2e run.
// With credentials the refresh thread fetches per-country rates.
// Sendcloud is the ONLY source of shipping prices: no credentials and
// no cached table means checkout refuses every order (loudly logged at
// startup). Dev and e2e get a table by writing the cache file next to
// the orders file by hand — same format the refresh writes, so no test
// hook exists for this and none can drift from production.
Server::ShippingConfig shipCfg;
if (const char* v = std::getenv("SENDCLOUD_PUBLIC_KEY")) shipCfg.publicKey = v;
if (const char* v = std::getenv("SENDCLOUD_SECRET_KEY")) shipCfg.secretKey = v;
@ -1457,7 +1745,7 @@ int main(int argc, char** argv) {
}
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
// the escape hatch for a payment bunq confirmed out-of-band (or a refund):
// the escape hatch for a payment confirmed out-of-band (or a refund):
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
if (!args.empty() && args[0] == "--orders") {
std::filesystem::path file = "orders.jsonl";
@ -1499,14 +1787,21 @@ int main(int argc, char** argv) {
std::println("orders: {}", orders.size());
if (orders.empty()) return 0;
std::println("");
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
"reference", "status", "total", "cc", "colour", "qty", "via",
"created", "token");
// `pay` is the rail the order was created on, `via` what actually
// settled it. Both, because they answer different questions: an order
// stuck awaiting needs the first (which provider's dashboard to open),
// and a paid one needs the second (whether the money can still be
// pulled back — cards can, iDEAL and crypto cannot).
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
"reference", "status", "total", "cc", "colour", "qty", "pay",
"via", "created", "token");
for (const auto& o : orders) {
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
o.reference, o.status, Money::FormatMinor(o.totalMinor),
o.buyer.country, o.color.empty() ? "-" : o.color,
o.quantity, o.paidVia.empty() ? "-" : o.paidVia,
o.quantity,
o.payChoice.empty() ? "-" : o.payChoice,
o.paidVia.empty() ? "-" : o.paidVia,
o.createdAt, o.token);
}
return 0;
@ -1514,10 +1809,12 @@ int main(int argc, char** argv) {
std::println("catcrafts-server: --selftest | --render <path> | --routes | --sitemap | --feed\n"
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
" [--rail=off|fake|mollie|bunq] [--rail-state=FILE] [--redirect-base=URL]\n"
" [--rail=off|fake|mollie] [--crypto-rail=off|fake-crypto|coingate]\n"
" [--rail-state=FILE] [--redirect-base=URL]\n"
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
"\n"
"environment: MOLLIE_API_KEY (test_… or live_…), BUNQ_API_KEY, BUNQ_SANDBOX=1,\n"
"environment: MOLLIE_API_KEY (test_… or live_…) selects the bank rail,\n"
" COINGATE_API_KEY the crypto rail, COINGATE_SANDBOX=1,\n"
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
return 0;