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

@ -0,0 +1,592 @@
/*
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 order ledger: the append-only JSON-lines log and the left fold that turns
// it back into orders. Everything downstream — the buyer's order page, the
// invoice, the reconciler, the mailer, the public sales total on /financials —
// reads whatever this fold says, and nothing else. So the properties pinned
// here are the ones that decide whether an order exists, what it is worth, and
// when it was paid.
//
// This suite drives the REAL storage functions against a scratch ledger rather
// than constructing OrderRecords by hand: the interesting behaviour is in the
// formatter and the fold, not in the struct.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
namespace fs = std::filesystem;
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);
}
fs::path gWork;
// The ledger path is process-global state, so every scenario gets its own file
// rather than inheriting the previous one's history.
fs::path FreshLedger(std::string_view name) {
const fs::path p = gWork / std::format("{}.jsonl", name);
std::error_code ec;
fs::remove(p, ec);
Server::SetOrdersPath(p);
return p;
}
// Hand-written ledgers: the fold's real input, byte for byte. Several
// properties here (a truncated line, a replayed event, a line an older build
// wrote) cannot be produced through CreateOrder at all.
void WriteLedger(const fs::path& p, std::initializer_list<std::string_view> lines) {
std::ofstream out(p, std::ios::trunc | std::ios::binary);
for (const std::string_view line : lines) out << line << '\n';
}
void AppendRaw(const fs::path& p, std::string_view line) {
std::ofstream out(p, std::ios::app | std::ios::binary);
out << line << '\n';
}
std::string ReadAll(const fs::path& p) {
std::ifstream in(p, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(in),
std::istreambuf_iterator<char>());
}
std::size_t CountOf(std::string_view hay, std::string_view needle) {
std::size_t n = 0;
for (std::size_t i = hay.find(needle); i != std::string_view::npos;
i = hay.find(needle, i + needle.size())) {
++n;
}
return n;
}
// The single ledger line containing `needle`, without its terminator; empty
// when no line has it.
std::string LineContaining(std::string_view text, std::string_view needle) {
std::size_t start = 0;
while (start <= text.size()) {
const std::size_t nl = text.find('\n', start);
const std::size_t end = (nl == std::string_view::npos) ? text.size() : nl;
const std::string_view line = text.substr(start, end - start);
if (line.find(needle) != std::string_view::npos) return std::string(line);
if (nl == std::string_view::npos) break;
start = nl + 1;
}
return {};
}
// A stored order with every field populated, so a scenario only has to say
// what it cares about.
Server::OrderRecord Sample(std::string token, std::string email) {
Server::OrderRecord o;
o.token = std::move(token);
o.reference = Server::ReferenceFromToken(o.token);
o.product = "fairphone-6";
o.color = "green";
o.quantity = 1;
o.unitMinor = 56330;
o.createdAt = "2026-08-15T09:00:00Z";
o.buyer = { std::move(email), "Ada Lovelace", "Main St 1", "1234AB",
"Delft", "NL" };
o.goodsMinor = 56330;
o.shippingMinor = 1500;
o.totalMinor = 57830; // 56330 + 1500
o.vatIncluded = true;
o.payChoice = std::string(Form::kPayBank);
o.payUrl = "https://pay.example.org/tr_test";
o.payId = "tr_test";
return o;
}
// ── buyer free text cannot leave its field ────────────────────────────
//
// Form::ValidateCheckout length-limits name/street/postal/city and nothing
// more, so quotes, backslashes and newlines reach CreateOrder's formatter
// exactly as they were typed. JsonEscape is the only thing between them and
// the record format.
void BuyerTextStaysInItsField() {
FreshLedger("escaping");
Server::OrderRecord o = Sample("1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a", "ada@example.org");
// Shaped to close the name string and open a "total_minor":1 of its own.
// Json::Value::Find returns the FIRST match for a key, so an unescaped
// quote here would make this €578.30 order worth one cent — and
// total_minor is what the invoice bills and what /financials publishes.
o.buyer.name = R"(Ada ","total_minor":1,"x":")";
Check(!Server::FindOrder(o.token).has_value(),
"ledger: a file that does not exist yet holds no orders");
Check(Server::CreateOrder(o), "escaping: the order is written");
const std::optional<Server::OrderRecord> back = Server::FindOrder(o.token);
Check(back.has_value(), "escaping: an injected name still parses as one record");
if (back) {
Check(back->buyer.name == o.buyer.name,
"escaping: the name round-trips byte for byte", back->buyer.name);
Check(back->totalMinor == 57830,
"escaping: a buyer cannot mint their own total_minor",
std::format("{}", back->totalMinor));
}
// A backslash and a raw newline, on their own ledger so the line count
// below means what it says.
const fs::path solo = FreshLedger("escaping-newline");
Server::OrderRecord n = Sample("2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b", "ada@example.org");
n.buyer.street = R"(Main \ St 1)";
n.buyer.city = "Delft\nNL";
Check(Server::CreateOrder(n), "escaping: the order carrying a newline is written");
const std::optional<Server::OrderRecord> back2 = Server::FindOrder(n.token);
Check(back2.has_value(), "escaping: a newline in the address does not lose the record");
if (back2) {
Check(back2->buyer.street == R"(Main \ St 1)",
"escaping: a backslash survives the round trip", back2->buyer.street);
Check(back2->buyer.city == "Delft\nNL",
"escaping: an embedded newline survives the round trip");
}
// One record is one LINE. An unescaped newline would split this order in
// two: the reader would keep the head and silently drop the address, the
// total and the payment id.
const std::string text = ReadAll(solo);
Check(CountOf(text, "\n") == 1, "escaping: one order is exactly one line",
std::format("{} line terminator(s)", CountOf(text, "\n")));
Check(Server::ListOrders().size() == 1,
"escaping: and the file folds back to exactly one order");
}
// ── the sale is the FIRST paid event ──────────────────────────────────
//
// The join between the append-only log and the public sales figure. A refund
// folds the status onward but never un-happens the payment, and a replayed
// paid line must not be able to move the recorded moment of sale — the
// timestamp both the bookkeeping and the invoice sequence hang off.
void TheSaleIsTheFirstPaidEvent() {
const fs::path led = FreshLedger("paid-then-cancelled");
WriteLedger(led, {
R"({"type":"order","at":"2025-12-31T23:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
R"("ref":"CC-A1B2C3","product":"fairphone-6","email":"ada@example.org",)"
R"("total_minor":57830})",
R"({"type":"status","at":"2026-01-01T00:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
R"("status":"paid","via":"ideal"})",
// The same paid event again — a redelivered provider callback, or a
// reconciler sweep that ran twice.
R"({"type":"status","at":"2026-06-06T00:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
R"("status":"paid","via":"ideal"})",
// Six months later the sale is refunded.
R"({"type":"status","at":"2026-07-07T00:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
R"("status":"cancelled"})",
});
const std::optional<Server::OrderRecord> o =
Server::FindOrder("a1b2c3d4e5f60718293a4b5c6d7e8f90");
Check(o.has_value(), "fold: the hand-written order resolves");
if (o) {
Check(o->paidAt == "2026-01-01T00:00:00Z",
"fold: paidAt is the first paid event, not the replayed one", o->paidAt);
Check(o->status == "cancelled", "fold: the latest status wins", o->status);
Check(o->updatedAt == "2026-07-07T00:00:00Z",
"fold: updatedAt follows the newest event folded in", o->updatedAt);
// The refund carries no `via`, and losing the method here would erase
// exactly which orders were settled with reversible money.
Check(o->paidVia == "ideal", "fold: the settlement method outlives the refund",
o->paidVia);
}
// …and the refunded order is still a sale: /financials counts ever-paid.
const std::vector<Server::OrderRecord> all = Server::ListOrders();
const Server::SalesSummary sum = Server::SummarizeSales(all);
Check(sum.count == 1 && sum.totalMinor == 57830,
"fold: a refunded order still counts as a sale",
std::format("count {} total {}", sum.count, sum.totalMinor));
}
// ── invoice numbers are a sequence, per customer ──────────────────────
//
// Art. 226(2) permits "one or more series"; this is one series per customer,
// keyed on the case-normalised email. Both the invoice download route and the
// confirmation mailer call this on orders that may already carry a number, so
// idempotency is not an optimisation — a second number for one sale means the
// attached invoice stops matching the ledger.
void InvoiceNumbersAreASequence() {
const fs::path led = FreshLedger("invoice-idempotent");
const Server::OrderRecord o = Sample("3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c", "ada@example.org");
Check(Server::CreateOrder(o), "invoice: the order is written");
const std::optional<std::string> first =
Server::AssignInvoiceNumber(o.token, "2026-08-15T10:00:00Z");
Check(first.has_value(), "invoice: a stored order gets a number");
if (first) {
// "<uuid(36)>-<seq>": 36 + 1 + 1 for a customer's first invoice.
Check(first->size() == 38, "invoice: <uuid>-<seq> shape", *first);
Check((*first)[36] == '-', "invoice: the sequence hangs off a 36-char customer number",
*first);
Check(first->ends_with("-1"), "invoice: a new customer's series starts at 1", *first);
}
const std::optional<std::string> again =
Server::AssignInvoiceNumber(o.token, "2026-08-15T10:05:00Z");
Check(again == first, "invoice: re-assigning returns the number already issued",
again.value_or("<none>"));
Check(CountOf(ReadAll(led), R"("type":"invoice")") == 1,
"invoice: and appends no second invoice event");
// One customer who typed their address differently the second time is
// still one customer, and so one series.
FreshLedger("invoice-series");
const Server::OrderRecord a = Sample("4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d", "Ada@Example.org");
const Server::OrderRecord b = Sample("5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e", "ada@example.org");
Check(Server::CreateOrder(a) && Server::CreateOrder(b),
"invoice: both of the customer's orders are written");
const std::optional<std::string> na =
Server::AssignInvoiceNumber(a.token, "2026-08-15T10:00:00Z");
const std::optional<std::string> nb =
Server::AssignInvoiceNumber(b.token, "2026-08-16T10:00:00Z");
Check(na.has_value() && nb.has_value(), "invoice: both orders get numbers");
if (na && nb) {
Check(na->substr(0, 36) == nb->substr(0, 36),
"invoice: a differently-cased email is the same customer number",
std::format("{} vs {}", *na, *nb));
Check(na->ends_with("-1") && nb->ends_with("-2"),
"invoice: the second sale continues the series rather than starting one",
std::format("{} then {}", *na, *nb));
}
// A token no order line names. Numbering an order that does not exist
// would burn a member of the sequence on nothing.
Check(!Server::AssignInvoiceNumber("deadbeefdeadbeefdeadbeefdeadbeef",
"2026-08-15T10:00:00Z").has_value(),
"invoice: no order, no number");
}
// ── one bad line is only one bad line ─────────────────────────────────
//
// The design explicitly accepts a truncated last line from a crash mid-append.
// If the fold aborted on a parse failure instead of skipping, one interrupted
// write would 404 every order before it: buyers lose their status page, the
// mailer stops, and /financials silently drops to zero.
void OneBadLineIsOnlyOneBadLine() {
const fs::path led = FreshLedger("corrupt");
WriteLedger(led, {
R"({"type":"order","at":"2026-08-01T09:00:00Z","id":"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",)"
R"("ref":"CC-0A0A0A","product":"fairphone-6","email":"a@example.org",)"
R"("total_minor":57830})",
"not json at all",
"",
// A crash between the write and the newline.
R"({"type":"order","id":"bbbb)",
R"({"type":"order","at":"2026-08-02T09:00:00Z","id":"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",)"
R"("ref":"CC-0B0B0B","product":"fairphone-6","email":"b@example.org",)"
R"("total_minor":11111})",
});
const std::vector<Server::OrderRecord> all = Server::ListOrders();
Check(all.size() == 2, "ledger: three unreadable lines cost three lines and no more",
std::format("{} record(s)", all.size()));
if (all.size() == 2) {
Check(all[0].token == "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a"
&& all[1].token == "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
"ledger: both good orders survive, in file order");
Check(all[1].totalMinor == 11111,
"ledger: the record after the truncated line is complete");
}
Check(Server::FindOrder("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b").has_value(),
"ledger: and it still resolves by token");
// An order event with no id names no order. Folding it in as a tokenless
// record would give every later id-less event something to match.
AppendRaw(led,
R"({"type":"order","at":"2026-08-03T09:00:00Z","ref":"CC-NOID00",)"
R"("product":"fairphone-6","email":"c@example.org","total_minor":100})");
const std::vector<Server::OrderRecord> after = Server::ListOrders();
Check(after.size() == 2, "ledger: an order event with no id is dropped",
std::format("{} record(s)", after.size()));
bool tokenless = false;
for (const Server::OrderRecord& r : after) tokenless = tokenless || r.token.empty();
Check(!tokenless, "ledger: no tokenless record is ever folded in");
}
// ── history is not rewritten ──────────────────────────────────────────
//
// The file is append-only precisely so an amount cannot be changed after the
// fact; first-order-event-wins is the enforcement. A later line that
// overwrote the total would change what the invoice bills, what the
// reconciler matches against the provider, and what /financials reports —
// leaving no trace, since the original line still sits in the file.
void HistoryIsNotRewritten() {
const fs::path led = FreshLedger("duplicate-order");
WriteLedger(led, {
R"({"type":"order","at":"2026-08-01T09:00:00Z","id":"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",)"
R"("ref":"CC-0D0D0D","product":"fairphone-6","email":"first@example.org",)"
R"("total_minor":57830})",
R"({"type":"order","at":"2026-08-01T09:05:00Z","id":"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",)"
R"("ref":"CC-0D0D0D","product":"fairphone-6","email":"second@example.org",)"
R"("total_minor":1})",
});
const std::vector<Server::OrderRecord> all = Server::ListOrders();
Check(all.size() == 1, "ledger: a duplicate order event yields one record, not two",
std::format("{} record(s)", all.size()));
if (all.size() == 1) {
Check(all[0].totalMinor == 57830,
"ledger: the first order event fixes the amount",
std::format("{}", all[0].totalMinor));
Check(all[0].buyer.email == "first@example.org",
"ledger: and the buyer it was sold to", all[0].buyer.email);
Check(all[0].createdAt == "2026-08-01T09:00:00Z",
"ledger: and when the sale happened", all[0].createdAt);
}
}
// ── a ledger written by an older build still reads ────────────────────
//
// The module states this as a design guarantee, and it is the reason nothing
// is ever rewritten in place: every key added since must default to something
// an old line can live with.
void AnOlderLedgerStillReads() {
const fs::path led = FreshLedger("old-build");
WriteLedger(led, {
R"({"type":"order","at":"2026-02-02T08:00:00Z","id":"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",)"
R"("ref":"CC-0E0E0E","product":"fairphone-6","email":"old@example.org",)"
R"("total_minor":56330})",
});
const std::optional<Server::OrderRecord> o =
Server::FindOrder("0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e");
Check(o.has_value(), "old ledger: a pre-variants order line still resolves");
if (o) {
// Int("quantity", 1) is the only thing standing between an old line
// and a "× 0" on the buyer's page — and a zero-quantity line on an
// invoice that is a legal document.
Check(o->quantity == 1, "old ledger: an absent quantity reads as one, never zero",
std::format("{}", o->quantity));
// An empty status would make the order invisible to the reconciler
// (not awaiting_payment) and to the mailer (not paid) at once.
Check(o->status == "awaiting_payment",
"old ledger: an absent status reads as awaiting payment", o->status);
Check(o->totalMinor == 56330, "old ledger: what it does say is read");
Check(o->color.empty(), "old ledger: pre-variants orders have no colour");
Check(o->payChoice.empty(),
"old ledger: the rail is read as written, with no default invented");
Check(!o->vatIncluded, "old ledger: an absent vat_included is false");
// Every order written before donations existed is a sale, or the
// public sales total would quietly shrink under a newer build.
Check(!o->donation, "old ledger: an absent donation flag reads as a sale");
Check(o->unitMinor == 0 && o->goodsMinor == 0 && o->shippingMinor == 0,
"old ledger: absent amounts are zero, not garbage");
Check(o->createdAt == "2026-02-02T08:00:00Z" && o->updatedAt == o->createdAt,
"old ledger: an order with no later event was last updated when it was made");
Check(o->paidAt.empty() && o->invoiceNumber.empty() && o->confirmationSentAt.empty(),
"old ledger: never paid, never invoiced, never emailed");
}
}
// ── only a confirmation marks the confirmation sent ───────────────────
//
// confirmationSentAt is the only thing that stops MailerLoop re-sending, and
// the only thing that makes it send at all. "what" exists so the future
// shipped-notice can share this event type; if the fold matched any "what",
// that notice would mark the order as already notified and a buyer who paid
// would get neither confirmation nor invoice.
void OnlyAConfirmationMarksTheEmailSent() {
const fs::path led = FreshLedger("notified");
const Server::OrderRecord a = Sample("1111111111111111aaaaaaaaaaaaaaaa", "a@example.org");
const Server::OrderRecord b = Sample("2222222222222222bbbbbbbbbbbbbbbb", "b@example.org");
Check(Server::CreateOrder(a) && Server::CreateOrder(b),
"notified: both orders are written");
Check(Server::AppendOrderNotified(a.token, "2026-08-15T10:00:00Z"),
"notified: the event is appended");
Check(CountOf(ReadAll(led), R"("what":"confirmation")") == 1,
"notified: the writer names the message it sent");
const std::optional<Server::OrderRecord> ra = Server::FindOrder(a.token);
Check(ra && ra->confirmationSentAt == "2026-08-15T10:00:00Z",
"notified: the confirmation timestamp folds in",
ra ? ra->confirmationSentAt : std::string("<no order>"));
// A different message about a different order.
AppendRaw(led,
R"({"type":"notified","at":"2026-08-15T11:00:00Z",)"
R"("id":"2222222222222222bbbbbbbbbbbbbbbb","what":"shipped"})");
const std::optional<Server::OrderRecord> rb = Server::FindOrder(b.token);
Check(rb.has_value(), "notified: the second order still resolves");
Check(rb && rb->confirmationSentAt.empty(),
"notified: a shipped notice does not claim the confirmation was sent",
rb ? rb->confirmationSentAt : std::string("<no order>"));
const std::optional<Server::OrderRecord> ra2 = Server::FindOrder(a.token);
Check(ra2 && ra2->confirmationSentAt == "2026-08-15T10:00:00Z",
"notified: and it does not disturb the order that was confirmed");
}
// ── a donation is income, never a sale ────────────────────────────────
//
// The donation flag decides three downstream behaviours at once (no invoice,
// the thank-you email, and WHICH /financials row the money lands in), so what
// is pinned here is the ledger's half: the flag round-trips through the
// writer and the fold, an old line without the key stays a sale, and
// SummarizeSales books each paid euro in exactly one row.
void ADonationIsIncomeNeverASale() {
const fs::path led = FreshLedger("donation");
Server::OrderRecord sale = Sample("6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f", "ada@example.org");
Server::OrderRecord gift = Sample("7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a", "");
gift.product = "donation";
gift.donation = true;
gift.color.clear();
gift.buyer = { "", "", "", "", "", "" }; // nothing ships, nothing stored
gift.unitMinor = 2500;
gift.goodsMinor = 2500;
gift.shippingMinor = 0;
gift.totalMinor = 2500;
gift.vatIncluded = false;
Check(Server::CreateOrder(sale) && Server::CreateOrder(gift),
"donation: both records are written");
// The writer follows the omit-rather-than-empty rule: the key exists only
// on the donation's line, which is what keeps absent-means-false safe.
const std::string text = ReadAll(led);
Check(CountOf(text, R"("donation":true)") == 1,
"donation: the flag is written once, on the donation's line");
const std::string saleLine = LineContaining(text, sale.token);
Check(!saleLine.empty() && saleLine.find(R"("donation")") == std::string::npos,
"donation: a sale's line carries no donation key at all");
const std::optional<Server::OrderRecord> back = Server::FindOrder(gift.token);
Check(back.has_value() && back->donation,
"donation: the flag folds back in");
const std::optional<Server::OrderRecord> saleBack = Server::FindOrder(sale.token);
Check(saleBack.has_value() && !saleBack->donation,
"donation: a sale folds back as one");
// Both paid: each euro lands in exactly one summary row.
Check(Server::AppendOrderStatus(sale.token, "paid", "2026-08-17T10:00:00Z", "ideal")
&& Server::AppendOrderStatus(gift.token, "paid", "2026-08-17T10:01:00Z",
"eurc-base"),
"donation: both paid transitions append");
const Server::SalesSummary sum = Server::SummarizeSales(Server::ListOrders());
Check(sum.count == 1 && sum.totalMinor == 57830,
"donation: the sale row counts only the sale",
std::format("count {} total {}", sum.count, sum.totalMinor));
Check(sum.donationCount == 1 && sum.donationsMinor == 2500,
"donation: the donation row counts only the donation",
std::format("count {} total {}", sum.donationCount, sum.donationsMinor));
// An UNPAID donation is nothing yet — same ever-paid rule as sales.
const fs::path led2 = FreshLedger("donation-unpaid");
Server::OrderRecord pending = gift;
pending.token = "8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b";
Check(Server::CreateOrder(pending), "donation: the unpaid donation is written");
const Server::SalesSummary none = Server::SummarizeSales(Server::ListOrders());
Check(none.donationCount == 0 && none.donationsMinor == 0,
"donation: an unpaid donation counts nothing");
}
// ── a transition keeps what it does not name ──────────────────────────
//
// paidVia is how the ledger shows at a glance which orders carry reversible
// card money for months — the stated reason `via` exists at all. Losing it on
// the next transition would erase that flag exactly when an order ships.
void ATransitionKeepsWhatItDoesNotName() {
const fs::path led = FreshLedger("status");
const Server::OrderRecord o = Sample("f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", "ada@example.org");
Check(Server::CreateOrder(o), "status: the order is written");
Check(Server::AppendOrderStatus(o.token, "paid", "2026-08-15T09:00:00Z", "creditcard"),
"status: the paid transition is appended");
Check(Server::AppendOrderStatus(o.token, "shipped", "2026-08-15T11:00:00Z"),
"status: the shipped transition is appended");
const std::optional<Server::OrderRecord> r = Server::FindOrder(o.token);
Check(r.has_value(), "status: the order resolves");
if (r) {
Check(r->status == "shipped", "status: the latest transition wins", r->status);
Check(r->paidVia == "creditcard",
"status: the settlement method survives the next transition", r->paidVia);
Check(r->paidAt == "2026-08-15T09:00:00Z",
"status: shipping does not restate when it was paid", r->paidAt);
Check(r->updatedAt == "2026-08-15T11:00:00Z",
"status: updatedAt follows the transition", r->updatedAt);
}
// The writer omits the key entirely rather than writing an empty one:
// that is what makes "absent means unchanged" safe to rely on in the fold.
const std::string text = ReadAll(led);
const std::string shipped = LineContaining(text, R"("status":"shipped")");
Check(!shipped.empty(), "status: the shipped transition is on the file");
Check(shipped.find(R"("via")") == std::string::npos,
"status: a transition with no method writes no via key at all", shipped);
// An empty status is not a state. Applying it would leave the order
// invisible to the reconciler, the mailer and the invoice route at once.
AppendRaw(led,
R"({"type":"status","at":"2026-08-15T12:00:00Z",)"
R"("id":"f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0","status":""})");
const std::optional<Server::OrderRecord> blanked = Server::FindOrder(o.token);
Check(blanked && blanked->status == "shipped",
"status: an empty status is skipped, not applied",
blanked ? blanked->status : std::string("<no order>"));
Check(blanked && blanked->updatedAt == "2026-08-15T11:00:00Z",
"status: and it does not even move updatedAt",
blanked ? blanked->updatedAt : std::string("<no order>"));
// A transition naming an order that does not exist.
AppendRaw(led,
R"({"type":"status","at":"2026-08-15T13:00:00Z",)"
R"("id":"deadbeefdeadbeefdeadbeefdeadbeef","status":"cancelled"})");
const std::vector<Server::OrderRecord> all = Server::ListOrders();
Check(all.size() == 1, "status: an event for an unknown order conjures no record",
std::format("{} record(s)", all.size()));
if (all.size() == 1) {
Check(all[0].status == "shipped" && all[0].updatedAt == "2026-08-15T11:00:00Z",
"status: and leaves the order that does exist alone");
}
}
} // namespace
int main() {
std::error_code ec;
// The suites run in parallel, so the scratch directory has to be unique
// per run rather than merely per suite.
gWork = fs::temp_directory_path(ec)
/ std::format("catcrafts-ledger-{}", Server::NewOrderToken());
fs::create_directories(gWork, ec);
if (ec) {
std::println(std::cerr, "could not create {}: {}", gWork.string(), ec.message());
return 1;
}
BuyerTextStaysInItsField();
TheSaleIsTheFirstPaidEvent();
InvoiceNumbersAreASequence();
OneBadLineIsOnlyOneBadLine();
HistoryIsNotRewritten();
AnOlderLedgerStillReads();
ADonationIsIncomeNeverASale();
OnlyAConfirmationMarksTheEmailSent();
ATransitionKeepsWhatItDoesNotName();
fs::remove_all(gWork, ec);
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}