This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -6,21 +6,31 @@ 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.
|
||||
// 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 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.
|
||||
// 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 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.
|
||||
// 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.
|
||||
//
|
||||
// Like the bunq rail, this code has not run against the real API — no
|
||||
// Like the CoinGate 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.
|
||||
// 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;
|
||||
|
|
@ -40,8 +50,8 @@ 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.
|
||||
// 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+/";
|
||||
|
|
@ -69,6 +79,13 @@ std::string NowIsoS() {
|
|||
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);
|
||||
|
|
@ -76,8 +93,13 @@ void SaveCacheLocked() {
|
|||
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);
|
||||
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";
|
||||
|
|
@ -95,21 +117,72 @@ void LoadCacheLocked() {
|
|||
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));
|
||||
// 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<std::int64_t>(b.array[0].number);
|
||||
const auto minor = static_cast<std::int64_t>(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<double>: 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 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.
|
||||
// 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);
|
||||
|
|
@ -131,39 +204,77 @@ ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view meth
|
|||
}
|
||||
}
|
||||
|
||||
auto ladderFor = [&](const std::string& cc) -> std::vector<Money::ShipBracket>& {
|
||||
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<std::string> 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<std::string> 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;
|
||||
|
||||
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);
|
||||
// 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<Money::ShipBracket>& 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 });
|
||||
}
|
||||
}
|
||||
break; // first method matching THIS filter wins; next filter
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -183,15 +294,25 @@ void ConfigureShipping(const ShippingConfig& config) {
|
|||
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::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);
|
||||
std::optional<std::int64_t> 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() {
|
||||
|
|
@ -200,7 +321,7 @@ ShippingTable CurrentShippingTable() {
|
|||
}
|
||||
|
||||
// Called by the HTTP layer's refresh thread. One authenticated GET; on any
|
||||
// failure the previous table (cached or zone fallback) simply stays.
|
||||
// failure the previous table (cached, or none at all) simply stays.
|
||||
void RefreshShippingTable() {
|
||||
ShippingConfig cfg;
|
||||
{
|
||||
|
|
@ -237,9 +358,11 @@ void RefreshShippingTable() {
|
|||
// 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);
|
||||
// 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();
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue