catcrafts.net/shared/interfaces/Catcrafts.Shared-Money.cppm
Jorijn van der Graaf b0666841f6
All checks were successful
Deploy / build-deploy (push) Successful in 2m18s
sanctions block list
2026-08-15 03:22:25 +02:00

322 lines
15 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 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;
}
// Destinations the law forbids, as opposed to the insurance choice above.
//
// 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. A separate list
// rather than more entries in NoSaleCountries because the refusal needs
// different words: "does not" is a choice, "cannot" is the law, and each gets
// its own explanation 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();
}
// 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. Both lists deny:
// most callers only need "is this destination for sale", and only the checkout
// error message cares which refusal it is (IsSanctioned above).
export bool SellsTo(std::string_view cc) {
return !IsSanctioned(cc) &&
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) {
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: 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, 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