All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
592 lines
29 KiB
C++
592 lines
29 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 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;
|
||
}
|