286 lines
13 KiB
C++
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
|