catcrafts.net/shared/interfaces/Catcrafts.Shared-Money.cppm
Jorijn van der Graaf b49042a626
All checks were successful
Deploy / build-deploy (push) Successful in 5m32s
new logo
2026-08-05 23:52:40 +02:00

218 lines
9.4 KiB
C++
Raw Permalink 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 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 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();
}
// 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", "" } },
{ "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