donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-17 11:04:03 +02:00
commit abbd616b40
23 changed files with 2898 additions and 209 deletions

View file

@ -94,8 +94,8 @@ void OpenShopLifecycle(TestServer& srv) {
{
const std::string ledger = srv.OrdersText();
Check(ledger.find("\"country\":\"NL\"") != std::string::npos
&& ledger.find("\"total_minor\":57830") != std::string::npos,
"order stored: NL total is €578.30 (green €563.30 + €15 shipping)");
&& ledger.find("\"total_minor\":58880") != std::string::npos,
"order stored: NL total is €588.80 (green €573.80 + €15 shipping)");
// No `pay` field in that submission, which is what a form with only
// one rail configured posts: it must land on the bank rail rather
// than nothing.
@ -109,7 +109,7 @@ void OpenShopLifecycle(TestServer& srv) {
{
const std::string page = srv.Body(orderPath);
for (std::string_view probe : { "awaiting payment", "Resume payment", "CC-",
"http-equiv=\"refresh\"", "€578.30" }) {
"http-equiv=\"refresh\"", "€588.80" }) {
Check(page.find(probe) != std::string::npos,
std::format("order page has {}", probe));
}
@ -173,8 +173,8 @@ void OpenShopLifecycle(TestServer& srv) {
Check(!tokenGb.empty(), "GB checkout issues an order");
if (!tokenGb.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenGb));
// €465.54 goods (green net) + €55 world shipping = €520.54
Check(page.find("€520.54") != std::string::npos,
// €474.21 goods (green net) + €55 world shipping = €529.21
Check(page.find("€529.21") != std::string::npos,
"export order total is ex-VAT + world shipping");
Check(page.find("Zero-rated export") != std::string::npos,
"export order states the VAT treatment");
@ -184,14 +184,14 @@ void OpenShopLifecycle(TestServer& srv) {
"conversion is labelled indicative");
}
// A two-unit white export order: unit €665, line €1330, net from the LINE
// total (not per unit) = €1082.45, plus €55 world shipping = €1137.45.
// A two-unit white export order: unit €665.38, line €1330.76, net from the
// LINE total (not per unit) = €1099.80, plus €55 world shipping = €1154.80.
const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos",
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=GB&color=white&quantity=2"));
Check(!tokenWhite.empty(), "white ×2 checkout issues an order");
if (!tokenWhite.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenWhite));
Check(page.find("€1137.45") != std::string::npos,
Check(page.find("€1154.80") != std::string::npos,
"white ×2 export total nets the line, not the unit");
Check(page.find("Device × 2") != std::string::npos, "order page shows the quantity");
Check(page.find("White") != std::string::npos, "order page names the colour");
@ -286,7 +286,7 @@ void OpenShopLifecycle(TestServer& srv) {
for (std::string_view probe : { "BEGIN PGP SIGNED MESSAGE", "# Invoice ",
"Customer number: ", "Chico Mendesring 256",
"KVK 78437059", "NL003329281B38", "CC-",
"VAT 21% (NL)", "€578.30" }) {
"VAT 21% (NL)", "€588.80" }) {
Check(invoice.body.find(probe) != std::string::npos,
std::format("invoice has {}", probe));
}
@ -372,7 +372,7 @@ void OpenShopLifecycle(TestServer& srv) {
if (!nlMail.empty()) {
for (std::string_view probe : { "To: e2e@example.org", "Subject: Catcrafts order CC-",
"From: Catcrafts <info@catcrafts.net>",
"MIME-Version: 1.0", "€578.30", "incl. 21% NL VAT",
"MIME-Version: 1.0", "€588.80", "incl. 21% NL VAT",
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
"filename=\"catcrafts-invoice-" }) {
Check(nlMail.find(probe) != std::string::npos,
@ -495,6 +495,186 @@ void AlwaysOnValidation(TestServer& srv) {
srv.CheckStatus("/shop/nope", "404", "POST", Good()); // unknown product
}
// The donation item is open in BOTH shop states — it is the soft opening the
// coming-soon phone waits behind — so this whole lifecycle runs
// unconditionally: create with a buyer-named amount, settle on the fake rail,
// confirm there is no invoice and no VAT, and watch /financials book it under
// donations rather than sales.
void DonationLifecycle(TestServer& srv) {
// ── validation ────────────────────────────────────────────────────
// The donation validator's refusals, over real HTTP. No address fields
// exist to miss; the amount is the field that carries the rules.
srv.CheckStatus("/shop/donation", "422", "POST", "email=a%40b.example"); // no amount
srv.CheckStatus("/shop/donation", "422", "POST", "amount=nonsense");
srv.CheckStatus("/shop/donation", "422", "POST", "amount=0.50"); // below €1
srv.CheckStatus("/shop/donation", "422", "POST", "amount=10000.01"); // above €10k
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&email=nonsense");
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&website=spam"); // honeypot
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&pay=free");
{
// A refused amount comes back in the form, like any rejected field.
const auto refused = srv.Post("/shop/donation", "amount=0.50");
Check(refused.body.find("field__error") != std::string::npos,
"a refused donation shows a field error");
Check(refused.body.find("€1 to €10000") != std::string::npos,
"the amount refusal names the bounds");
}
// ── a donation with no email at all ───────────────────────────────
// Identity is optional: the capability URL is the receipt.
const auto created = srv.Post("/shop/donation", "amount=25");
const std::string token = TokenOf(created);
Check(created.status == "303" && !token.empty(),
"POST donation -> 303 straight to payment", created.status);
{
const std::string line = [&] {
for (const std::string& l : LedgerLines(srv)) {
if (l.find(std::format("\"id\":\"{}\"", token)) != std::string::npos
&& l.find("\"type\":\"order\"") != std::string::npos) {
return l;
}
}
return std::string{};
}();
Check(!line.empty(), "the donation order is on the ledger");
for (std::string_view probe : { "\"donation\":true", "\"total_minor\":2500",
"\"shipping_minor\":0", "\"vat_included\":false",
"\"product\":\"donation\"", "\"email\":\"\"" }) {
Check(line.find(probe) != std::string::npos,
std::format("donation ledger line has {}", probe));
}
}
const std::string orderPath = std::format("/order/{}", token);
{
const std::string page = srv.Body(orderPath);
Check(page.find("€25") != std::string::npos,
"donation order page shows the amount");
Check(page.find("Shipping") == std::string::npos,
"donation order page has no shipping row");
}
// ── the payment lands ─────────────────────────────────────────────
// Same fake-rail marker as checkout (idempotent if the open-shop half
// already created it). The paid state is a thank-you, not a dispatch
// promise, and there is no invoice to download — not before, not after.
const std::size_t invoicesBefore =
CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"");
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
WriteFile(std::filesystem::path(srv.Orders().string() + ".fake-paid"), "");
{
const std::string page = srv.WaitForBody(orderPath, "Thank you");
Check(page.find("Thank you") != std::string::npos,
"a paid donation says thank you");
Check(page.find("VAT 0%") != std::string::npos,
"a paid donation states the 0% VAT treatment");
Check(page.find("Zero-rated export") == std::string::npos,
"a donation is not worded as an export");
Check(page.find("invoice.md") == std::string::npos,
"a paid donation offers no invoice download");
Check(page.find("flashed and tested") == std::string::npos,
"a paid donation promises no dispatch");
}
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
// Settle a moment: no invoice event may appear for a donation, ever.
SettleUntil([&] {
return srv.OrdersText().find(std::format("\"id\":\"{}\",\"status\":\"paid\"", token))
!= std::string::npos;
});
Check(CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"") == invoicesBefore,
"a paid donation is assigned no invoice number");
// ── /financials books it under donations ──────────────────────────
// Ledger-derived, same rule as the sales assertion: paid donation orders
// sum into the donations pair, paid goods orders into sales, and no euro
// sits in both.
{
std::set<std::string> paidIds;
const std::vector<std::string> lines = LedgerLines(srv);
for (const std::string& line : lines) {
const auto event = Json::Parse(line);
if (!event || !event->IsObject()) continue;
if (event->Str("type") == "status" && event->Str("status") == "paid") {
paidIds.insert(std::string(event->Str("id")));
}
}
std::int64_t wantSales = 0, wantDonations = 0;
std::size_t wantSalesCount = 0, wantDonationCount = 0;
for (const std::string& line : lines) {
const auto event = Json::Parse(line);
if (!event || !event->IsObject()) continue;
if (event->Str("type") != "order"
|| !paidIds.contains(std::string(event->Str("id")))) {
continue;
}
if (event->Bool("donation")) {
wantDonations += event->Int("total_minor");
++wantDonationCount;
} else {
wantSales += event->Int("total_minor");
++wantSalesCount;
}
}
const std::string page = srv.Body("/financials");
Check(wantDonationCount > 0, "at least one paid donation is on the ledger");
Check(page.find(std::format("data-fin-donations-count=\"{}\"", wantDonationCount))
!= std::string::npos
&& page.find(std::format("data-fin-donations-minor=\"{}\"", wantDonations))
!= std::string::npos,
std::format("donation totals equal the ledger ({} donations, {} cents)",
wantDonationCount, wantDonations));
Check(page.find(std::format("data-fin-sales-count=\"{}\"", wantSalesCount))
!= std::string::npos
&& page.find(std::format("data-fin-sales-minor=\"{}\"", wantSales))
!= std::string::npos,
"sales totals exclude the donation");
}
// ── the confirmation email ────────────────────────────────────────
// With an email given: a thank-you, no invoice attached (none exists).
// Without one: silence — no address means the donor asked for nothing.
const std::size_t mailsBefore = MailCount(srv);
const std::string tokenMailed = TokenOf(srv.Post("/shop/donation",
"amount=10&email=donor%40example.org"));
Check(!tokenMailed.empty(), "a donation with an email goes through");
SettleUntil([&] { return MailCount(srv) > mailsBefore; }, 60);
std::string donationMail;
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
const std::string name = entry.path().filename().string();
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
const std::string mail = ReadFile(entry.path());
if (mail.find(std::format("/order/{}", tokenMailed)) != std::string::npos) {
donationMail = mail;
}
}
Check(!donationMail.empty(), "a donation with an email gets a confirmation");
if (!donationMail.empty()) {
for (std::string_view probe : { "To: donor@example.org",
"Subject: Catcrafts donation CC-",
"Thank you", "€10",
"No VAT applies" }) {
Check(donationMail.find(probe) != std::string::npos,
std::format("donation email has {}", probe));
}
Check(donationMail.find("BEGIN PGP SIGNED MESSAGE") == std::string::npos
&& donationMail.find("filename=\"catcrafts-invoice-") == std::string::npos,
"donation email attaches no invoice");
}
// The no-email donation stays unmailed: sit out two mailer sweeps and
// expect no message carrying its link.
std::this_thread::sleep_for(std::chrono::seconds(5));
bool mailedAnyway = false;
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
const std::string name = entry.path().filename().string();
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
if (ReadFile(entry.path()).find(std::format("/order/{}", token))
!= std::string::npos) {
mailedAnyway = true;
}
}
Check(!mailedAnyway, "a donation without an email is never emailed");
}
// The re-rendered form only exists when the shop is open; while coming-soon a
// rejection answers with the coming-soon page instead.
void RejectedFormEcho(TestServer& srv) {
@ -582,6 +762,9 @@ int main(int argc, char** argv) {
AlwaysOnValidation(srv);
// The donation item is open in both shop states — that is the point of it.
DonationLifecycle(srv);
if (srv.ShopOpen()) {
RejectedFormEcho(srv);
}