catcrafts.net/tests/ShouldFoldTheOrderLedger/main.cpp
Jorijn van der Graaf abbd616b40
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
donation item, shop soft open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:04:03 +02:00

592 lines
29 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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;
}