catcrafts.net/shared/interfaces/Catcrafts.Shared-Money.cppm
Jorijn van der Graaf 4666c1995f
All checks were successful
Deploy / build-deploy (push) Successful in 2m23s
markdown fix
2026-08-18 11:12:24 +02:00

490 lines
26 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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<const std::string_view> EuCountries() {
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",
};
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<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();
}
// 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();
}
// 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<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 —
// 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<const CurrencyRow> AllCurrencies() {
static constexpr std::array<CurrencyRow, 14> rows{{
{ "GB", { "GBP", "£" } },
{ "CH", { "CHF", "CHF " } },
{ "NO", { "NOK", "kr " } },
{ "SE", { "SEK", "kr " } },
{ "DK", { "DKK", "kr " } },
{ "PL", { "PLN", "" } },
{ "CZ", { "CZK", "" } },
{ "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