catcrafts.net/tests/ShouldComputeMoney/main.cpp
Jorijn van der Graaf abbd616b40
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
donation item, shop soft open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:04:03 +02:00

315 lines
18 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.
*/
// The money layer: formatting, the VAT arithmetic, zones and the sale policy,
// the carrier weight brackets, order totals, indicative currency conversion
// and the ECB rates loader. Every price the site shows or charges goes
// through these functions.
import std;
import Catcrafts.Shared;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
using namespace Catcrafts::Money;
// ── formatting ────────────────────────────────────────────────────
Check(FormatMinor(58000) == "580.00", "money: wire format");
Check(FormatMinor(47934) == "479.34", "money: wire format with cents");
Check(FormatMinor(5) == "0.05", "money: sub-unit");
Check(FormatMinor(0) == "0.00", "money: zero");
Check(FormatEuro(58000) == "€580", "money: whole euros displayed bare");
Check(FormatEuro(47934) == "€479.34", "money: cents displayed when present");
// ── VAT arithmetic ────────────────────────────────────────────────
// €580.00 gross at 21%: net = 58000/1.21 = 47933.88... -> 47934 half-up.
Check(NetFromGross(58000) == 47934, "vat: net from €580 gross");
// The derived pair must reconstruct plausibly: net + vat == gross.
Check(58000 - NetFromGross(58000) == 10066, "vat: vat portion exact");
Check(NetFromGross(0) == 0, "vat: zero");
Check(NetFromGross(121) == 100, "vat: €1.21 -> €1.00 exactly");
// The gross-up direction, used to charge carrier costs without eating
// the VAT slice: €7.13 cost -> €8.63 charged, and the pair round-trips.
Check(GrossFromNet(713) == 863, "vat: gross from €7.13 net");
Check(NetFromGross(GrossFromNet(713)) == 713, "vat: gross-up round-trips");
Check(GrossFromNet(100) == 121, "vat: €1.00 -> €1.21 exactly");
Check(GrossFromNet(0) == 0, "vat: gross-up zero");
// The half-up term (+5000) caught at sub-cent scale, once each way:
// 3 × 1.21 = 3.63 must land on 4, 2 × 1.21 = 2.42 must land on 2. Plain
// truncation would give 3 for the first, so this is what pins the term.
Check(GrossFromNet(3) == 4, "vat: gross-up rounds .63 up");
Check(GrossFromNet(2) == 2, "vat: gross-up rounds .42 down");
// The round-trip is the property that makes "cost plus, eat nothing" true,
// and it is applied to every EU carrier bracket — so it sets the shipping
// cents on every EU order. If either rounding constant drifted, the shop
// would remit VAT on a grossed-up rate that no longer nets back to the
// carrier's own cost, losing or pocketing a cent on every parcel. A single
// case cannot catch that; sweep the range a shipping rate lives in.
//
// Why it must hold for every n: GrossFromNet(n) is 1.21n rounded half up,
// so it sits within 0.5 of 1.21n. Dividing back by 1.21 therefore lands
// within 0.5/1.21 ≈ 0.413 of n — never far enough to reach the next
// half-up boundary, so NetFromGross returns n exactly.
{
std::string broke;
for (std::int64_t n = 0; n <= 2000; ++n) {
if (NetFromGross(GrossFromNet(n)) != n && broke.empty()) {
broke = std::format("net {} -> gross {} -> net {}", n,
GrossFromNet(n), NetFromGross(GrossFromNet(n)));
}
}
Check(broke.empty(),
"vat: gross-up round-trips for every net from €0.00 to €20.00", broke);
}
// ── zones and membership ──────────────────────────────────────────
Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members");
// The whole roster, restated here rather than borrowed from EuCountries()
// — a list that checks itself proves nothing. IsEuCountry is the single
// switch in ComputeTotals between charging the VAT-inclusive price and
// charging a zero-rated export net, so a member quietly lost to a rebase,
// or typed as EL instead of GR, bills that country's buyers ~17% under the
// order's worth while the shop still owes NL OSS VAT on the sale. Money
// out the door, per order, with nothing else in the repo watching.
constexpr std::array<std::string_view, 27> members{
"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",
};
Check(EuCountries().size() == 27, "eu: 27 member states, no more and no fewer");
for (const std::string_view cc : members) {
Check(IsEuCountry(cc), "eu: member state recognised", cc);
}
Check(!IsEuCountry("GB"), "eu: UK left");
Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU");
// Three of the easiest false positives: IS shares the single market
// through the EEA, UA and TR are candidates (TR is even in the customs
// union). None of that is membership, and none of it makes a sale
// domestic for VAT.
Check(!IsEuCountry("IS"), "eu: EEA membership is not EU membership");
Check(!IsEuCountry("UA") && !IsEuCountry("TR"), "eu: candidates are not members");
Check(!IsEuCountry("CA") && !IsEuCountry("US"), "eu: north america");
Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)");
Check(ZoneFor("NL") == Zone::Nl, "zone: home");
Check(ZoneFor("DE") == Zone::Eu, "zone: eu");
Check(ZoneFor("GB") == Zone::World, "zone: world");
// ── destinations the shop refuses ─────────────────────────────────
// Zones still classify US and CA (the arithmetic is destination-blind, and
// keeping it that way means one policy switch, not two); the sale is what
// stops, in SellsTo.
Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused");
Check(SellsTo("NL") && SellsTo("DE"), "policy: EU sells");
Check(SellsTo("GB") && SellsTo("CH") && SellsTo("AU"),
"policy: the rest of the world still sells");
Check(SellsTo("us"), "policy: matched on the normalised code, like membership");
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify");
// Sanctioned destinations refuse through the same gate, but the two lists
// stay distinguishable — the checkout error names the law for one and the
// shop's own choice for the other.
Check(!SellsTo("RU") && !SellsTo("BY") && !SellsTo("KP"),
"sanctions: RU, BY and KP refused");
Check(IsSanctioned("RU") && IsSanctioned("BY") && IsSanctioned("KP"),
"sanctions: the list knows its members");
Check(!IsSanctioned("US") && !IsSanctioned("NL"),
"sanctions: the insurance refusal is not a sanctions refusal");
// ── carrier weight brackets ───────────────────────────────────────
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
// 20 kg, with the 20 kg band deliberately CHEAPER than the 10 kg one —
// real carrier tariffs do that, and picking the tightest band rather than
// the cheapest one that carries the parcel would overcharge for it.
{
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 1650 },
{ 20000, 1490 } };
Check(RateFor(ladder, 700) == 895, "brackets: one unit takes the 2 kg band");
Check(RateFor(ladder, 2000) == 895, "brackets: the ceiling is inclusive");
Check(RateFor(ladder, 2001) == 1490,
"brackets: cheapest band that CARRIES it, not the tightest");
Check(RateFor(ladder, 20001) == 0, "brackets: above every band is no price");
Check(RateFor({}, 700) == 0, "brackets: an uncovered country has no price");
Check(MaxUnitsFor(ladder, 700) == 28, "brackets: units that fit one parcel");
Check(MaxUnitsFor(ladder, 25000) == 0,
"brackets: a unit heavier than every band fits nothing");
Check(MaxUnitsFor(ladder, 0) == 0, "brackets: no weight, no answer");
Check(MaxUnitsFor({}, 700) == 0, "brackets: no ladder, nothing fits");
// The table-level lookups the handler and the page both go through.
const std::vector<ShipRates> table{ { "NL", ladder }, { "JP", { { 2000, 4250 } } } };
Check(RateFor(LadderFor(table, "NL"), 700) == 895, "table: NL priced");
Check(RateFor(LadderFor(table, "JP"), 2100) == 0,
"table: JP has one light band, so two units are unshippable");
Check(LadderFor(table, "BR").empty(), "table: unlisted country is empty");
}
// A zero-priced bracket, which is what a Sendcloud row with a missing or
// unparseable price field becomes by the time it reaches here. RateFor
// spends `best == 0` as its "nothing covers this weight" sentinel, so a
// zero-cent band can never win — and that collision is doing real work: it
// makes the lookup fail CLOSED (ShippingTable::Find returns 0, checkout
// answers 422 and refuses) instead of shipping a €580 parcel worldwide for
// nothing. It is an accident of the sentinel choice rather than a stated
// rule, which is exactly why it needs a test standing over it: "prefer the
// cheapest band" is a tempting simplification that would give the parcel
// away.
{
const std::vector<ShipBracket> onlyFree{ { 2000, 0 } };
Check(RateFor(onlyFree, 700) == 0,
"brackets: a zero-priced band reads as no price, never as free");
const std::vector<ShipBracket> withFree{ { 2000, 0 }, { 10000, 1650 } };
Check(RateFor(withFree, 700) == 1650,
"brackets: a real price wins over a zero-priced band that also carries it");
}
// ── order totals ──────────────────────────────────────────────────
// NL: gross + shipping, VAT included in both.
auto nl = ComputeTotals(58000, 1, 1500, "NL");
Check(nl.goods == 58000 && nl.shipping == 1500 && nl.total == 59500,
"totals: NL");
Check(nl.vatIncluded, "totals: NL includes VAT");
Check(nl.vatCharged == 59500 - NetFromGross(59500), "totals: NL VAT covers shipping");
auto de = ComputeTotals(58000, 1, 2500, "DE");
Check(de.goods == 58000 && de.shipping == 2500 && de.total == 60500,
"totals: EU");
// Export: net goods, world shipping, no VAT.
auto gb = ComputeTotals(58000, 1, 5500, "GB");
Check(gb.goods == 47934 && gb.shipping == 5500 && gb.total == 53434,
"totals: export");
Check(!gb.vatIncluded && gb.vatCharged == 0, "totals: export carries no VAT");
// Quantity: the export net is derived from the LINE total, not per unit —
// per-unit rounding times qty would differ by a cent here, and the JS
// preview mirrors this exact formula.
auto gb2 = ComputeTotals(57500, 2, 5500, "GB");
Check(gb2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
Check(gb2.goods == 95041, "totals: 2× green export net exact");
auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
// VAT is derived ONCE, from the taxable total — never from a sum of line
// nets. ComputeTotals says so above (nl.vatCharged comes off goods +
// shipping as one number); these four make the reason a test rather than a
// comment, because NetFromGross is NOT additive across lines. Each division
// rounds half up on its own remainder, and the remainders do not have to
// agree:
//
// NetFromGross(56330) = (563'300'000 + 6050) / 12100 = 46554 rem 2650
// NetFromGross( 400) = ( 4'000'000 + 6050) / 12100 = 331 rem 950
// NetFromGross(56730) = (567'300'000 + 6050) / 12100 = 46884 rem 9650
//
// 56330 + 400 is 56730, but 46554 + 331 is 46885 — one cent ABOVE the net
// the combined total yields. That gap is not exotic: across the real price
// grid (three variants × 1..28 units × the shipping ladder) roughly a
// quarter of the combinations hit it.
//
// It matters because the invoice prints a "Subtotal (ex VAT)" that is
// NetFromGross of the whole total, with the VAT line derived from that
// subtotal — and non-additivity is exactly why its shipping line is the
// REMAINDER of that subtotal after the goods net, never NetFromGross of
// the shipping on its own. The day someone "tidies" the remainder into a
// third independent rounding, a GPG-signed tax document starts
// disagreeing with itself by a cent on a quarter of the price grid.
Check(NetFromGross(56330) == 46554, "vat: net of a goods line");
Check(NetFromGross(400) == 331, "vat: net of a shipping line");
Check(NetFromGross(56730) == 46884, "vat: net of the two taken together");
Check(NetFromGross(56330) + NetFromGross(400) != NetFromGross(56730),
"vat: line nets do not sum to the total net — derive VAT once, from the total");
// ── indicative conversion ─────────────────────────────────────────
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
// Both cases above land far from the rounding boundary and would pass
// under plain truncation too, which leaves the +50'000'000 term — the only
// thing making this round rather than truncate — entirely unpinned. Drop
// it and every quoted foreign price shifts DOWN by up to a whole unit, on
// the number a non-euro buyer reads before deciding to order. So take the
// boundary head-on: €1.00 at a rate of exactly 1.5 is 1.5 units.
Check(ConvertIndicative(100, 1'500'000) == 2, "fx: exactly half rounds up");
Check(ConvertIndicative(100, 1'499'999) == 1,
"fx: one millionth below half rounds down");
Check(ConvertIndicative(100, 1'400'000) == 1, "fx: .4 of a unit rounds down");
// The term must not conjure a unit out of nothing, either.
Check(ConvertIndicative(0, 1'083'400) == 0, "fx: zero converts to zero");
auto gbp = CurrencyFor("GB");
Check(gbp.has_value() && gbp->code == "GBP", "fx: GB -> GBP");
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion");
if (gbp) {
Check(FormatIndicative(*gbp, 920) == "≈ £920", "fx: display form");
}
// A country the shop refuses gets no localised price either — the two
// tables are kept consistent on purpose, so this is a real invariant and
// not a coincidence of the current list.
for (const std::string_view cc : NoSaleCountries()) {
Check(!CurrencyFor(cc).has_value(),
"fx: refused destinations have no display currency", cc);
}
for (const std::string_view cc : SanctionedCountries()) {
Check(!CurrencyFor(cc).has_value(),
"fx: sanctioned destinations have no display currency", cc);
}
// ── rates loader ──────────────────────────────────────────────────
const Rates r = LoadRates(
R"({"date":"2026-08-04","micro_per_eur":{"USD":1083400,"CAD":1489000}})");
Check(r.date == "2026-08-04", "rates: date");
Check(r.Find("USD") == 1'083'400, "rates: lookup");
Check(r.Find("XXX") == 0, "rates: absent is zero");
Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none");
// Above is the whole-document failure; this is the per-ENTRY one, which is
// the case CI actually produces. LoadRates admits a rate only when it is a
// JSON number AND strictly positive, and that guard is the single thing
// standing between a bad rates.json and a printed price: the views and the
// order handler only re-check `rate > 0` before formatting, so a negative
// that slipped through here would render "≈ £-628" on every shop card.
// One entry per rejected shape — zero, negative, a number sent as a
// string, and null.
const Rates bad = LoadRates(
R"({"date":"2026-08-04","micro_per_eur":{"USD":0,"GBP":-860000,)"
R"("CHF":"940000","SEK":null}})");
Check(bad.date == "2026-08-04", "rates: a readable date survives unusable entries");
Check(bad.microPerEur.empty(),
"rates: zero, negative, string and null entries are all refused");
Check(bad.Find("USD") == 0 && bad.Find("GBP") == 0 && bad.Find("CHF") == 0 &&
bad.Find("SEK") == 0,
"rates: a refused entry is indistinguishable from an absent one");
// Refusal is per entry, not per document — one unusable rate must not take
// its healthy siblings down with it, or a single ECB hiccup blanks every
// localised price on the site instead of just the one currency's.
const Rates partial = LoadRates(
R"({"date":"2026-08-04","micro_per_eur":{"GBP":-860000,"NOK":11700000}})");
Check(partial.microPerEur.size() == 1, "rates: only the bad entry is dropped");
Check(partial.Find("NOK") == 11'700'000, "rates: the valid sibling still loads");
Check(partial.Find("GBP") == 0, "rates: the negative sibling does not");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}