This commit is contained in:
parent
fb2f6079cc
commit
934c94cb5c
50 changed files with 10464 additions and 758 deletions
258
server/implementations/Catcrafts.Server-Shipping.cpp
Normal file
258
server/implementations/Catcrafts.Server-Shipping.cpp
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Live shipping rates from Sendcloud, with the zone table as the floor.
|
||||
//
|
||||
// Shape: GET /api/v2/shipping_methods (basic auth) returns every method the
|
||||
// account can book, each with a per-country price list. One configured method
|
||||
// (matched by name substring) becomes a country -> cents table, cached to disk
|
||||
// and refreshed daily by a background thread the HTTP layer starts.
|
||||
//
|
||||
// Failure posture mirrors the rest of the build pipeline: Sendcloud being
|
||||
// down, slow, or unconfigured NEVER breaks checkout — the compiled-in zone
|
||||
// table (Catcrafts.Shared:Content) answers instead. A stale cached table
|
||||
// beats both, which is why the cache survives restarts.
|
||||
//
|
||||
// Like the bunq rail, this code has not run against the real API — no
|
||||
// credentials existed at build time. ParseSendcloudMethods is exercised by the
|
||||
// self-test against a canned response; the fetch around it is thin.
|
||||
|
||||
module;
|
||||
module Catcrafts.Server;
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace Catcrafts::Server {
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex gShipMutex;
|
||||
ShippingConfig gShipConfig;
|
||||
ShippingTable gShipTable;
|
||||
bool gShipConfigured = false;
|
||||
|
||||
// Same alphabet as the bunq helper; duplicated rather than shared because
|
||||
// each implementation unit keeps its internals to itself.
|
||||
std::string Base64S(std::string_view in) {
|
||||
static constexpr char tbl[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
std::size_t i = 0;
|
||||
const auto* d = reinterpret_cast<const unsigned char*>(in.data());
|
||||
for (; i + 2 < in.size(); i += 3) {
|
||||
const std::uint32_t n = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += tbl[(n >> 6) & 63]; out += tbl[n & 63];
|
||||
}
|
||||
if (i + 1 == in.size()) {
|
||||
const std::uint32_t n = d[i] << 16;
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63]; out += "==";
|
||||
} else if (i + 2 == in.size()) {
|
||||
const std::uint32_t n = (d[i] << 16) | (d[i + 1] << 8);
|
||||
out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
|
||||
out += tbl[(n >> 6) & 63]; out += '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string NowIsoS() {
|
||||
return std::format("{:%FT%TZ}", std::chrono::floor<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now()));
|
||||
}
|
||||
|
||||
void SaveCacheLocked() {
|
||||
if (gShipConfig.cachePath.empty()) return;
|
||||
std::ofstream out(gShipConfig.cachePath, std::ios::trunc | std::ios::binary);
|
||||
if (!out) return;
|
||||
out << std::format(R"({{"method":"{}","fetched_at":"{}","per_country":{{)",
|
||||
gShipTable.method, gShipTable.fetchedAt);
|
||||
bool first = true;
|
||||
for (const auto& [cc, minor] : gShipTable.perCountry) {
|
||||
out << std::format(R"({}"{}":{})", first ? "" : ",", cc, minor);
|
||||
first = false;
|
||||
}
|
||||
out << "}}\n";
|
||||
}
|
||||
|
||||
void LoadCacheLocked() {
|
||||
std::ifstream in(gShipConfig.cachePath, std::ios::binary);
|
||||
if (!in) return;
|
||||
std::ostringstream buf;
|
||||
buf << in.rdbuf();
|
||||
auto doc = Json::Parse(buf.str());
|
||||
if (!doc || !doc->IsObject()) return;
|
||||
ShippingTable t;
|
||||
t.method = std::string(doc->Str("method"));
|
||||
t.fetchedAt = std::string(doc->Str("fetched_at"));
|
||||
if (const Json::Value* m = doc->Find("per_country"); m && m->IsObject()) {
|
||||
for (const auto& [k, v] : m->object) {
|
||||
if (v.type == Json::Type::Number && v.number > 0) {
|
||||
t.perCountry.emplace_back(k, static_cast<std::int64_t>(v.number));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!t.perCountry.empty()) gShipTable = std::move(t);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// `methodName` is a comma-separated list of name substrings, merged in order
|
||||
// with FIRST MATCH PER COUNTRY winning. One method rarely covers a whole
|
||||
// market: the realistic setup is a courier inside Europe and post beyond
|
||||
// ("DPD Home,PostNL Parcels non-EU"), and the order encodes the preference —
|
||||
// a country served by both gets the earlier method's price.
|
||||
ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName) {
|
||||
ShippingTable out;
|
||||
auto doc = Json::Parse(json);
|
||||
if (!doc || !doc->IsObject()) return out;
|
||||
const Json::Value* methods = doc->Find("shipping_methods");
|
||||
if (!methods || !methods->IsArray()) return out;
|
||||
|
||||
std::vector<std::string_view> filters;
|
||||
{
|
||||
std::string_view rest = methodName;
|
||||
while (!rest.empty()) {
|
||||
const std::size_t comma = rest.find(',');
|
||||
std::string_view part = rest.substr(0, comma);
|
||||
while (!part.empty() && part.front() == ' ') part.remove_prefix(1);
|
||||
while (!part.empty() && part.back() == ' ') part.remove_suffix(1);
|
||||
if (!part.empty()) filters.push_back(part);
|
||||
if (comma == std::string_view::npos) break;
|
||||
rest = rest.substr(comma + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const std::string_view filter : filters) {
|
||||
for (const Json::Value& method : methods->array) {
|
||||
if (!method.IsObject()) continue;
|
||||
const std::string_view name = method.Str("name");
|
||||
if (name.find(filter) == std::string_view::npos) continue;
|
||||
|
||||
if (!out.method.empty()) out.method += " + ";
|
||||
out.method += std::string(name);
|
||||
if (const Json::Value* countries = method.Find("countries");
|
||||
countries && countries->IsArray()) {
|
||||
for (const Json::Value& c : countries->array) {
|
||||
if (!c.IsObject()) continue;
|
||||
std::string cc(c.Str("iso_2"));
|
||||
if (cc.size() != 2) continue;
|
||||
// Earlier methods own their countries — a later method
|
||||
// never overrides.
|
||||
if (out.Find(cc) > 0) continue;
|
||||
// Sendcloud sends the price as a JSON number of euros.
|
||||
// Money stays integer everywhere else; this one boundary
|
||||
// rounds a decimal that is exact to the cent in a double
|
||||
// (shipping prices are far inside the safe range), and
|
||||
// llround guards the representation edge
|
||||
// (8.20*100 == 819.999...).
|
||||
const Json::Value* price = c.Find("price");
|
||||
if (!price || price->type != Json::Type::Number) continue;
|
||||
const std::int64_t minor = std::llround(price->number * 100.0);
|
||||
if (minor <= 0) continue;
|
||||
out.perCountry.emplace_back(std::move(cc), minor);
|
||||
}
|
||||
}
|
||||
break; // first method matching THIS filter wins; next filter
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void ConfigureShipping(const ShippingConfig& config) {
|
||||
std::lock_guard lock(gShipMutex);
|
||||
gShipConfig = config;
|
||||
gShipConfigured = !config.publicKey.empty() && !config.secretKey.empty()
|
||||
&& !config.methodName.empty();
|
||||
LoadCacheLocked();
|
||||
if (gShipConfigured) {
|
||||
std::println(std::cerr,
|
||||
"shipping: sendcloud configured (method filter '{}'){}",
|
||||
config.methodName,
|
||||
gShipTable.perCountry.empty()
|
||||
? ""
|
||||
: std::format(", cached table: {} countries from {}",
|
||||
gShipTable.perCountry.size(),
|
||||
gShipTable.fetchedAt));
|
||||
}
|
||||
}
|
||||
|
||||
std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
|
||||
std::int64_t zoneEu, std::int64_t zoneWorld) {
|
||||
{
|
||||
std::lock_guard lock(gShipMutex);
|
||||
if (const std::int64_t live = gShipTable.Find(country); live > 0) return live;
|
||||
}
|
||||
return Money::ZoneShipping(zoneNl, zoneEu, zoneWorld, country);
|
||||
}
|
||||
|
||||
ShippingTable CurrentShippingTable() {
|
||||
std::lock_guard lock(gShipMutex);
|
||||
return gShipTable;
|
||||
}
|
||||
|
||||
// Called by the HTTP layer's refresh thread. One authenticated GET; on any
|
||||
// failure the previous table (cached or zone fallback) simply stays.
|
||||
void RefreshShippingTable() {
|
||||
ShippingConfig cfg;
|
||||
{
|
||||
std::lock_guard lock(gShipMutex);
|
||||
if (!gShipConfigured) return;
|
||||
cfg = gShipConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
Crafter::ClientHTTP1 client("panel.sendcloud.sc",
|
||||
static_cast<std::uint16_t>(443),
|
||||
Crafter::TLSClientCredentials{});
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = "GET";
|
||||
req.path = "/api/v2/shipping_methods";
|
||||
req.authority = "panel.sendcloud.sc";
|
||||
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
|
||||
req.headers["authorization"] =
|
||||
"Basic " + Base64S(cfg.publicKey + ":" + cfg.secretKey);
|
||||
const Crafter::HTTPResponse res = client.Send(req);
|
||||
if (res.status != "200") {
|
||||
std::println(std::cerr, "shipping: sendcloud answered {}", res.status);
|
||||
return;
|
||||
}
|
||||
ShippingTable t = ParseSendcloudMethods(res.body, cfg.methodName);
|
||||
if (t.perCountry.empty()) {
|
||||
std::println(std::cerr,
|
||||
"shipping: no method matching '{}' with prices in the response",
|
||||
cfg.methodName);
|
||||
return;
|
||||
}
|
||||
// Sendcloud rates are the shop's ex-VAT COST. What the buyer is
|
||||
// charged must NET that cost: EU destinations are grossed up by the
|
||||
// VAT rate here (€7.13 -> €8.63; the difference is remitted, the cost
|
||||
// is covered), non-EU postage is zero-rated so cost is charged as-is.
|
||||
// Done once at table build — the cache stores consumer prices, so a
|
||||
// cache reload must not (and does not) gross up again.
|
||||
for (auto& [cc, minor] : t.perCountry) {
|
||||
if (Money::IsEuCountry(cc)) minor = Money::GrossFromNet(minor);
|
||||
}
|
||||
t.fetchedAt = NowIsoS();
|
||||
{
|
||||
std::lock_guard lock(gShipMutex);
|
||||
gShipTable = std::move(t);
|
||||
SaveCacheLocked();
|
||||
}
|
||||
std::println(std::cerr, "shipping: table refreshed ({} countries, method '{}')",
|
||||
CurrentShippingTable().perCountry.size(),
|
||||
CurrentShippingTable().method);
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "shipping: refresh failed: {}", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Catcrafts::Server
|
||||
Loading…
Reference in a new issue