2026-08-05 04:18:37 +02:00
|
|
|
|
/*
|
|
|
|
|
|
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.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
// Money and VAT arithmetic, in integer minor units. No floats, ever: a double
|
|
|
|
|
|
// cannot represent 0.01 exactly, and a price that drifts by a cent between the
|
|
|
|
|
|
// page, the payment request and the invoice is a bookkeeping bug you find at
|
|
|
|
|
|
// tax time. Everything here is exact integer math with explicit rounding.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Lives in Catcrafts.Shared (imports std only) so the same arithmetic renders
|
|
|
|
|
|
// the price on the page and computes the amount actually charged — one
|
|
|
|
|
|
// function, so they cannot disagree.
|
|
|
|
|
|
|
|
|
|
|
|
export module Catcrafts.Shared:Money;
|
|
|
|
|
|
import std;
|
|
|
|
|
|
|
|
|
|
|
|
namespace Catcrafts::Money {
|
|
|
|
|
|
|
|
|
|
|
|
// NL standard VAT rate, in basis points. Prices are stored VAT-inclusive (EU
|
|
|
|
|
|
// Price Indication Directive: consumers must see the final price), and the net
|
|
|
|
|
|
// is derived — not the other way round — so the advertised number is exact and
|
|
|
|
|
|
// the derived one takes the rounding.
|
|
|
|
|
|
export inline constexpr std::int64_t kVatRateBp = 2100;
|
|
|
|
|
|
|
|
|
|
|
|
// Net (ex-VAT) amount from a VAT-inclusive gross, rounding half up on the
|
|
|
|
|
|
// division. gross = net * (1 + rate) exactly when working in real numbers;
|
|
|
|
|
|
// in minor units the net absorbs the sub-cent remainder.
|
|
|
|
|
|
export constexpr std::int64_t NetFromGross(std::int64_t grossMinor,
|
|
|
|
|
|
std::int64_t rateBp = kVatRateBp) {
|
|
|
|
|
|
// net = gross * 10000 / (10000 + rate), rounded half up.
|
|
|
|
|
|
const std::int64_t denom = 10000 + rateBp;
|
|
|
|
|
|
return (grossMinor * 10000 + denom / 2) / denom;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The other direction: the VAT-inclusive price that NETS a given ex-VAT cost.
|
|
|
|
|
|
// This is how "cost plus, eat nothing" survives VAT: a carrier rate of €7.13
|
|
|
|
|
|
// ex VAT must be charged as €8.63 inclusive, or the remitted VAT comes out of
|
|
|
|
|
|
// the margin. Half-up like its sibling, and the pair round-trips (GrossFromNet
|
|
|
|
|
|
// then NetFromGross returns the original cost).
|
|
|
|
|
|
export constexpr std::int64_t GrossFromNet(std::int64_t netMinor,
|
|
|
|
|
|
std::int64_t rateBp = kVatRateBp) {
|
|
|
|
|
|
return (netMinor * (10000 + rateBp) + 5000) / 10000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
|
// "580.00" — the wire format both payment providers quote amounts in, and the
|
|
|
|
|
|
// unambiguous way to show cents. Always two decimals, no thousands separator.
|
2026-08-05 04:18:37 +02:00
|
|
|
|
export std::string FormatMinor(std::int64_t minor) {
|
|
|
|
|
|
const bool neg = minor < 0;
|
|
|
|
|
|
if (neg) minor = -minor;
|
|
|
|
|
|
return std::format("{}{}.{:02}", neg ? "-" : "", minor / 100, minor % 100);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Display form: "€580" when the cents are zero, "€479.34" otherwise. Whole
|
|
|
|
|
|
// prices are chosen deliberately (no .99 games), so showing ".00" everywhere
|
|
|
|
|
|
// would just add noise to the number that matters.
|
|
|
|
|
|
export std::string FormatEuro(std::int64_t minor) {
|
|
|
|
|
|
if (minor % 100 == 0 && minor >= 0) return std::format("€{}", minor / 100);
|
|
|
|
|
|
return "€" + FormatMinor(minor);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// EU membership decides VAT treatment: inside the EU the price is charged
|
|
|
|
|
|
// VAT-inclusive; outside, the sale is a zero-rated export and the buyer's own
|
|
|
|
|
|
// customs channel collects import VAT and duty. ISO 3166-1 alpha-2, uppercase.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Note NIR/GB: the UK left; Northern Ireland's special goods status is not
|
|
|
|
|
|
// modelled — GB is simply non-EU here, which is the correct default for a
|
|
|
|
|
|
// consumer parcel.
|
2026-08-05 23:52:40 +02:00
|
|
|
|
export std::span<const std::string_view> EuCountries() {
|
2026-08-05 04:18:37 +02:00
|
|
|
|
static constexpr std::array<std::string_view, 27> eu{
|
|
|
|
|
|
"AT", "BE", "BG", "HR", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
|
|
|
|
|
|
"FR", "GR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL",
|
|
|
|
|
|
"PT", "RO", "SE", "SI", "SK",
|
|
|
|
|
|
};
|
2026-08-05 23:52:40 +02:00
|
|
|
|
return eu;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export bool IsEuCountry(std::string_view cc) {
|
|
|
|
|
|
return std::ranges::find(EuCountries(), cc) != EuCountries().end();
|
2026-08-05 04:18:37 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 11:12:24 +02:00
|
|
|
|
// Destinations the law forbids.
|
2026-08-15 03:22:25 +02:00
|
|
|
|
//
|
|
|
|
|
|
// EU sanctions — Regulation 833/2014 for Russia, its Belarus mirror, and the
|
|
|
|
|
|
// North Korea embargo — prohibit exporting consumer electronics to these
|
|
|
|
|
|
// countries, by customs code and by the luxury-goods value threshold both, and
|
|
|
|
|
|
// the prohibition covers indirect routes (a forwarder, a reseller) as much as
|
|
|
|
|
|
// a direct parcel. That binds every EU seller as criminal law; there is no
|
2026-08-18 11:12:24 +02:00
|
|
|
|
// small-shop exemption and no surcharge version of compliance.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Kept apart from the shipping allow-list below even though both refuse,
|
|
|
|
|
|
// because the refusal needs different words: not shipping somewhere yet is a
|
|
|
|
|
|
// state of the paperwork, while this is a prohibition nothing about the shop
|
|
|
|
|
|
// could change. Each gets its own sentence at checkout and on the terms page.
|
2026-08-15 03:22:25 +02:00
|
|
|
|
export std::span<const std::string_view> SanctionedCountries() {
|
|
|
|
|
|
static constexpr std::array<std::string_view, 3> blocked{ "RU", "BY", "KP" };
|
|
|
|
|
|
return blocked;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export bool IsSanctioned(std::string_view cc) {
|
|
|
|
|
|
return std::ranges::find(SanctionedCountries(), cc) != SanctionedCountries().end();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 11:12:24 +02:00
|
|
|
|
// Where this shop ships. An allow-list, and that inversion IS the design.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Everything not named here is refused. That is not timidity, it is the only
|
|
|
|
|
|
// honest posture available: the rules deciding whether a phone may lawfully be
|
|
|
|
|
|
// sold into a country are national, they differ in kind rather than degree, and
|
|
|
|
|
|
// nobody has read all of them. A deny-list ships to every country nobody thought
|
|
|
|
|
|
// about; an allow-list refuses them. Only one of those failure modes is
|
|
|
|
|
|
// survivable, so the default is no, and every code below is a country somebody
|
|
|
|
|
|
// actually checked.
|
|
|
|
|
|
//
|
|
|
|
|
|
// What "checked" has to mean before a code goes in:
|
|
|
|
|
|
//
|
|
|
|
|
|
// * the destination does not reach the FOREIGN SELLER with producer duties of
|
|
|
|
|
|
// its own — packaging, e-waste, batteries. The EU does. The EEA (Iceland,
|
|
|
|
|
|
// Liechtenstein, Norway) does too, through the same directives, which is why
|
|
|
|
|
|
// no EEA country is here. The UK does by national law.
|
|
|
|
|
|
// * a phone posted from here is actually admitted: no national IMEI database
|
|
|
|
|
|
// to be registered against before a local network will attach it, and no
|
|
|
|
|
|
// customs practice of seizing handsets that lack local type approval.
|
|
|
|
|
|
// * the parcel crosses as the BUYER's import, so their own authority charges
|
|
|
|
|
|
// them VAT and duty and their own type-approval rules bind them rather than
|
|
|
|
|
|
// Catcrafts. This is the one thing the price buys: every foreign
|
|
|
|
|
|
// low-value-consignment regime found sits far below €600, so nothing routes
|
|
|
|
|
|
// through a seller-side registration scheme.
|
|
|
|
|
|
// * liability cover reaches it. The AVB is written worldwide EXCLUDING the
|
|
|
|
|
|
// United States and Canada — confirm that territory in the policy schedule
|
|
|
|
|
|
// before trusting this list, because every entry assumes it.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Removed after verification, and NOT to be re-added on a hunch — both were on
|
|
|
|
|
|
// this list once, on the mistaken assumption that a domestic-supply approval
|
|
|
|
|
|
// regime never reaches a personal import:
|
|
|
|
|
|
//
|
|
|
|
|
|
// JP — using a handset without Japan's giteki (技適) mark on a Japanese network
|
|
|
|
|
|
// is a Radio Act offence carrying up to a year's detention or ¥1,000,000.
|
|
|
|
|
|
// The only exemption is for a short-stay visitor carrying a device in; it
|
|
|
|
|
|
// does not reach a resident receiving a parcel. Fairphone holds no Japanese
|
|
|
|
|
|
// certification and says outright that its devices cannot be used in
|
|
|
|
|
|
// countries requiring local homologation. Note where the liability lands:
|
|
|
|
|
|
// the offence is USE, so the person committing it is the customer. Selling
|
|
|
|
|
|
// someone a phone they break the law by switching on is worse than any
|
|
|
|
|
|
// paperwork gap on this page.
|
|
|
|
|
|
// NZ — the radio regulator defines a supplier to include "a seller", says the
|
|
|
|
|
|
// rules apply identically whether stock ships from inside New Zealand or
|
|
|
|
|
|
// reaches the market through a website, and requires a Licence to Supply
|
|
|
|
|
|
// for radio transmitters. The cure is then bolted shut: an overseas company
|
|
|
|
|
|
// cannot register on the compliance database, and unlike Australia there is
|
|
|
|
|
|
// no agent workaround. Structurally the same trap as Norway and the UK,
|
|
|
|
|
|
// living in radio law rather than waste law.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Not here, and deliberately: the United States and Canada. The insurance
|
|
|
|
|
|
// exclusion was only ever half the reason and is no longer the interesting half.
|
|
|
|
|
|
// Canada is legally shut — the Fairphone (Gen. 6) holds no ISED certification,
|
|
|
|
|
|
// and Canadian law bars importing, distributing or selling uncertified radio
|
|
|
|
|
|
// apparatus, so no structure or policy opens it. The US adds per-parcel customs
|
|
|
|
|
|
// entry since de minimis ended, carrier certification that gates activation, and
|
|
|
|
|
|
// no emergency-call immunity of any kind for a device maker. Both are refused for
|
|
|
|
|
|
// regulatory reasons now, not commercial ones, and neither becomes available by
|
|
|
|
|
|
// buying a policy.
|
|
|
|
|
|
// Every entry must also ship DDU/DAP — the buyer as importer of record, paying
|
|
|
|
|
|
// their own authority at the border. That is not a commercial preference, it is
|
|
|
|
|
|
// load-bearing law in three of the five: it is what makes Hong Kong's
|
|
|
|
|
|
// personal-use import exemption apply, what keeps Catcrafts outside Singapore's
|
|
|
|
|
|
// producer definition, and what puts import tax on the consumer everywhere else.
|
|
|
|
|
|
// Appear as importer of record and two of these countries close.
|
|
|
|
|
|
export std::span<const std::string_view> ShippableCountries() {
|
|
|
|
|
|
static constexpr std::array<std::string_view, 10> open{
|
|
|
|
|
|
// Home. Verpact wants nothing under 50,000 kg of packaging, and the
|
|
|
|
|
|
// phones are already on the Dutch market when Catcrafts buys them, so no
|
|
|
|
|
|
// producer duty attaches. That second clause is load-bearing: source
|
|
|
|
|
|
// stock from another member state and Catcrafts becomes the Dutch
|
|
|
|
|
|
// importer, owing Stichting OPEN before this line is honest again.
|
|
|
|
|
|
"NL",
|
|
|
|
|
|
// Checked end to end against the federal texts. CE accepted under the
|
|
|
|
|
|
// bilateral MRA (its chapter 7 is radio equipment); the e-waste ordinance
|
|
|
|
|
|
// reaches only those importing for COMMERCIAL supply, with no
|
|
|
|
|
|
// distance-seller limb and no producer register; and the mail-order VAT
|
|
|
|
|
|
// duty is scoped to consignments cheap enough to be import-tax-exempt, so
|
|
|
|
|
|
// a phone goes through ordinary import with the buyer paying at the
|
|
|
|
|
|
// border. The packaging ordinance expected 1 Jan 2027 turns out not to
|
|
|
|
|
|
// matter — it was adopted 24 June 2026 and its fee covers GLASS only.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The real watch item is elsewhere, and it is specific: the circular-
|
|
|
|
|
|
// economy revision of the environment act, in force since 1 Jan 2025,
|
|
|
|
|
|
// already empowers a disposal fee on "ausländische Online-
|
|
|
|
|
|
// Versandhandelsunternehmen" — defined as whoever offers products
|
|
|
|
|
|
// digitally and delivers to consumers in Switzerland without a Swiss
|
|
|
|
|
|
// seat or establishment. That is this shop, definitionally. It is dormant
|
|
|
|
|
|
// only because the power is discretionary and no ordinance names a phone,
|
|
|
|
|
|
// and the companion article is deliberately not yet in force. An ordinance
|
|
|
|
|
|
// extending the fee to electrical devices is the day Switzerland flips.
|
|
|
|
|
|
"CH",
|
|
|
|
|
|
// Read out of the Radiocommunications Equipment (General) Rules 2021
|
|
|
|
|
|
// rather than off a guidance page, because ACMA's own site is misleading
|
|
|
|
|
|
// here. The labelling and registration duties in s25 bind an importer who
|
|
|
|
|
|
// then SUPPLIES — neither limb is met when the consumer imports one phone
|
|
|
|
|
|
// for themselves. What does reach Catcrafts is s12: a person must not
|
|
|
|
|
|
// supply a device failing a prescribed general standard, extended to trade
|
|
|
|
|
|
// between Australia and places outside it. That is a SUBSTANTIVE standards
|
|
|
|
|
|
// duty, not a registration one — so no Australian establishment is needed,
|
|
|
|
|
|
// unlike New Zealand. Phones are outside the e-waste scheme (which covers
|
|
|
|
|
|
// TVs, printers and computers, and binds Australian corporations anyway).
|
|
|
|
|
|
// Two watch items: a mandatory small-electricals stewardship scheme is
|
|
|
|
|
|
// committed but not law, and the A$1,000 low-value-import line sits close
|
|
|
|
|
|
// enough to €600 that FX moves can cross it.
|
|
|
|
|
|
"AU",
|
|
|
|
|
|
// The best-documented jurisdiction of the set. Mobile phones sit outside
|
|
|
|
|
|
// the e-waste producer-responsibility list; user equipment needs no type
|
|
|
|
|
|
// approval (voluntary certification only); the import ordinance exempts
|
|
|
|
|
|
// equipment brought in for reasonable personal use; and there is no VAT or
|
|
|
|
|
|
// GST at all, so no foreign-seller registration can arise.
|
|
|
|
|
|
"HK",
|
|
|
|
|
|
// Both feared hooks miss on the facts. The e-waste producer duty requires
|
|
|
|
|
|
// importing INTO Singapore in furtherance of a Singapore supply business,
|
|
|
|
|
|
// which a DDU parcel is not, and there is no distance-selling limb of the
|
|
|
|
|
|
// kind UK law uses. IMDA states personal-use imports need no registration
|
|
|
|
|
|
// and sets no quantity limit. On tax the price helps: the low-value-goods
|
|
|
|
|
|
// regime caps at S$400, so a €600 phone is above it and the overseas-vendor
|
|
|
|
|
|
// rules cannot reach it at any turnover.
|
|
|
|
|
|
"SG",
|
|
|
|
|
|
// The Western Balkans four. All share the shape that matters: producer
|
|
|
|
|
|
// responsibility attaches to whoever places goods on the DOMESTIC market
|
|
|
|
|
|
// — the in-country importer, so the buyer — with none of the
|
|
|
|
|
|
// "regardless of sales channel" drafting that catches a distance seller in
|
|
|
|
|
|
// the EU, Norway, Iceland, Moldova and Bosnia. CE is accepted, no IMEI
|
|
|
|
|
|
// whitelist exists, and non-resident VAT reaches services only.
|
|
|
|
|
|
//
|
|
|
|
|
|
// RS — the strongest of them, because the answer comes from the customs
|
|
|
|
|
|
// authority itself: conformity documents are demanded only for
|
|
|
|
|
|
// certain drones, no radio-equipment conformity paper is required at
|
|
|
|
|
|
// import, and per the telecoms ministry's published position the
|
|
|
|
|
|
// Radio Equipment Rulebook does not apply to natural persons at all.
|
|
|
|
|
|
// ME — best-evidenced on tax: the VAT act states in terms that where
|
|
|
|
|
|
// transport begins outside Montenegro the IMPORTER makes the supply,
|
|
|
|
|
|
// and a tax representative is needed only for supplies made inside
|
|
|
|
|
|
// the country. EPR bylaws are still pending.
|
|
|
|
|
|
// AL — its WEEE decree still uses the pre-2012 three-limb producer
|
|
|
|
|
|
// definition, so the distance-selling limb simply is not there, and
|
|
|
|
|
|
// registration would need an Albanian tax number nobody can give a
|
|
|
|
|
|
// foreigner. Smartphones are also duty-free in the 2026 tariff.
|
|
|
|
|
|
// HARD DATE: Law 74/2025 takes effect 1 December 2026. Re-read its
|
|
|
|
|
|
// producer definition and any implementing acts before then, because
|
|
|
|
|
|
// that is when this entry could stop being true.
|
|
|
|
|
|
// XK — cleared on the law, with one operational caveat that is not legal:
|
|
|
|
|
|
// Kosovo is not a UPU member, so there is no treaty-based tracking
|
|
|
|
|
|
// guarantee or loss indemnity. On a €600 parcel that matters — ship
|
|
|
|
|
|
// it by courier rather than post. Note also that XK is a
|
|
|
|
|
|
// user-assigned code rather than official ISO 3166-1; if the carrier
|
|
|
|
|
|
// table spells Kosovo differently this entry simply never matches and
|
|
|
|
|
|
// the destination falls through to the no-carrier-rate refusal, which
|
|
|
|
|
|
// is the safe direction to fail.
|
|
|
|
|
|
"RS", "ME", "AL", "XK",
|
|
|
|
|
|
// Held back for a while on the dual-use crypto question rather than
|
|
|
|
|
|
// anything Georgian, and that question turned out to be a paperwork task
|
|
|
|
|
|
// rather than a gate — the mass-market exemption releases this phone for
|
|
|
|
|
|
// the same reason it releases every unlockable Pixel. On its own law
|
|
|
|
|
|
// Georgia is among the cleanest here: producer duties attach to whoever
|
|
|
|
|
|
// IMPORTS, with no distance-selling limb and no authorised-representative
|
|
|
|
|
|
// concept at all; the electronic communications act contains not one
|
|
|
|
|
|
// mention of IMEI; the product-safety code admits goods built to the
|
|
|
|
|
|
// standards of any EU or OECD country; and phones carry no duty, with the
|
|
|
|
|
|
// buyer paying import VAT at the border. Same caveat as Kosovo: the
|
|
|
|
|
|
// national post is slow with unreliable tracking, so send it by courier.
|
|
|
|
|
|
"GE",
|
|
|
|
|
|
};
|
|
|
|
|
|
return open;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export bool ShipsTo(std::string_view cc) {
|
|
|
|
|
|
return std::ranges::find(ShippableCountries(), cc) != ShippableCountries().end();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
|
// ISO 3166-1 alpha-2, uppercase, like everything else here. Callers ask this
|
2026-08-18 11:12:24 +02:00
|
|
|
|
// rather than testing the lists themselves, so the policy has exactly one
|
|
|
|
|
|
// definition and opening a country is a one-line change.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Two HARD gates, in order of permanence: the law, then the shipping list. A
|
|
|
|
|
|
// destination the carrier happens not to price is a third and much softer
|
|
|
|
|
|
// refusal that lives with the rate table (Form::kNoShippingTemplate) — it says
|
|
|
|
|
|
// no price exists, not that the sale is refused, and a carrier contract can
|
|
|
|
|
|
// change it tomorrow. Most callers only need "is this destination for sale";
|
|
|
|
|
|
// only the checkout error cares which refusal it is.
|
2026-08-13 23:34:19 +02:00
|
|
|
|
export bool SellsTo(std::string_view cc) {
|
2026-08-18 11:12:24 +02:00
|
|
|
|
return !IsSanctioned(cc) && ShipsTo(cc);
|
2026-08-13 23:34:19 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
|
export enum class Zone { Nl, Eu, World };
|
|
|
|
|
|
|
|
|
|
|
|
export Zone ZoneFor(std::string_view cc) {
|
|
|
|
|
|
if (cc == "NL") return Zone::Nl;
|
|
|
|
|
|
return IsEuCountry(cc) ? Zone::Eu : Zone::World;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 23:34:19 +02:00
|
|
|
|
// ── 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 {};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
|
// 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 —
|
|
|
|
|
|
// kept because the invoice needs it, not because the page shows it.
|
|
|
|
|
|
export struct Totals {
|
|
|
|
|
|
std::int64_t goods = 0;
|
|
|
|
|
|
std::int64_t shipping = 0;
|
|
|
|
|
|
std::int64_t total = 0;
|
|
|
|
|
|
std::int64_t vatCharged = 0;
|
|
|
|
|
|
bool vatIncluded = false; // true when `goods` includes EU VAT
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-08-13 23:34:19 +02:00
|
|
|
|
// 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
|
//
|
|
|
|
|
|
// 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
|
|
|
|
|
|
// formula the checkout preview script mirrors, so the preview and the charge
|
|
|
|
|
|
// cannot drift by a cent.
|
|
|
|
|
|
export Totals ComputeTotals(std::int64_t unitGrossMinor, std::int64_t quantity,
|
|
|
|
|
|
std::int64_t shippingMinor,
|
|
|
|
|
|
std::string_view country) {
|
|
|
|
|
|
Totals t;
|
|
|
|
|
|
t.shipping = shippingMinor;
|
|
|
|
|
|
const std::int64_t lineGross = unitGrossMinor * quantity;
|
|
|
|
|
|
if (IsEuCountry(country)) {
|
|
|
|
|
|
t.goods = lineGross;
|
|
|
|
|
|
t.vatIncluded = true;
|
|
|
|
|
|
// VAT applies to the shipping too — it is part of the taxable supply.
|
|
|
|
|
|
const std::int64_t taxable = t.goods + t.shipping;
|
|
|
|
|
|
t.vatCharged = taxable - NetFromGross(taxable);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
t.goods = NetFromGross(lineGross);
|
|
|
|
|
|
t.vatIncluded = false;
|
|
|
|
|
|
t.vatCharged = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
t.total = t.goods + t.shipping;
|
|
|
|
|
|
return t;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── indicative currency display ───────────────────────────────────────
|
|
|
|
|
|
//
|
2026-08-15 00:54:05 +02:00
|
|
|
|
// Orders are charged in euros, always — both rails collect EUR (crypto is
|
|
|
|
|
|
// accepted in EURC, a euro stablecoin, so the token amount IS the euro
|
|
|
|
|
|
// amount) and the invoice is EUR. But a Canadian reading "€614" has to do mental arithmetic to know what
|
2026-08-05 04:18:37 +02:00
|
|
|
|
// 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
|
|
|
|
|
|
// in at build time. Indicative is the whole contract: the buyer's bank sets
|
|
|
|
|
|
// the real conversion rate, and the page says so next to the number.
|
|
|
|
|
|
|
|
|
|
|
|
export struct Currency {
|
|
|
|
|
|
std::string_view code; // ISO 4217
|
|
|
|
|
|
std::string_view symbol; // display prefix, e.g. "CA$"
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// One supported non-euro display currency and its representative country.
|
|
|
|
|
|
// `cc` matters beyond lookup: IsEuCountry(cc) decides which euro amount a
|
|
|
|
|
|
// conversion starts from — an EU member's currency (SEK, PLN, …) converts the
|
|
|
|
|
|
// VAT-inclusive price, everyone else's converts the ex-VAT export price.
|
|
|
|
|
|
export struct CurrencyRow {
|
|
|
|
|
|
std::string_view cc;
|
|
|
|
|
|
Currency cur;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Only currencies the ECB publishes reference rates for; anywhere else shows
|
|
|
|
|
|
// plain euros. Euro countries are deliberately absent — converting EUR to EUR
|
2026-08-18 11:12:24 +02:00
|
|
|
|
// is noise. So are USD and CAD, and that one is a policy choice rather than a
|
|
|
|
|
|
// gap in the ECB feed: those two are refused for regulatory reasons that no
|
|
|
|
|
|
// amount of demand will lift, so quoting a visitor a friendly price in their own
|
|
|
|
|
|
// currency before declining them is both a worse experience and the kind of
|
|
|
|
|
|
// localisation that reads as marketing into a market this shop cannot serve.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The rest of this table deliberately runs AHEAD of ShippableCountries. It
|
|
|
|
|
|
// answers "what would this cost in my money", which stays a fair question for a
|
|
|
|
|
|
// country whose paperwork is merely pending — GB is one small registration from
|
|
|
|
|
|
// opening — and keeping the row spares a delete-and-restore cycle later. The
|
|
|
|
|
|
// refusal that must never be quoted around is enforced in SellsTo, not here.
|
2026-08-05 04:18:37 +02:00
|
|
|
|
export std::span<const CurrencyRow> AllCurrencies() {
|
2026-08-13 23:34:19 +02:00
|
|
|
|
static constexpr std::array<CurrencyRow, 14> rows{{
|
2026-08-05 04:18:37 +02:00
|
|
|
|
{ "GB", { "GBP", "£" } },
|
|
|
|
|
|
{ "CH", { "CHF", "CHF " } },
|
|
|
|
|
|
{ "NO", { "NOK", "kr " } },
|
|
|
|
|
|
{ "SE", { "SEK", "kr " } },
|
|
|
|
|
|
{ "DK", { "DKK", "kr " } },
|
|
|
|
|
|
{ "PL", { "PLN", "zł " } },
|
|
|
|
|
|
{ "CZ", { "CZK", "Kč " } },
|
|
|
|
|
|
{ "HU", { "HUF", "Ft " } },
|
|
|
|
|
|
{ "RO", { "RON", "lei " } },
|
|
|
|
|
|
{ "BG", { "BGN", "лв " } },
|
|
|
|
|
|
{ "AU", { "AUD", "A$" } },
|
|
|
|
|
|
{ "NZ", { "NZD", "NZ$" } },
|
|
|
|
|
|
{ "JP", { "JPY", "¥" } },
|
|
|
|
|
|
{ "IS", { "ISK", "kr " } },
|
|
|
|
|
|
}};
|
|
|
|
|
|
return rows;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Currency for a destination country, or nullopt for euro countries and
|
|
|
|
|
|
// anywhere unsupported.
|
|
|
|
|
|
export std::optional<Currency> CurrencyFor(std::string_view cc) {
|
|
|
|
|
|
for (const CurrencyRow& r : AllCurrencies()) {
|
|
|
|
|
|
if (r.cc == cc) return r.cur;
|
|
|
|
|
|
}
|
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Convert cents-EUR to WHOLE units of the target currency, half-up. Whole
|
|
|
|
|
|
// units on purpose: a number that is explicitly approximate should not carry
|
|
|
|
|
|
// two decimals of false precision. `rateMicro` is target-per-euro in millionths
|
|
|
|
|
|
// (1 EUR = 1.0834 USD -> 1'083'400).
|
|
|
|
|
|
export constexpr std::int64_t ConvertIndicative(std::int64_t minorEur,
|
|
|
|
|
|
std::int64_t rateMicro) {
|
|
|
|
|
|
// units = minorEur/100 * rateMicro/1e6, rounded half up.
|
|
|
|
|
|
return (minorEur * rateMicro + 50'000'000) / 100'000'000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// "≈ CA$920" — the display form of an indicative conversion.
|
|
|
|
|
|
export std::string FormatIndicative(const Currency& cur, std::int64_t wholeUnits) {
|
|
|
|
|
|
return std::format("≈ {}{}", cur.symbol, wholeUnits);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} // namespace Catcrafts::Money
|