This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -46,8 +46,8 @@ export constexpr std::int64_t GrossFromNet(std::int64_t netMinor,
|
|||
return (netMinor * (10000 + rateBp) + 5000) / 10000;
|
||||
}
|
||||
|
||||
// "580.00" — the wire format bunq's amount objects use, and the unambiguous
|
||||
// way to show cents. Always two decimals, no thousands separator.
|
||||
// "580.00" — the wire format both payment providers quote amounts in, and the
|
||||
// unambiguous way to show cents. Always two decimals, no thousands separator.
|
||||
export std::string FormatMinor(std::int64_t minor) {
|
||||
const bool neg = minor < 0;
|
||||
if (neg) minor = -minor;
|
||||
|
|
@ -82,9 +82,33 @@ export bool IsEuCountry(std::string_view cc) {
|
|||
return std::ranges::find(EuCountries(), cc) != EuCountries().end();
|
||||
}
|
||||
|
||||
// Shipping zones. Three tiers is deliberate — real carrier pricing has more
|
||||
// distinctions than anyone wants in a checkout, and the tiers only need to be
|
||||
// roughly right because the rates are set per product with margin.
|
||||
// Destinations this shop refuses outright.
|
||||
//
|
||||
// Not a carrier problem — parcels reach both fine, and the rate tables price
|
||||
// them. It is an insurance boundary: liability cover for a Dutch shop is
|
||||
// written "worldwide excluding USA/Canada", and a phone carrying a lithium
|
||||
// battery and a replaced OS is precisely the product-liability exposure that
|
||||
// exclusion exists for. A sale into either country would therefore be
|
||||
// uninsured, with the buyer's own courts deciding the damages, so checkout
|
||||
// declines it instead of pricing it. Declining also keeps Catcrafts from
|
||||
// having marketed into those jurisdictions at all, which is the other half of
|
||||
// why the answer is no rather than a surcharge.
|
||||
export std::span<const std::string_view> NoSaleCountries() {
|
||||
static constexpr std::array<std::string_view, 2> blocked{ "US", "CA" };
|
||||
return blocked;
|
||||
}
|
||||
|
||||
// ISO 3166-1 alpha-2, uppercase, like everything else here. Callers ask this
|
||||
// rather than comparing against "US" themselves, so the policy has exactly one
|
||||
// definition and adding a country later is a one-line change.
|
||||
export bool SellsTo(std::string_view cc) {
|
||||
return std::ranges::find(NoSaleCountries(), cc) == NoSaleCountries().end();
|
||||
}
|
||||
|
||||
// Delivery-time tiers. NOT a price concept — every rate comes from the carrier
|
||||
// (see ShipBracket below). This exists because Sendcloud's method list carries
|
||||
// no transit estimate, so the "1-2 / 2-5 / 5-14 days" the listing publishes is
|
||||
// ours to state, and distance is the only thing it can reasonably key on.
|
||||
export enum class Zone { Nl, Eu, World };
|
||||
|
||||
export Zone ZoneFor(std::string_view cc) {
|
||||
|
|
@ -92,6 +116,69 @@ export Zone ZoneFor(std::string_view cc) {
|
|||
return IsEuCountry(cc) ? Zone::Eu : Zone::World;
|
||||
}
|
||||
|
||||
// ── carrier rates ─────────────────────────────────────────────────────
|
||||
//
|
||||
// One weight bracket of one carrier method: what a parcel up to
|
||||
// `maxWeightGrams` costs to this country, in cents, already grossed up to the
|
||||
// consumer price (the server does that once, when it builds the table).
|
||||
//
|
||||
// Brackets exist because a carrier prices by weight, and Sendcloud lists the
|
||||
// same service once per band — so a country's rates arrive as a ladder, not a
|
||||
// single number. Nothing here is hardcoded: an empty ladder means the shop
|
||||
// cannot ship there, which is a refusal, not a fallback.
|
||||
export struct ShipBracket {
|
||||
std::int64_t maxWeightGrams = 0;
|
||||
std::int64_t minor = 0;
|
||||
};
|
||||
|
||||
// One destination's ladder. Kept as a flat vector of these rather than a map
|
||||
// so the table is trivially serialisable and the order the carrier gave is
|
||||
// preserved.
|
||||
export struct ShipRates {
|
||||
std::string cc;
|
||||
std::vector<ShipBracket> brackets;
|
||||
};
|
||||
|
||||
// The rate for a parcel of `grams` to a destination whose ladder this is, or 0
|
||||
// when nothing covers it — too heavy, or no rates at all.
|
||||
//
|
||||
// The cheapest bracket that can CARRY the weight wins, which is not always the
|
||||
// tightest one: a carrier's 20 kg band is occasionally priced below its 10 kg
|
||||
// band, and quoting the higher of the two would overcharge for a parcel both
|
||||
// accept. A band always accepts a parcel lighter than its maximum, so this
|
||||
// stays bookable at whatever it quotes.
|
||||
export std::int64_t RateFor(std::span<const ShipBracket> ladder, std::int64_t grams) {
|
||||
std::int64_t best = 0;
|
||||
for (const ShipBracket& b : ladder) {
|
||||
if (b.maxWeightGrams < grams) continue;
|
||||
if (best == 0 || b.minor < best) best = b.minor;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// How many units of `unitGrams` fit the heaviest bracket this destination has.
|
||||
// The quantity ceiling the buy form offers and the checkout enforces: one
|
||||
// order is one parcel, so anything above this is an email conversation rather
|
||||
// than a quote the shop cannot honour.
|
||||
export std::int64_t MaxUnitsFor(std::span<const ShipBracket> ladder,
|
||||
std::int64_t unitGrams) {
|
||||
if (unitGrams <= 0) return 0;
|
||||
std::int64_t heaviest = 0;
|
||||
for (const ShipBracket& b : ladder) heaviest = std::max(heaviest, b.maxWeightGrams);
|
||||
return heaviest / unitGrams;
|
||||
}
|
||||
|
||||
// Ladder lookup across a whole table. Linear because the table is one entry
|
||||
// per country the method covers — a couple of hundred at most, walked once per
|
||||
// checkout.
|
||||
export std::span<const ShipBracket> LadderFor(std::span<const ShipRates> table,
|
||||
std::string_view cc) {
|
||||
for (const ShipRates& r : table) {
|
||||
if (r.cc == cc) return r.brackets;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// One order's money, fully derived. `goods` is what the buyer pays for the
|
||||
// device: the VAT-inclusive price inside the EU, the derived net outside it.
|
||||
// `vatCharged` is what the total contains in Dutch VAT — zero for exports —
|
||||
|
|
@ -106,8 +193,9 @@ export struct Totals {
|
|||
|
||||
// The single authority on what an order costs. The checkout handler calls this
|
||||
// with the buyer's country; nothing about the amount ever comes from the
|
||||
// client. `shippingMinor` arrives already resolved (live carrier table or the
|
||||
// zone fallback — the server decides which), so this function stays pure.
|
||||
// client. `shippingMinor` arrives already resolved from the carrier table (see
|
||||
// RateFor) — an order with no carrier rate is refused before it gets here, so
|
||||
// this function never has to invent a price and stays pure.
|
||||
//
|
||||
// The export net is derived from the LINE total (unit × qty), not per unit —
|
||||
// rounding per line is the invoice-correct convention, and it is also the
|
||||
|
|
@ -134,18 +222,10 @@ export Totals ComputeTotals(std::int64_t unitGrossMinor, std::int64_t quantity,
|
|||
return t;
|
||||
}
|
||||
|
||||
// The zone-table shipping fallback, used when no live carrier table covers the
|
||||
// destination. Exported separately so the same lookup renders the shipping
|
||||
// table on the product page.
|
||||
export std::int64_t ZoneShipping(std::int64_t shipNl, std::int64_t shipEu,
|
||||
std::int64_t shipWorld, std::string_view country) {
|
||||
const Zone z = ZoneFor(country);
|
||||
return z == Zone::Nl ? shipNl : z == Zone::Eu ? shipEu : shipWorld;
|
||||
}
|
||||
|
||||
// ── indicative currency display ───────────────────────────────────────
|
||||
//
|
||||
// Orders are charged in euros, always — bunq collects EUR and the invoice is
|
||||
// Orders are charged in euros, always — both rails collect EUR (CoinGate
|
||||
// converts the coin at a locked rate and settles euro) and the invoice is
|
||||
// EUR. But a Canadian reading "€614" has to do mental arithmetic to know what
|
||||
// their card will actually take, so the order page also shows an INDICATIVE
|
||||
// conversion in the buyer's national currency, from ECB reference rates baked
|
||||
|
|
@ -168,11 +248,12 @@ export struct CurrencyRow {
|
|||
|
||||
// Only currencies the ECB publishes reference rates for; anywhere else shows
|
||||
// plain euros. Euro countries are deliberately absent — converting EUR to EUR
|
||||
// is noise.
|
||||
// is noise. So are USD and CAD: NoSaleCountries means no order can ever be
|
||||
// charged from those countries, and quoting a visitor a friendly price in
|
||||
// their own currency before refusing them at checkout is both a worse
|
||||
// experience and the kind of localisation that reads as marketing there.
|
||||
export std::span<const CurrencyRow> AllCurrencies() {
|
||||
static constexpr std::array<CurrencyRow, 16> rows{{
|
||||
{ "US", { "USD", "US$" } },
|
||||
{ "CA", { "CAD", "CA$" } },
|
||||
static constexpr std::array<CurrencyRow, 14> rows{{
|
||||
{ "GB", { "GBP", "£" } },
|
||||
{ "CH", { "CHF", "CHF " } },
|
||||
{ "NO", { "NOK", "kr " } },
|
||||
|
|
|
|||
Loading…
Reference in a new issue