/* catcrafts.net Copyright (C) 2026 Catcrafts The source code of this website is made available for viewing purposes only. No permission is granted to copy, modify, distribute, or create derivative works. */ // The CoinGate payment rail — the crypto half of the checkout. // // Why a processor rather than a self-hosted node: accepting crypto for goods // makes this shop a MERCHANT, not a crypto-asset service provider, under either // arrangement. What differs is everything around it. CoinGate is MiCA-licensed // (mandatory to serve EU clients since 1 July 2026) and settles EUR to the // business account by SEPA at a locked rate, so the money that lands is the // money the invoice says, the bookkeeping line is identical to Mollie's, and no // coin ever sits on this balance sheet waiting to move in price. A self-hosted // BTCPay would cost 1% less and a Bitcoin node's worth of operations, custody // and per-payment revaluation — a trade worth making for sovereignty, not for a // tail of international orders. // // Which is why receive_currency is EUR below and not DO_NOT_CONVERT: that one // parameter is the whole difference between "a second Mollie" and "the shop now // holds crypto". Changing it is a tax decision, not a code cleanup. // // The API is the same shape as Mollie's, so this client is the same shape as // that one: // // POST /api/v2/orders {price_amount, price_currency, …} -> id + payment_url // GET /api/v2/orders/{id} -> status, pay_currency // // Two differences from Mollie worth knowing. Requests are form-encoded, which // is what every CoinGate example uses and what their v2 API is documented // against — the responses are JSON either way, and JSON is the only direction // that matters here, since it is the one carrying money. And their ids are JSON // NUMBERS, not strings, so the parser renders them to decimal (see // ParseCoingateOrder) and the ledger stores text like it does for every rail. // // Trust direction is the design rule and is unchanged: CoinGate can be told a // callback_url and it is deliberately NOT given one. An order becomes paid only // when an authenticated GET says status=paid over a covering EUR amount. // Crypto invoices die fast — two hours before a coin is picked, twenty minutes // after — so Dead is a state this rail reaches far more often than Mollie does, // and the reconciler lapsing those orders is the normal case rather than an // exception. module; module Catcrafts.Server; import std; import Catcrafts.Shared; import Crafter.Network; using namespace Crafter; namespace Catcrafts::Server { namespace { // Percent-encode one form value. Unreserved characters pass; everything else // becomes %XX, including the space (rather than '+', which is only correct in // a query string and is one of those differences that works until it doesn't). std::string FormEncode(std::string_view s) { static constexpr std::string_view kHex = "0123456789ABCDEF"; std::string out; out.reserve(s.size() + 8); for (const char c : s) { const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~'; if (unreserved) { out += c; } else { const auto byte = static_cast(c); out += '%'; out += kHex[byte >> 4]; out += kHex[byte & 0x0f]; } } return out; } // Ticker symbols arrive uppercase ("BTC"); the ledger's via column is lowercase // everywhere else ("ideal", "creditcard"), and a column that shouts in one row // and whispers in the next is just noise to read past. std::string LowerAscii(std::string_view s) { std::string out(s); for (char& c : out) { if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); } return out; } // CoinGate caps title at 150 characters and description at 500. Both are built // from the order reference here and come nowhere near either, but a truncating // helper means a future longer description degrades to a shorter one rather // than to a 422 at checkout — with the buyer already committed. std::string Clamp(std::string_view s, std::size_t max) { return std::string(s.substr(0, std::min(s.size(), max))); } } // namespace std::optional ParseCoingateOrder(std::string_view json) { auto doc = Json::Parse(json); if (!doc || !doc->IsObject()) return std::nullopt; CoingateOrder o; // The id arrives as a number. Accept a string too: costing a live checkout // over a provider changing a field's JSON type would be an absurd way to // lose a sale, and either spelling names the same order. if (const Json::Value* id = doc->Find("id")) { if (id->type == Json::Type::String) { o.id = id->string; } else if (id->type == Json::Type::Number) { o.id = std::format("{}", static_cast(id->number)); } } o.status = std::string(doc->Str("status")); o.payCurrency = std::string(doc->Str("pay_currency")); o.payUrl = std::string(doc->Str("payment_url")); if (o.id.empty() || o.status.empty()) return std::nullopt; // Only euro-priced orders are ever created, so anything else failing to // parse to zero is the safe outcome — a zero amount never satisfies an // order total. Note this is price_amount (what the buyer owed) and not // receive_amount (what lands after conversion and fee): the question being // asked is whether the buyer paid their invoice, not what the shop nets. if (doc->Str("price_currency") == "EUR") { if (auto minor = ParseAmountToMinor(doc->Str("price_amount"))) { o.priceMinor = *minor; } } return o; } namespace { class CoingateRail final : public PaymentRail { public: explicit CoingateRail(RailConfig cfg) : cfg_(std::move(cfg)), host_(cfg_.sandbox ? "api-sandbox.coingate.com" : "api.coingate.com") {} std::optional CreateLink(std::int64_t amountMinor, const std::string& description, const std::string& redirectUrl) override { std::lock_guard lock(mutex_); // receive_currency=EUR is the settlement decision; see the header. // No callback_url on purpose: state comes from the poll, never from // something that arrives unbidden claiming an order was paid. const std::string body = std::format( "price_amount={}&price_currency=EUR&receive_currency=EUR" "&title={}&description={}&order_id={}&success_url={}&cancel_url={}", FormEncode(Money::FormatMinor(amountMinor)), FormEncode(Clamp(description, 150)), FormEncode(Clamp(description, 500)), FormEncode(Clamp(description, 255)), FormEncode(redirectUrl), FormEncode(redirectUrl)); const std::optional res = Call("POST", "/api/v2/orders", body); if (!res) return std::nullopt; const auto order = ParseCoingateOrder(*res); if (!order || order->payUrl.empty()) { std::println(std::cerr, "coingate: create returned no payment url"); return std::nullopt; } PaymentLink link; link.payId = order->id; link.payUrl = order->payUrl; return link; } std::optional CheckPaid(const std::string& payId, std::int64_t expectedMinor) override { std::lock_guard lock(mutex_); // CoinGate ids are decimal integers. The id came from them, but it // travels through our ledger — keep the path composition strict anyway. if (payId.empty()) return PaidStatus{ PayState::Dead, {} }; for (const char c : payId) { if (c < '0' || c > '9') return PaidStatus{ PayState::Dead, {} }; } const std::optional res = Call("GET", "/api/v2/orders/" + payId, {}); if (!res) return std::nullopt; const auto order = ParseCoingateOrder(*res); if (!order) return std::nullopt; PaidStatus out; // What settled it, for the ledger's "via" column: the coin the shopper // actually paid in ("BTC" -> "bitcoin" is their business, not ours — // the ticker is the honest record). Empty until a coin is picked. out.method = order->payCurrency.empty() ? std::string("crypto") : LowerAscii(order->payCurrency); const std::string_view status = order->status; if (status == "paid" && order->priceMinor >= expectedMinor) { out.state = PayState::Paid; } else if (status == "new" || status == "pending" || status == "confirming") { // Still in flight. "confirming" is the blockchain-confirmation // wait: the money is visible but not final, and this rail does not // treat visible as received. out.state = PayState::Pending; } else if (status == "refunded" || status == "partially_refunded") { // Paid and then given back — which, seen from an order still // awaiting payment, means the reconciler missed the entire paid // window (a long outage) and the money has since left again. Lapse // it rather than confirm an order whose payment was undone, and say // so loudly: this is the one case where --mark-paid may be the // right answer and only a human can tell. std::println(std::cerr, "coingate: order {} is {} — lapsing; confirm by hand if " "the refund was partial and the goods still ship", payId, status); out.state = PayState::Dead; } else if (status == "invalid" || status == "expired" || status == "canceled") { out.state = PayState::Dead; } else { // An unknown status is not a licence to guess. Pending means "ask // again", which is the only safe reading of a word we do not know. std::println(std::cerr, "coingate: order {} has unknown status '{}'", payId, status); out.state = PayState::Pending; } return out; } std::string_view Name() const override { return "coingate"; } // Slower than Mollie's ten seconds: a crypto payment waits on block // confirmations, so there is nothing a faster sweep could learn. The // buyer's own arrival at the order page still polls once immediately. std::chrono::seconds PollInterval() const override { return std::chrono::seconds(20); } private: // One HTTPS call; nullopt on transport failure or a non-2xx answer. The // reconciler treats nullopt as "unknown, retry" — never as unpaid or dead. std::optional Call(std::string_view method, const std::string& path, const std::string& body) { try { if (!client_) { client_ = std::make_unique( host_, static_cast(443), Crafter::TLSClientCredentials{}); } Crafter::HTTPRequest req; req.method = std::string(method); req.path = path; req.authority = host_; req.body = body; // Not "Bearer": CoinGate's scheme word is literally "Token". req.headers["authorization"] = "Token " + cfg_.apiKey; req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)"; req.headers["accept"] = "application/json"; if (!body.empty()) { req.headers["content-type"] = "application/x-www-form-urlencoded"; } const Crafter::HTTPResponse res = client_->Send(req); if (res.status.size() != 3 || res.status[0] != '2') { std::println(std::cerr, "coingate: {} {} -> {} {}", method, path, res.status, res.body.substr(0, 200)); return std::nullopt; } return res.body; } catch (const std::exception& e) { std::println(std::cerr, "coingate: {} {} failed: {}", method, path, e.what()); client_.reset(); // dial fresh next time return std::nullopt; } } RailConfig cfg_; std::string host_; std::mutex mutex_; std::unique_ptr client_; }; } // namespace std::unique_ptr MakeCoingateRail(const RailConfig& config) { return std::make_unique(config); } } // namespace Catcrafts::Server