catcrafts.net/shared/interfaces/Catcrafts.Shared-Money.cppm

299 lines
13 KiB
Text
Raw Normal View History

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-13 23:34:19 +02:00
// Destinations this shop refuses outright.
//
// Not a carrier problem — parcels reach both fine, and the rate tables price
// them. It is an insurance boundary: liability cover for a Dutch shop is
// written "worldwide excluding USA/Canada", and a phone carrying a lithium
// battery and a replaced OS is precisely the product-liability exposure that
// exclusion exists for. A sale into either country would therefore be
// uninsured, with the buyer's own courts deciding the damages, so checkout
// declines it instead of pricing it. Declining also keeps Catcrafts from
// having marketed into those jurisdictions at all, which is the other half of
// why the answer is no rather than a surcharge.
export std::span<const std::string_view> NoSaleCountries() {
static constexpr std::array<std::string_view, 2> blocked{ "US", "CA" };
return blocked;
}
// ISO 3166-1 alpha-2, uppercase, like everything else here. Callers ask this
// rather than comparing against "US" themselves, so the policy has exactly one
// definition and adding a country later is a one-line change.
export bool SellsTo(std::string_view cc) {
return std::ranges::find(NoSaleCountries(), cc) == NoSaleCountries().end();
}
// Delivery-time tiers. NOT a price concept — every rate comes from the carrier
// (see ShipBracket below). This exists because Sendcloud's method list carries
// no transit estimate, so the "1-2 / 2-5 / 5-14 days" the listing publishes is
// ours to state, and distance is the only thing it can reasonably key on.
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-13 23:34:19 +02:00
// Orders are charged in euros, always — both rails collect EUR (CoinGate
// converts the coin at a locked rate and settles euro) and the invoice is
2026-08-05 04:18:37 +02:00
// 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
2026-08-13 23:34:19 +02:00
// 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.
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