/* 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; } // "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; 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. export std::span EuCountries() { static constexpr std::array 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", }; return eu; } export bool IsEuCountry(std::string_view cc) { return std::ranges::find(EuCountries(), cc) != EuCountries().end(); } // Destinations the law forbids. // // 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 // 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. export std::span SanctionedCountries() { static constexpr std::array blocked{ "RU", "BY", "KP" }; return blocked; } export bool IsSanctioned(std::string_view cc) { return std::ranges::find(SanctionedCountries(), cc) != SanctionedCountries().end(); } // 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 ShippableCountries() { static constexpr std::array 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(); } // ISO 3166-1 alpha-2, uppercase, like everything else here. Callers ask this // 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. export bool SellsTo(std::string_view cc) { return !IsSanctioned(cc) && ShipsTo(cc); } // 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) { if (cc == "NL") return Zone::Nl; 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 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 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 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 LadderFor(std::span 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 — // 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 // 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 // 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 ─────────────────────────────────────── // // 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 // 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 // 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. export std::span AllCurrencies() { static constexpr std::array rows{{ { "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 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