donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
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:
parent
b0666841f6
commit
abbd616b40
23 changed files with 2898 additions and 209 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue