/* 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. The only source of shipping prices. // // Shape: GET /api/v2/shipping_methods (basic auth) returns every method the // account can book, each with its weight range and a per-country price list. // The SAME carrier service appears once per weight band, so the configured // methods (matched by name substring) become a country -> bracket-ladder // table, cached to disk and refreshed daily by a background thread the HTTP // layer starts. // // Failure posture, and it is a real trade: there is no compiled-in fallback // any more. A country with no bracket, or a parcel heavier than every bracket, // is REFUSED at checkout. That is the honest answer — a rate the carrier does // not offer is a parcel that cannot be posted, and quoting one anyway sells an // order that has to be refunded or absorbed. The cost is that an empty table // means an unsellable shop, which is why the disk cache is load-bearing: it is // read at startup whether or not credentials exist, so an outage keeps selling // at the last known prices, and a hand-placed cache file is how dev and e2e // get a table with no account at all. // // 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. The one // thing to verify against a live payload is the weight fields: this reads // `min_weight`/`max_weight` as kilogram strings, which is what the v2 docs // describe, and a method missing them is skipped rather than guessed at. 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; // 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(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::system_clock::now())); } // Cache format: {"method":..,"fetched_at":..,"per_country":{"NL":[[2000,895],..]}} // where each pair is [maxWeightGrams, consumerCents]. Grams and cents, not the // kilograms and euros the API speaks, because everything downstream of the // parse is integer. // // This is also the documented way to run without an account: writing this file // by hand gives the server a complete rate table. 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 Money::ShipRates& row : gShipTable.perCountry) { out << std::format(R"({}"{}":[)", first ? "" : ",", row.cc); for (std::size_t i = 0; i < row.brackets.size(); ++i) { out << std::format("{}[{},{}]", i ? "," : "", row.brackets[i].maxWeightGrams, row.brackets[i].minor); } out << ']'; 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) { // A pre-brackets cache (country -> flat cents) parses fine as JSON // but is not a ladder, so it lands here as "not an array" and is // dropped. Correct: those numbers were a single unknown weight // band, and re-serving them would price parcels by guess. The next // refresh rewrites the file. if (!v.IsArray() || k.size() != 2) continue; Money::ShipRates row; row.cc = k; for (const Json::Value& b : v.array) { if (!b.IsArray() || b.array.size() != 2) continue; if (b.array[0].type != Json::Type::Number || b.array[1].type != Json::Type::Number) continue; const auto grams = static_cast(b.array[0].number); const auto minor = static_cast(b.array[1].number); if (grams > 0 && minor > 0) row.brackets.push_back({ grams, minor }); } if (!row.brackets.empty()) t.perCountry.push_back(std::move(row)); } } if (!t.perCountry.empty()) gShipTable = std::move(t); } // "2.001" (kilograms, as the API sends them) -> 2001 grams. Accepts a JSON // number too, in case the field is not always a string. Returns 0 for anything // unparseable, which the caller treats as "this method has no usable weight // range" and skips — a bracket with an invented ceiling is exactly the kind of // guess this module no longer makes. std::int64_t KgFieldToGrams(const Json::Value* v) { if (!v) return 0; if (v->type == Json::Type::Number) return std::llround(v->number * 1000.0); if (v->type != Json::Type::String) return 0; // Hand-rolled rather than from_chars: the value is a fixed-point // decimal and this keeps it exact, the same reason money never touches a // float here. std::string_view s = v->string; std::int64_t whole = 0, frac = 0, scale = 1; std::size_t i = 0; for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) { whole = whole * 10 + (s[i] - '0'); if (whole > 1'000'000) return 0; // absurd; treat as unusable } if (i == 0) return 0; if (i < s.size() && s[i] == '.') { ++i; for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) { if (scale <= 100) { frac = frac * 10 + (s[i] - '0'); scale *= 10; } } } if (i != s.size()) return 0; // trailing junk while (scale <= 100) { frac *= 10; scale *= 10; } // normalise to 1/1000 return whole * 1000 + frac; } } // namespace // `methodName` is a comma-separated list of name substrings, merged in order // with the FIRST FILTER TO COVER A COUNTRY winning it. 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 filter's prices. // // Every method matching a filter contributes, NOT just the first. Sendcloud // publishes one entry per weight band of the same service, so the matches for // "DPD Home" are that service's ladder and taking only one of them would price // every parcel at whichever band the response happened to list first — the qty // 1 rate included. 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 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); } } auto ladderFor = [&](const std::string& cc) -> std::vector& { for (Money::ShipRates& r : out.perCountry) { if (r.cc == cc) return r.brackets; } out.perCountry.push_back({ cc, {} }); return out.perCountry.back().brackets; }; std::vector names; // unique, for the operator log for (const std::string_view filter : filters) { // Snapshot of who is already covered: ownership is per FILTER, so a // later filter may not touch a country an earlier one priced, but the // bands within this filter must all reach the countries they cover. std::vector owned; for (const Money::ShipRates& r : out.perCountry) owned.push_back(r.cc); 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; // The band ceiling. A method that does not state one is unusable: // without it there is no way to know which parcels the price // covers, and assuming "any" is the guess this module exists to // avoid. const std::int64_t maxGrams = KgFieldToGrams(method.Find("max_weight")); if (maxGrams <= 0) continue; if (std::ranges::find(names, name) == names.end()) names.emplace_back(name); const Json::Value* countries = method.Find("countries"); if (!countries || !countries->IsArray()) continue; for (const Json::Value& c : countries->array) { if (!c.IsObject()) continue; std::string cc(c.Str("iso_2")); if (cc.size() != 2) continue; if (std::ranges::find(owned, cc) != owned.end()) 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; std::vector& ladder = ladderFor(cc); // Two methods under one filter can publish the same ceiling // (a service and its signed-for variant, say). Keep the // cheaper: both carry the parcel, so the dearer one is never // the right quote. auto same = std::ranges::find(ladder, maxGrams, &Money::ShipBracket::maxWeightGrams); if (same != ladder.end()) { same->minor = std::min(same->minor, minor); } else { ladder.push_back({ maxGrams, minor }); } } } } for (Money::ShipRates& r : out.perCountry) { std::ranges::sort(r.brackets, {}, &Money::ShipBracket::maxWeightGrams); } for (const std::string& n : names) { if (!out.method.empty()) out.method += " + "; out.method += n; } 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)); } // Said loudly because it is not a degraded mode, it is a shop that cannot // take an order: with no rate table every checkout refuses. Not fatal — // the rest of the site is worth serving, and the refresh below may fix it // seconds later — but an operator who sees this and does nothing has a // storefront selling nothing. if (gShipTable.perCountry.empty()) { std::println(std::cerr, "shipping: NO RATE TABLE — checkout will refuse every order " "until Sendcloud answers{}", gShipConfigured ? "" : " (no credentials configured; a " "hand-written cache file also works)"); } } std::optional ShipCostFor(std::string_view country, std::int64_t grams) { std::lock_guard lock(gShipMutex); const std::int64_t rate = gShipTable.Find(country, grams); if (rate <= 0) return std::nullopt; return rate; } 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 none at all) 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(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. Every bracket // gets it, since any of them can be the one a parcel is quoted at. for (Money::ShipRates& row : t.perCountry) { if (!Money::IsEuCountry(row.cc)) continue; for (Money::ShipBracket& b : row.brackets) b.minor = Money::GrossFromNet(b.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