167 lines
8.9 KiB
C++
167 lines
8.9 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.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
// 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");
|
|||
|
|
|
|||
|
|
// ── zones and membership ──────────────────────────────────────────
|
|||
|
|
Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members");
|
|||
|
|
Check(!IsEuCountry("GB"), "eu: UK left");
|
|||
|
|
Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU");
|
|||
|
|
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");
|
|||
|
|
|
|||
|
|
// ── 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");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 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");
|
|||
|
|
|
|||
|
|
// ── 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");
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 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");
|
|||
|
|
|
|||
|
|
if (failures != 0) {
|
|||
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
|||
|
|
return 1;
|
|||
|
|
}
|
|||
|
|
return 0;
|
|||
|
|
}
|