214 lines
9.3 KiB
C++
214 lines
9.3 KiB
C++
/*
|
||
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<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 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<const CurrencyRow> AllCurrencies() {
|
||
static constexpr std::array<CurrencyRow, 16> 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<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
|