catcrafts.net/server/implementations/Catcrafts.Server-Coingate.cpp
Jorijn van der Graaf 70668af8f5
All checks were successful
Deploy / build-deploy (push) Successful in 3m10s
coingate
2026-08-13 23:34:19 +02:00

286 lines
13 KiB
C++

/*
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<unsigned char>(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<char>(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<CoingateOrder> 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<std::int64_t>(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<PaymentLink> 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<std::string> 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<PaidStatus> 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<std::string> 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<std::string> Call(std::string_view method, const std::string& path,
const std::string& body) {
try {
if (!client_) {
client_ = std::make_unique<Crafter::ClientHTTP1>(
host_, static_cast<std::uint16_t>(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<Crafter::ClientHTTP1> client_;
};
} // namespace
std::unique_ptr<PaymentRail> MakeCoingateRail(const RailConfig& config) {
return std::make_unique<CoingateRail>(config);
}
} // namespace Catcrafts::Server