crates/monetize-admin/src/money_log.rs
//! ★ **The money lines' own home** (ALICA-3, the owner's purchase 2026-10-26): the ONE box's
//! boot log is one ring for every line PID 1 and its children write, so an hour of log noise (the
//! HTTP door's reads, the guardian's ticks) pushed the lines of a purchase out of it before
//! anybody read them. Every MONEY line PID 1 hears is ALSO kept here, on the data disk:
//! `<data>/money/lines.log`, bounded by its own size only ([`MAX_BYTES`], then one previous file
//! `lines.log.1`), so no number of other lines can push one out, and a restart or a self update
//! keeps them. The operator reads them at the door's `GET /money/log` (the newest
//! [`DEFAULT_TAIL`] lines, `?tail=N` up to [`MAX_TAIL`]).
//!
//! A money line is one of monetize's own lines about money ([`MARKS`]): a sign-up's settle, a
//! vendor's callback, the reconciler, a private order's hold and capture, a refund, nonpay, a
//! purchase or card check, and the alerts about them. It is a log's routing, decided nowhere else:
//! the line is still in the boot log too.
use std::io::Write;
use std::path::{Path, PathBuf};
/// Where the money lines live, under the box's data root.
pub const REL: &str = "money/lines.log";
/// The home's bound: past it the file becomes `lines.log.1` (the previous one goes) and a new one
/// starts, so the home holds between one and two of these. Money lines are rare (a purchase is
/// tens of lines): 8 MiB is months of them.
pub const MAX_BYTES: u64 = 8 << 20;
/// The lines `GET /money/log` answers with no `tail`.
pub const DEFAULT_TAIL: usize = 2_000;
/// The most lines one read answers.
pub const MAX_TAIL: usize = 50_000;
/// **What makes a line a money line**: monetize-server's own words for money, and the operator
/// alerts about money. Matched anywhere in the line (PID 1's lines carry its time and the child's
/// name first).
pub const MARKS: &[&str] = &[
"monetize-server: signup",
"monetize-server: callback",
"monetize-server: reconcile",
"monetize-server: nonpay",
"monetize-server: card check",
"monetize-server: payment",
"monetize-server: purchase",
"monetize-server: renewal",
"monetize-server: refund",
"monetize-server: site order",
"monetize-server: estate order ",
"monetize-server: test run",
"monetize-server: an account at the shop",
"monetize-server: pending push",
"monetize-server: vendor ",
];
/// **The operator alerts about money**, by their code's beginning: a line `ALERT <code> …` whose
/// code begins with one of these is a money line (the codes are docs/alerts.md's rows).
pub const ALERT_CODES: &[&str] = &["payment.", "signup", "refund", "nonpay", "code-red", "estate-orders.reversal", "estate-orders.account-row"];
/// The word an operator alert begins with (kept apart so this table is no alert itself).
const ALERT: &str = "ALERT";
/// Pure: **is `line` a money line** ([`MARKS`], [`ALERT_CODES`])?
pub fn is_money(line: &str) -> bool {
MARKS.iter().any(|m| line.contains(m)) || line.match_indices(ALERT).any(|(at, _)| line[at + ALERT.len()..].strip_prefix(' ').is_some_and(|code| ALERT_CODES.iter().any(|c| code.starts_with(c))))
}
fn home(root: &Path) -> PathBuf {
root.join(REL)
}
fn previous(root: &Path) -> PathBuf {
root.join(format!("{REL}.1"))
}
/// **Keep `line` in the home under `root`** when it is a money line ([`is_money`]); bounded by
/// `max_bytes` ([`MAX_BYTES`] on the box). Answers whether it was kept.
pub fn keep_bounded(root: &Path, line: &str, max_bytes: u64) -> std::io::Result<bool> {
let line = line.trim_end_matches(['\n', '\r']);
if !is_money(line) {
return Ok(false);
}
let path = home(root);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
if std::fs::metadata(&path).is_ok_and(|m| m.len() + line.len() as u64 + 1 > max_bytes) {
std::fs::rename(&path, previous(root))?;
}
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
f.write_all(format!("{line}\n").as_bytes())?;
Ok(true)
}
/// [`keep_bounded`] at [`MAX_BYTES`].
pub fn keep(root: &Path, line: &str) -> std::io::Result<bool> {
keep_bounded(root, line, MAX_BYTES)
}
/// **The newest `tail` money lines** (at most [`MAX_TAIL`]), oldest first: the previous file's,
/// then the current one's.
pub fn read(root: &Path, tail: usize) -> String {
let text = [previous(root), home(root)].iter().filter_map(|p| std::fs::read_to_string(p).ok()).collect::<Vec<_>>().concat();
let lines: Vec<&str> = text.lines().collect();
let from = lines.len().saturating_sub(tail.min(MAX_TAIL));
lines[from..].iter().map(|l| format!("{l}\n")).collect()
}
/// **The PID 1 log writer, its money lines kept**: writes through to `inner`, and every money
/// line of it is [`keep`]t under `root` as well. A home that cannot be written is said once, on
/// `inner` and to the operator's alerts ([`crate::alerts::hear`]), and the writing goes on.
pub struct Keep<W: Write> {
inner: W,
root: PathBuf,
partial: Vec<u8>,
said: bool,
}
impl<W: Write> Keep<W> {
pub fn new(inner: W, root: PathBuf) -> Keep<W> {
Keep { inner, root, partial: Vec::new(), said: false }
}
}
impl<W: Write> Write for Keep<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.partial.extend_from_slice(&buf[..n]);
while let Some(i) = self.partial.iter().position(|b| *b == b'\n') {
let line: Vec<u8> = self.partial.drain(..=i).collect();
if let Err(e) = keep(&self.root, &String::from_utf8_lossy(&line)) {
if !self.said {
self.said = true;
let said = format!("monetize-admin: ALERT money-log.unwritten the money lines' home under {} cannot be written ({e}); they are in the boot log only", self.root.display());
let _ = writeln!(self.inner, "{said}");
crate::alerts::hear(&said);
}
}
}
if self.partial.len() > 64 * 1024 {
self.partial.clear();
}
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn root(name: &str) -> PathBuf {
let r = std::env::temp_dir().join(format!("monetize-admin-money-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&r);
r
}
/// A purchase's lines, as PID 1 writes them (its time, the child's name, the child's words).
const PAID: &str = "2026-10-26T17:45:02.113Z INFO monetize_admin::supervise: monetize-server: callback signup stripe:cs_live_a1 paid 99.00 SEK child=\"server\"";
const SETTLED: &str = "2026-10-26T17:45:03.002Z INFO monetize_admin::supervise: monetize-server: signup stripe:cs_live_a1 CREATED bob child=\"server\"";
const HELD: &str = "2026-10-26T17:46:00.000Z INFO monetize_admin::supervise: monetize-server: estate order acme-1: held 1 290.00 SEK child=\"server\"";
const NOISE: &str = "2026-10-26T17:45:04.000Z INFO monetize_admin::supervise: monetize-server: GET /v1/fleet 200 3 ms child=\"server\"";
/// ★ **A purchase's lines are money lines; the door's reads and the ticks are not.**
#[test]
fn a_purchases_lines_are_money_lines_and_noise_is_not() {
for l in [PAID, SETTLED, HELD, "monetize-server: ALERT payment.refused stripe: declined", "monetize-server: reconcile: stripe:cs_1 read PAID at the vendor"] {
assert!(is_money(l), "{l}");
}
for l in [NOISE, "monetize-server: guardian: acme-3 running: recharge", "monetize-admin: the solo monetize box starts", "monetize-server: estate orders: the tick an answer nudged is still running"] {
assert!(!is_money(l), "{l}");
}
}
/// ★ **No number of other lines pushes a money line out** (ALICA-3): a money line, then a
/// quarter of a million noise lines through PID 1's writer, then another money line: both
/// money lines are in the home, and no noise is.
#[test]
fn noise_never_pushes_a_money_line_out() {
let r = root("noise");
let mut w = Keep::new(std::io::sink(), r.clone());
writeln!(w, "{PAID}").unwrap();
for _ in 0..250_000 {
writeln!(w, "{NOISE}").unwrap();
}
writeln!(w, "{SETTLED}").unwrap();
let kept = read(&r, DEFAULT_TAIL);
assert_eq!(kept, format!("{PAID}\n{SETTLED}\n"), "the home holds the money lines and nothing else");
let _ = std::fs::remove_dir_all(&r);
}
/// ★ **The home is bounded by its own size only**: past the bound the file becomes the previous
/// one, and a read answers the previous file's lines, then the current one's, newest last.
#[test]
fn the_home_is_bounded_and_keeps_the_previous_file() {
let r = root("bound");
let max = 4 * 1024;
for i in 0..400 {
assert!(keep_bounded(&r, &format!("monetize-server: signup stripe:cs_{i} CREATED u{i}"), max).unwrap());
}
let cur = std::fs::metadata(r.join(REL)).unwrap().len();
let prev = std::fs::metadata(r.join(format!("{REL}.1"))).unwrap().len();
assert!(cur <= max && prev <= max, "{cur} {prev}");
let all = read(&r, MAX_TAIL);
assert!(all.ends_with("monetize-server: signup stripe:cs_399 CREATED u399\n"), "newest last: {}", &all[all.len().saturating_sub(200)..]);
assert_eq!(read(&r, 2).lines().count(), 2);
assert!(!keep_bounded(&r, NOISE, max).unwrap(), "noise is not kept");
let _ = std::fs::remove_dir_all(&r);
}
}