/* 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 bunq's amount objects use, 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 bool IsEuCountry(std::string_view cc) { 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 std::ranges::find(eu, cc) != eu.end(); } // Shipping zones. Three tiers is deliberate — real carrier pricing has more // distinctions than anyone wants in a checkout, and the tiers only need to be // roughly right because the rates are set per product with margin. 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; } // 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 (live carrier table or the // zone fallback — the server decides which), so this function 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; } // The zone-table shipping fallback, used when no live carrier table covers the // destination. Exported separately so the same lookup renders the shipping // table on the product page. export std::int64_t ZoneShipping(std::int64_t shipNl, std::int64_t shipEu, std::int64_t shipWorld, std::string_view country) { const Zone z = ZoneFor(country); return z == Zone::Nl ? shipNl : z == Zone::Eu ? shipEu : shipWorld; } // ── indicative currency display ─────────────────────────────────────── // // Orders are charged in euros, always — bunq collects EUR 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. export std::span AllCurrencies() { static constexpr std::array rows{{ { "US", { "USD", "US$" } }, { "CA", { "CAD", "CA$" } }, { "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