2026-08-15 00:54:05 +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.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
// 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");
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
|
|
|
|
|
|
|
// ── zones and membership ──────────────────────────────────────────
|
|
|
|
|
|
Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members");
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
|
Check(!IsEuCountry("GB"), "eu: UK left");
|
|
|
|
|
|
Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU");
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// 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");
|
2026-08-15 00:54:05 +02:00
|
|
|
|
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.
|
2026-08-18 11:12:24 +02:00
|
|
|
|
// Two hard gates, and the shipping one is an ALLOW-list. The assertion that
|
|
|
|
|
|
// matters most is therefore the DEFAULT: a country nobody has cleared
|
|
|
|
|
|
// refuses. This is the test that fails if the gate is ever "simplified" back
|
|
|
|
|
|
// into a deny-list, which would silently reopen most of the world.
|
|
|
|
|
|
Check(SellsTo("NL") && SellsTo("CH") && SellsTo("AU")
|
|
|
|
|
|
&& SellsTo("HK") && SellsTo("SG")
|
|
|
|
|
|
&& SellsTo("RS") && SellsTo("ME") && SellsTo("AL") && SellsTo("XK")
|
|
|
|
|
|
&& SellsTo("GE"),
|
|
|
|
|
|
"policy: every cleared destination sells");
|
|
|
|
|
|
// Both were cleared once and refuted on verification. JP because using a
|
|
|
|
|
|
// non-giteki handset is a Radio Act offence for the BUYER; NZ because its
|
|
|
|
|
|
// radio regulator's supplier duties expressly reach a website seller and an
|
|
|
|
|
|
// overseas company cannot register to comply. Asserted by name so a future
|
|
|
|
|
|
// "these look fine, add them back" cannot pass silently.
|
|
|
|
|
|
Check(!SellsTo("JP") && !SellsTo("NZ"),
|
|
|
|
|
|
"policy: refuted destinations stay refuted");
|
|
|
|
|
|
Check(!SellsTo("DE") && !SellsTo("FR") && !SellsTo("BE"),
|
|
|
|
|
|
"policy: uncleared member states refuse by default");
|
|
|
|
|
|
Check(!SellsTo("KR") && !SellsTo("MX") && !SellsTo("ZA") && !SellsTo("XX"),
|
|
|
|
|
|
"policy: uncleared and unknown codes refuse by default");
|
|
|
|
|
|
Check(!SellsTo("NO") && !SellsTo("IS"),
|
|
|
|
|
|
"policy: the EEA inherits the EU's distance-seller duties, so it waits");
|
|
|
|
|
|
Check(!SellsTo("GB"), "policy: GB waits on its EA small-producer entries");
|
|
|
|
|
|
Check(!SellsTo("TR") && !SellsTo("IN") && !SellsTo("BR"),
|
|
|
|
|
|
"policy: IMEI and type-approval destinations refuse");
|
|
|
|
|
|
// North America is refused for regulatory reasons now, not insurance ones —
|
|
|
|
|
|
// it simply is not on the list, and there is no separate category for it.
|
2026-08-15 00:54:05 +02:00
|
|
|
|
Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused");
|
2026-08-18 11:12:24 +02:00
|
|
|
|
// Matching is on the normalised code, as everywhere else here — but note the
|
|
|
|
|
|
// direction the allow-list fails in. Under the old deny-list, "us" missed the
|
|
|
|
|
|
// blocked entry and SOLD; now an unnormalised code is simply absent from the
|
|
|
|
|
|
// list and refuses. Callers still uppercase first (ValidateCheckout does),
|
|
|
|
|
|
// but the consequence of forgetting is a lost sale rather than a shipment to
|
|
|
|
|
|
// a country the shop cannot serve.
|
|
|
|
|
|
Check(!SellsTo("us") && !SellsTo("nl"),
|
|
|
|
|
|
"policy: an unnormalised code fails closed, not open");
|
2026-08-15 00:54:05 +02:00
|
|
|
|
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify");
|
2026-08-18 11:12:24 +02:00
|
|
|
|
Check(ShipsTo("NL") && !ShipsTo("US") && !ShipsTo("DE"),
|
|
|
|
|
|
"policy: ShipsTo is the allow-list on its own");
|
|
|
|
|
|
// Form::kShipsToMessage names these countries in prose for the buy page, and
|
|
|
|
|
|
// prose cannot be generated from ISO codes. If this fails because a country
|
|
|
|
|
|
// was opened, update that sentence too — the two must not drift.
|
|
|
|
|
|
Check(ShippableCountries().size() == 10,
|
|
|
|
|
|
"policy: opening a country means updating Form::kShipsToMessage as well");
|
|
|
|
|
|
|
|
|
|
|
|
// Sanctioned destinations refuse through the same gate, but stay
|
|
|
|
|
|
// distinguishable — the checkout error names the law for one and pending
|
|
|
|
|
|
// paperwork for the other, and conflating them would tell a Russian buyer
|
|
|
|
|
|
// to email and ask.
|
2026-08-15 03:22:25 +02:00
|
|
|
|
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");
|
2026-08-18 11:12:24 +02:00
|
|
|
|
Check(!IsSanctioned("US") && !IsSanctioned("DE") && !IsSanctioned("NL"),
|
|
|
|
|
|
"sanctions: an uncleared country is not a sanctioned one");
|
|
|
|
|
|
// Belt and braces: a sanctioned code must never reach the allow-list, or the
|
|
|
|
|
|
// wrong sentence would be shown for a criminal-law refusal.
|
|
|
|
|
|
for (const std::string_view cc : SanctionedCountries()) {
|
|
|
|
|
|
Check(!ShipsTo(cc), "sanctions: never on the shipping list", cc);
|
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
|
|
|
|
|
|
|
// ── 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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// 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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
|
// ── 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");
|
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// 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");
|
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
|
// ── 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");
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// 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");
|
2026-08-15 00:54:05 +02:00
|
|
|
|
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.
|
2026-08-15 03:22:25 +02:00
|
|
|
|
for (const std::string_view cc : SanctionedCountries()) {
|
|
|
|
|
|
Check(!CurrencyFor(cc).has_value(),
|
|
|
|
|
|
"fx: sanctioned destinations have no display currency", cc);
|
|
|
|
|
|
}
|
2026-08-18 11:12:24 +02:00
|
|
|
|
Check(!CurrencyFor("US").has_value() && !CurrencyFor("CA").has_value(),
|
|
|
|
|
|
"fx: north america has no display currency either");
|
|
|
|
|
|
// The invariant covers only refusals nothing will lift. The table otherwise
|
|
|
|
|
|
// runs AHEAD of the shipping list on purpose — GB keeps its GBP row while its
|
|
|
|
|
|
// e-waste registrations are pending, because "what would this cost me" stays
|
|
|
|
|
|
// a fair question for a country one small registration from opening, and
|
|
|
|
|
|
// deleting the row to restore it weeks later would be churn. The refusal that
|
|
|
|
|
|
// must not be quoted around is enforced in SellsTo, not here.
|
|
|
|
|
|
Check(CurrencyFor("GB").has_value(),
|
|
|
|
|
|
"fx: a temporarily-closed destination keeps its display currency");
|
2026-08-15 00:54:05 +02:00
|
|
|
|
|
|
|
|
|
|
// ── 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");
|
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// 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");
|
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
|
if (failures != 0) {
|
|
|
|
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|