Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-server/src/nonpay.rs

//! **A customer who stops paying: read-only at once, warned, and PURGED
//! after a grace** — `[nonpay] delete_after_days`, or the product's own
//! `[nonpay.products.<id>]`, default [`DEFAULT_DELETE_AFTER_DAYS`] (owner
//! rulings g-nonpay and g-renewal, 2026-09-29; 2026-10-11: purge after a grace
//! period, no keep-the-data path, no reclaim queue beside it). Product-neutral:
//! gunnar and holger both run it, and nothing here names either.
//!
//! ```text
//!   paid_until ─▶ the ladder moves them to Suspended (grace_days = 0: read-only at once)
//!   day 0      ─▶ Scheduled{delete_on} + mail "read-only now, deleted on <date>"
//!   day 23     ─▶ mail "deleted on <date>"            (warn_days = [0, 23, 29])
//!   day 29     ─▶ mail "deleted on <date>, tomorrow"
//!   day 30     ─▶ Product::purge_tenant (main; the product's own twin follows it)
//!                 → the iron back (release_tenant + the provider sweep)
//!                 → the quota freed (every cap zero, signed, pushed)
//!                 → Step::TenantDeleted on the ledger → ALERT nonpay.deleted
//!   paid before then ─▶ the payment's own fact restores write access; this
//!                 module sees Paid and writes Cancelled — the deletion is off.
//! ```
//!
//! and, for a customer who DOES pay:
//!
//! ```text
//!   paid_until − renewal_reminder_days ─▶ mail "your period ends on <date>"
//!   a Declined row under their reference ─▶ mail "the payment was declined"
//! ```
//!
//! # The date is fixed ONCE, by the first warning
//!
//! `delete_on` is written on the lapse's first row ([`Event::Scheduled`]) and
//! every later decision reads it from there — never re-derived from the
//! policy, which an operator may edit, and never earlier than
//! `delete_after_days − min(warn_days)` days after the first mail went out. That
//! second rule is the one that matters on the day this ships: an estate whose
//! tenants have ALREADY been suspended for six weeks under the old 90-day
//! policy must not lose them on the first tick with no word said. They get the
//! day-0 mail, and the full notice from it.
//!
//! # Who is never touched
//!
//! * a tenant in `[nonpay] exempt` (the operator's own namespaces);
//! * a tenant who is not a CUSTOMER: no created signup and no purchase on the
//!   ledger. A machine principal (the twin, the billing principal) and an
//!   operator's hand-set fact are both that, whatever their fact says;
//! * a tenant whose fact carries no `paid_until` (a hand-set Free or Paid).
//!
//! Each is written down ONCE per lapse as [`Event::Spared`], so the journal
//! says why a lapsed tenant is still there.
//!
//! # Idempotent, and the journal is why
//!
//! One JSON line per decision at `<data_dir>/nonpay.journal`, `fsync`ed, read
//! whole at start and refused if a line will not parse — the shape
//! the grow journal uses. A second tick finds the row and does nothing; a
//! crash between the product's purge and the `Deleted` line re-runs a purge of
//! an empty account (harmless), releases nothing (already released), and the
//! ledger folds the second [`monetize::ledger::Step::TenantDeleted`] into the
//! first.

use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use monetize::ledger::Step;
use monetize::Policy;
use monetize_embed::civil::{iso_date, DAY_MS};
use monetize_pay::{CustomerNotice, SignupError};
use monetize_product::{EntitlementFact, State, TenantId};

use crate::state::AppState;

/// The journal's file name under the data dir.
pub const JOURNAL_FILE: &str = "nonpay.journal";

/// The `source` a deletion's zero-cap fact is stamped with, so the product's
/// attestation log says why the quota went.
pub const DELETED_SOURCE: &str = "nonpay:deleted";

/// How long a purge may hold the account locked before the operator is told.
pub const PURGE_STUCK_MS: u64 = 24 * 60 * 60 * 1000;

/// A failed deletion is retried at most this often (and alerted once per retry).
pub const RETRY_EVERY_MS: u64 = 60 * 60 * 1000;

/// A `Declined` row older than this when first seen is not mailed about: the
/// day this ships must not mail every customer about every card ever refused.
pub const DECLINED_FRESH_MS: u64 = 3 * DAY_MS;

/// **The grace before a hosted account that stopped paying is PURGED**, in days
/// after its paid period ended, when neither `[nonpay]` nor its product names
/// one (owner 2026-10-11: purge after a grace period; there is no keep-the-data
/// path). Thirty days: a month to notice the warnings and pay.
pub const DEFAULT_DELETE_AFTER_DAYS: u16 = 30;

fn default_delete_after_days() -> u16 {
    DEFAULT_DELETE_AFTER_DAYS
}

/// **`[nonpay]`** — the purge policy and the mails, per estate, with a grace
/// per product (the kit's `nonpay_delete_after_days`, `nonpay_warn_days`,
/// `renewal_reminder_days`; the ONE kit's `[products.<id>] purge_after_days`).
///
/// ```toml
/// [nonpay]
/// delete_after_days     = 30          # absent: 30 (DEFAULT_DELETE_AFTER_DAYS); 0 is refused
/// warn_days             = [0, 23, 29] # days after the lapse a warning goes out
/// renewal_reminder_days = [7, 3, 1]   # days before the end; 7 alone still reads; 0/[]: none
/// exempt                = ["rickard"] # namespaces never touched
/// account_url           = "https://gunnar.rs/"
/// [nonpay.products.holger]
/// delete_after_days     = 14          # this product's own grace
/// ```
///
/// `Default` (every field zero) is the in-memory test state that deletes
/// nothing; a CONFIG always deletes: absent is the default grace, and 0 —
/// keep an unpaid account's data forever — is refused at start
/// ([`NonpayPolicy::check_purges`]).
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NonpayPolicy {
    #[serde(default = "default_delete_after_days")]
    pub delete_after_days: u16,
    pub warn_days: Vec<u16>,
    /// **The days before a paid period ends that a reminder goes out**
    /// (owner, 2026-10-05: 7, 3 and 1). One number — the shape every config
    /// before this was written in — is one reminder.
    pub renewal_reminder_days: ReminderDays,
    pub exempt: Vec<String>,
    /// Where a customer pays or renews — the mails name it. Empty: the mails
    /// say "the console" and name no address.
    pub account_url: String,
    /// **Each product's own grace** (`[nonpay.products.<id>]`); a product not
    /// named takes `delete_after_days`.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub products: BTreeMap<String, ProductNonpay>,
}

/// `[nonpay.products.<id>]`: one product's grace before the purge.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProductNonpay {
    pub delete_after_days: u16,
}

/// `renewal_reminder_days`: `7` or `[7, 3, 1]`.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
#[serde(transparent)]
pub struct ReminderDays(pub Vec<u16>);

impl From<u16> for ReminderDays {
    fn from(d: u16) -> Self {
        ReminderDays(vec![d])
    }
}

impl<'de> serde::Deserialize<'de> for ReminderDays {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        #[derive(serde::Deserialize)]
        #[serde(untagged)]
        enum Either {
            One(u16),
            Many(Vec<u16>),
        }
        Ok(match Either::deserialize(d)? {
            Either::One(n) => ReminderDays(vec![n]),
            Either::Many(v) => ReminderDays(v),
        })
    }
}

impl NonpayPolicy {
    /// **A config's policy when `[nonpay]` is absent**: purge after the
    /// default grace, warned on the day it lapses and the day before.
    pub fn shipped() -> NonpayPolicy {
        NonpayPolicy {
            delete_after_days: DEFAULT_DELETE_AFTER_DAYS,
            warn_days: vec![0, DEFAULT_DELETE_AFTER_DAYS - 1],
            ..NonpayPolicy::default()
        }
    }

    /// **The grace of `product`**: its own, else the estate's.
    pub fn delete_after_days_of(&self, product: &str) -> u16 {
        self.products
            .get(product)
            .map_or(self.delete_after_days, |p| p.delete_after_days)
    }

    /// **The policy one product's tenants are judged by**: the estate's
    /// warnings, mails and exemptions over that product's grace.
    pub fn for_product(&self, product: &str) -> NonpayPolicy {
        NonpayPolicy {
            delete_after_days: self.delete_after_days_of(product),
            products: BTreeMap::new(),
            ..self.clone()
        }
    }

    /// **A config purges** (owner 2026-10-11): a grace of 0 — keep an unpaid
    /// account's data forever — is refused by name, the estate's and each
    /// product's; each product's grace is checked like the estate's.
    pub fn check_purges(&self, policy: &Policy) -> Result<(), String> {
        let zero = |at: &str| {
            format!("{at} delete_after_days = 0 would keep an unpaid account's data forever; a hosted account that stops paying is purged after a grace (absent: {DEFAULT_DELETE_AFTER_DAYS} days)")
        };
        if self.delete_after_days == 0 {
            return Err(zero("[nonpay]"));
        }
        for (id, p) in &self.products {
            if p.delete_after_days == 0 {
                return Err(zero(&format!("[nonpay.products.{id}]")));
            }
            self.for_product(id)
                .check(policy)
                .map_err(|e| format!("[nonpay.products.{id}]: {e}"))?;
        }
        self.check(policy)
    }

    /// **The reminder days**, largest first, zeros and repeats dropped.
    pub fn reminder_days(&self) -> Vec<u16> {
        let mut d: Vec<u16> = self
            .renewal_reminder_days
            .0
            .iter()
            .copied()
            .filter(|d| *d > 0)
            .collect();
        d.sort_unstable_by(|a, b| b.cmp(a));
        d.dedup();
        d
    }

    /// Does this deployment delete at all?
    pub fn deletes(&self) -> bool {
        self.delete_after_days > 0
    }

    /// The warning days, sorted, deduplicated, and only those before the deletion. A policy
    /// that deletes and names NO warning day warns as [`NonpayPolicy::shipped`] does — the
    /// day it lapses and the day before the purge (prod audit NP-2: a fed `[nonpay]` naming
    /// only `exempt` or a product's grace left the list empty, and nobody was mailed).
    pub fn warn_days(&self) -> Vec<u16> {
        if self.warn_days.is_empty() && self.deletes() {
            let mut d = vec![0, self.delete_after_days - 1];
            d.dedup();
            return d;
        }
        let mut d: Vec<u16> = self
            .warn_days
            .iter()
            .copied()
            .filter(|d| !self.deletes() || *d < self.delete_after_days)
            .collect();
        d.sort_unstable();
        d.dedup();
        d
    }

    /// **Refused by name** rather than read as something nobody wrote: a
    /// warning on or after the deletion, and a deletion inside the ladder's
    /// grace (a tenant who can still WRITE must never be deleted — the ruling
    /// is read-only first).
    pub fn check(&self, policy: &Policy) -> Result<(), String> {
        if !self.deletes() {
            return Ok(());
        }
        if let Some(d) = self
            .warn_days
            .iter()
            .find(|d| **d >= self.delete_after_days)
        {
            return Err(format!(
                "[nonpay] warn_days has {d}, which is not before delete_after_days = {}: a warning on or after the deletion warns of nothing",
                self.delete_after_days
            ));
        }
        if self.delete_after_days <= policy.grace_days {
            return Err(format!(
                "[nonpay] delete_after_days = {} is inside [policy] grace_days = {}: the tenant could still write when their data is deleted; \
                 the ruling is read-only FIRST (grace_days = 0)",
                self.delete_after_days, policy.grace_days
            ));
        }
        Ok(())
    }

    fn exempt(&self, tenant: &str) -> bool {
        self.exempt.iter().any(|e| e.trim() == tenant)
    }

    /// **This policy for a tenant of a site** whose own account page is `url` (NP-3): the
    /// mails name it; `None` keeps the box's `account_url`.
    pub fn on_site(&self, url: Option<String>) -> NonpayPolicy {
        match url {
            Some(account_url) => NonpayPolicy {
                account_url,
                ..self.clone()
            },
            None => self.clone(),
        }
    }

    fn where_to_pay(&self) -> String {
        if self.account_url.trim().is_empty() {
            "the console".to_owned()
        } else {
            self.account_url.trim().to_owned()
        }
    }
}

/// What one journal row says happened.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event {
    /// The lapse was seen and its deletion date FIXED. Every later decision
    /// about this lapse reads `delete_on_unix_ms` from here.
    Scheduled { delete_on_unix_ms: u64 },
    /// A warning went out (`sent`), or could not (`sent: false`, and `mail`
    /// says why). Either way it is not tried again.
    Warned {
        day: u16,
        delete_on_unix_ms: u64,
        sent: bool,
        mail: String,
    },
    /// A warning day that came due while a later one was also due — only the
    /// later one is sent.
    WarningSkipped { day: u16 },
    /// Never deleted, and why (exempt, not a customer). Once per lapse.
    Spared { why: String },
    /// The product was asked to purge; the account is locked while it walks.
    Deleting,
    /// The deletion failed; retried after [`RETRY_EVERY_MS`].
    DeleteFailed { why: String },
    /// The data is gone, the iron back, the quota freed.
    Deleted {
        detail: String,
        bytes_reclaimed: Option<u64>,
        released: Vec<String>,
        freed: BTreeMap<String, u64>,
        ledger_seq: u32,
    },
    /// They paid (or an operator restored them) before the deletion.
    Cancelled { state: State },
    /// A reminder that the period ending at `lapsed_unix_ms` is about to end,
    /// `day` days before it (absent on a row from before there were several:
    /// that one was the 7-day reminder).
    RenewalReminded {
        sent: bool,
        mail: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        day: Option<u16>,
    },
    /// A mail about a declined payment, for the ledger row `(reference, seq)`.
    PaymentFailed {
        reference: String,
        seq: u32,
        sent: bool,
        mail: String,
    },
}

impl Event {
    pub fn name(&self) -> &'static str {
        match self {
            Event::Scheduled { .. } => "scheduled",
            Event::Warned { .. } => "warned",
            Event::WarningSkipped { .. } => "warning_skipped",
            Event::Spared { .. } => "spared",
            Event::Deleting => "deleting",
            Event::DeleteFailed { .. } => "delete_failed",
            Event::Deleted { .. } => "deleted",
            Event::Cancelled { .. } => "cancelled",
            Event::RenewalReminded { .. } => "renewal_reminded",
            Event::PaymentFailed { .. } => "payment_failed",
        }
    }
}

/// **One line of the journal.** `lapsed_unix_ms` is the `paid_until` the row
/// is about: the lapse for the nonpay rows, the period's end for a reminder,
/// 0 for a payment-failed row.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Entry {
    pub seq: u64,
    pub at_unix_ms: u64,
    pub product: String,
    pub tenant: String,
    pub lapsed_unix_ms: u64,
    #[serde(flatten)]
    pub event: Event,
}

/// **One lapse, folded.** Derived from the rows; the journal is the truth.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct Lapse {
    pub delete_on_unix_ms: Option<u64>,
    pub first_seen_unix_ms: u64,
    pub warned: BTreeSet<u16>,
    pub spared: bool,
    pub deleting_since_unix_ms: Option<u64>,
    pub last_failure_unix_ms: Option<u64>,
    pub deleted: bool,
    pub cancelled: bool,
}

impl Lapse {
    /// Still running: scheduled, and neither deleted nor called off.
    pub fn open(&self) -> bool {
        self.delete_on_unix_ms.is_some() && !self.deleted && !self.cancelled
    }
}

#[derive(Default)]
struct Book {
    entries: Vec<Entry>,
    lapses: BTreeMap<(String, String, u64), Lapse>,
    reminded: BTreeSet<(String, String, u64, u16)>,
    declined: BTreeSet<(String, u32)>,
}

impl Book {
    fn apply(&mut self, e: &Entry) {
        let key = (e.product.clone(), e.tenant.clone(), e.lapsed_unix_ms);
        match &e.event {
            Event::RenewalReminded { day, .. } => {
                self.reminded
                    .insert((key.0, key.1, key.2, day.unwrap_or(7)));
                return;
            }
            Event::PaymentFailed { reference, seq, .. } => {
                self.declined.insert((reference.clone(), *seq));
                return;
            }
            _ => {}
        }
        let l = self.lapses.entry(key).or_insert_with(|| Lapse {
            first_seen_unix_ms: e.at_unix_ms,
            ..Lapse::default()
        });
        match &e.event {
            Event::Scheduled { delete_on_unix_ms } => {
                l.delete_on_unix_ms = Some(*delete_on_unix_ms)
            }
            Event::Warned { day, .. } | Event::WarningSkipped { day } => {
                l.warned.insert(*day);
            }
            Event::Spared { .. } => l.spared = true,
            Event::Deleting => {
                l.deleting_since_unix_ms = Some(l.deleting_since_unix_ms.unwrap_or(e.at_unix_ms))
            }
            Event::DeleteFailed { .. } => l.last_failure_unix_ms = Some(e.at_unix_ms),
            Event::Deleted { .. } => l.deleted = true,
            Event::Cancelled { .. } => l.cancelled = true,
            Event::RenewalReminded { .. } | Event::PaymentFailed { .. } => {}
        }
    }
}

#[derive(Debug, thiserror::Error)]
#[error("nonpay journal {path}: {detail}")]
pub struct JournalError {
    pub path: String,
    pub detail: String,
}

/// **The journal.** `path` is `None` only in memory (tests, `--dry`).
pub struct NonpayJournal {
    path: Option<PathBuf>,
    book: Mutex<Book>,
}

impl std::fmt::Debug for NonpayJournal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("NonpayJournal")
            .field("path", &self.path)
            .finish_non_exhaustive()
    }
}

impl NonpayJournal {
    /// Open (or create on first append). A line that will not parse is
    /// REFUSED: a skipped `Cancelled` would delete a customer who paid.
    pub fn open(path: impl Into<PathBuf>) -> Result<NonpayJournal, JournalError> {
        let path = path.into();
        let mut book = Book::default();
        match std::fs::read_to_string(&path) {
            Ok(text) => {
                for (n, line) in text.lines().enumerate() {
                    if line.trim().is_empty() {
                        continue;
                    }
                    let e: Entry = serde_json::from_str(line).map_err(|e| JournalError {
                        path: path.display().to_string(),
                        detail: format!("line {}: unreadable ({e}); refusing to decide deletions from a file that cannot be read whole", n + 1),
                    })?;
                    book.apply(&e);
                    book.entries.push(e);
                }
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
            Err(e) => {
                return Err(JournalError {
                    path: path.display().to_string(),
                    detail: e.to_string(),
                })
            }
        }
        Ok(NonpayJournal {
            path: Some(path),
            book: Mutex::new(book),
        })
    }

    pub fn in_memory() -> NonpayJournal {
        NonpayJournal {
            path: None,
            book: Mutex::new(Book::default()),
        }
    }

    pub fn path(&self) -> Option<&Path> {
        self.path.as_deref()
    }

    /// Every row, oldest first.
    pub fn entries(&self) -> Vec<Entry> {
        self.book
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .entries
            .clone()
    }

    /// One tenant's rows, oldest first.
    pub fn of(&self, product: &str, tenant: &str) -> Vec<Entry> {
        self.book
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .entries
            .iter()
            .filter(|e| e.product == product && e.tenant == tenant)
            .cloned()
            .collect()
    }

    pub fn lapse(&self, product: &str, tenant: &str, lapsed: u64) -> Option<Lapse> {
        self.book
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .lapses
            .get(&(product.to_owned(), tenant.to_owned(), lapsed))
            .cloned()
    }

    /// The tenant's lapses still running, as `(lapsed, fold)`.
    pub fn open_lapses(&self, product: &str, tenant: &str) -> Vec<(u64, Lapse)> {
        self.book
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .lapses
            .iter()
            .filter(|((p, t, _), l)| p == product && t == tenant && l.open())
            .map(|((_, _, at), l)| (*at, l.clone()))
            .collect()
    }

    /// **The tenants of `product` whose deletion has started and not finished** —
    /// followed from the journal every tick, because a purge takes the account's
    /// namespace away and the product stops LISTING them (MEASURED on the mock
    /// 2026-09-29: the deletion never finished).
    pub fn deleting(&self, product: &str) -> Vec<String> {
        let mut out: Vec<String> = self
            .book
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .lapses
            .iter()
            .filter(|((p, _, _), l)| p == product && l.open() && l.deleting_since_unix_ms.is_some())
            .map(|((_, t, _), _)| t.clone())
            .collect();
        out.dedup();
        out
    }

    fn reminded(&self, product: &str, tenant: &str, paid_until: u64, day: u16) -> bool {
        self.book
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .reminded
            .contains(&(product.to_owned(), tenant.to_owned(), paid_until, day))
    }

    fn declined_told(&self, reference: &str, seq: u32) -> bool {
        self.book
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .declined
            .contains(&(reference.to_owned(), seq))
    }

    /// **Fold a backup's rows into an EMPTY journal** (`crate::backup::import`):
    /// refused over a journal that holds anything, so the file written is one that
    /// was empty and the append-only property is kept by refusing. The rows keep
    /// their own seq and stamps.
    pub fn restore(&self, rows: &[Entry]) -> Result<usize, JournalError> {
        let mut book = self.book.lock().unwrap_or_else(|e| e.into_inner());
        let shown = self
            .path
            .as_ref()
            .map_or_else(|| "(in memory)".to_string(), |p| p.display().to_string());
        if !book.entries.is_empty() {
            return Err(JournalError {
                path: shown,
                detail: format!("already holds {} row(s); a restore folds a backup into an EMPTY journal — move the file aside first", book.entries.len()),
            });
        }
        if let Some(path) = &self.path {
            let err = |detail: String| JournalError {
                path: path.display().to_string(),
                detail,
            };
            if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
                std::fs::create_dir_all(parent).map_err(|e| err(format!("dir: {e}")))?;
            }
            let mut text = String::new();
            for e in rows {
                text.push_str(&serde_json::to_string(e).map_err(|e| err(format!("encode: {e}")))?);
                text.push('\n');
            }
            let mut file = std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(path)
                .map_err(|e| err(e.to_string()))?;
            file.write_all(text.as_bytes())
                .map_err(|e| err(format!("write: {e}")))?;
            file.sync_all().map_err(|e| err(format!("fsync: {e}")))?;
        }
        for e in rows {
            book.apply(e);
            book.entries.push(e.clone());
        }
        Ok(rows.len())
    }

    /// **Another machine's rows AFTER this journal's own** (G10, the ledger
    /// merge): appended, renumbered after the last seq here, every stamp kept —
    /// a lapse's deletion date stays the date its customer was told. Refused
    /// whole if a `(product, tenant)` it brings already has rows here.
    pub fn merge(&self, rows: &[Entry]) -> Result<usize, JournalError> {
        let mut book = self.book.lock().unwrap_or_else(|e| e.into_inner());
        let shown = self
            .path
            .as_ref()
            .map_or_else(|| "(in memory)".to_string(), |p| p.display().to_string());
        if let Some(e) = rows.iter().find(|r| {
            book.entries
                .iter()
                .any(|h| h.product == r.product && h.tenant == r.tenant)
        }) {
            return Err(JournalError {
                path: shown,
                detail: format!(
                    "merge of {}/{}, who already has rows here",
                    e.product, e.tenant
                ),
            });
        }
        let first = book.entries.last().map_or(1, |e| e.seq + 1);
        let rows: Vec<Entry> = rows
            .iter()
            .enumerate()
            .map(|(i, e)| Entry {
                seq: first + i as u64,
                ..e.clone()
            })
            .collect();
        if let Some(path) = &self.path {
            let err = |detail: String| JournalError {
                path: path.display().to_string(),
                detail,
            };
            if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
                std::fs::create_dir_all(parent).map_err(|e| err(format!("dir: {e}")))?;
            }
            let mut text = String::new();
            for e in &rows {
                text.push_str(&serde_json::to_string(e).map_err(|e| err(format!("encode: {e}")))?);
                text.push('\n');
            }
            let mut file = std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(path)
                .map_err(|e| err(e.to_string()))?;
            file.write_all(text.as_bytes())
                .map_err(|e| err(format!("write: {e}")))?;
            file.sync_all().map_err(|e| err(format!("fsync: {e}")))?;
        }
        for e in rows.iter() {
            book.apply(e);
            book.entries.push(e.clone());
        }
        Ok(rows.len())
    }

    /// Append one row: on disk and `fsync`ed before the fold moves.
    pub fn append(
        &self,
        at_unix_ms: u64,
        product: &str,
        tenant: &str,
        lapsed_unix_ms: u64,
        event: Event,
    ) -> Result<Entry, JournalError> {
        let mut book = self.book.lock().unwrap_or_else(|e| e.into_inner());
        let entry = Entry {
            seq: book.entries.last().map_or(1, |e| e.seq + 1),
            at_unix_ms,
            product: product.to_owned(),
            tenant: tenant.to_owned(),
            lapsed_unix_ms,
            event,
        };
        if let Some(path) = &self.path {
            let err = |detail: String| JournalError {
                path: path.display().to_string(),
                detail,
            };
            if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
                std::fs::create_dir_all(parent).map_err(|e| err(format!("dir: {e}")))?;
            }
            let mut line =
                serde_json::to_string(&entry).map_err(|e| err(format!("encode: {e}")))?;
            line.push('\n');
            let mut file = std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(path)
                .map_err(|e| err(e.to_string()))?;
            file.write_all(line.as_bytes())
                .map_err(|e| err(format!("write: {e}")))?;
            file.sync_all().map_err(|e| err(format!("fsync: {e}")))?;
        }
        book.apply(&entry);
        book.entries.push(entry.clone());
        Ok(entry)
    }
}

/// What one tenant's pass did, for the tick's counts.
#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
pub struct Did {
    pub warned: usize,
    pub deleted: usize,
    pub cancelled: usize,
    pub reminded: usize,
    pub failures: usize,
}

impl std::ops::AddAssign for Did {
    fn add_assign(&mut self, o: Did) {
        self.warned += o.warned;
        self.deleted += o.deleted;
        self.cancelled += o.cancelled;
        self.reminded += o.reminded;
        self.failures += o.failures;
    }
}

/// **The operator alert.** One line on the front's log, which `estate watch`
/// reads (`nonpay.deleted` is a notice, `nonpay.delete_failed` CRITICAL), and a
/// functional-status row beside it.
fn alert(kind: &str, ok: bool, detail: &str) {
    skidbladnir::functional_status("monetize-server/nonpay", kind, ok, detail);
    eprintln!("monetize-server: ALERT {kind} {detail}");
}

fn line(product: &str, tenant: &str) -> String {
    format!("{product}/{tenant}")
}

/// **One tenant, one tick.** Called by the poller after the ladder, with the
/// fact as it stands now. Never fails the tick: a journal or a product that
/// will not answer is reported and the next tick asks again.
pub fn observe(
    state: &AppState,
    product: monetize_product::Served<'_>,
    tenant: &TenantId,
    now: u64,
) -> Did {
    let mut did = Did::default();
    // The product's own grace over the estate's warnings and mails.
    let policy = state
        .terms
        .read()
        .unwrap_or_else(|e| e.into_inner())
        .nonpay
        .for_product(product.id());
    // NP-3: the tenant's own site's page, not the box's or another site's.
    let policy = policy.on_site(
        state
            .registry
            .estate_of_tenant(product.id(), &tenant.0)
            .and_then(|e| state.registry.account_url_of(&e)),
    );
    let fact = match state.ledger.entitlement(product.id(), &tenant.0) {
        Ok(Some(f)) => f,
        Ok(None) => return did,
        Err(e) => {
            eprintln!(
                "monetize-server: nonpay {}: entitlement: {e}",
                line(product.id(), &tenant.0)
            );
            return did;
        }
    };
    let r = (|| -> Result<(), JournalError> {
        cancel_if_paid(state, product.id(), &tenant.0, &fact, now, &mut did)?;
        payment_failures(state, &policy, product.id(), &tenant.0, now, &mut did)?;
        let Some(paid_until) = fact.paid_until_unix_ms else {
            return Ok(());
        };
        match fact.state {
            State::Paid => remind(
                state,
                &policy,
                product.id(),
                &tenant.0,
                paid_until,
                now,
                &mut did,
            ),
            State::Grace | State::Suspended | State::Retention
                if now >= paid_until && policy.deletes() =>
            {
                lapsed(
                    state,
                    &policy,
                    Case {
                        product,
                        tenant,
                        fact: &fact,
                        lapsed: paid_until,
                    },
                    now,
                    &mut did,
                )
            }
            _ => Ok(()),
        }
    })();
    if let Err(e) = r {
        skidbladnir::functional_status("monetize-server/nonpay", "journal", false, &e.to_string());
        eprintln!("monetize-server: {e}");
        did.failures += 1;
    }
    did
}

/// **They paid**: every lapse still running whose `paid_until` is not the
/// one standing now (a payment moved it), or whose tenant is back in Paid or
/// Free, is called off by a row.
fn cancel_if_paid(
    state: &AppState,
    product: &str,
    tenant: &str,
    fact: &EntitlementFact,
    now: u64,
    did: &mut Did,
) -> Result<(), JournalError> {
    for (lapsed, _) in state.nonpay.open_lapses(product, tenant) {
        let back = matches!(fact.state, State::Paid | State::Free)
            || fact.paid_until_unix_ms != Some(lapsed);
        if back {
            state.nonpay.append(
                now,
                product,
                tenant,
                lapsed,
                Event::Cancelled { state: fact.state },
            )?;
            eprintln!(
                "monetize-server: nonpay {}: the lapse of {} is CANCELLED — {:?} now; nothing will be deleted",
                line(product, tenant),
                iso_date(lapsed),
                fact.state
            );
            did.cancelled += 1;
        }
    }
    Ok(())
}

/// Is `tenant` a customer — somebody who paid through this deployment?
fn customer(state: &AppState, product: &str, tenant: &str) -> Result<bool, String> {
    if state
        .signups
        .email_of(product, tenant)
        .map_err(|e| e.to_string())?
        .is_some()
    {
        return Ok(true);
    }
    let refs = state
        .ledger
        .references_with_prefix(&format!("{product}/{tenant}/"))
        .map_err(|e| e.to_string())?;
    Ok(!refs.is_empty())
}

/// **One lapsed account**: whose it is, the fact it was found in, and when it
/// lapsed (its `paid_until`, the lapse's key).
#[derive(Clone, Copy)]
struct Case<'a> {
    product: monetize_product::Served<'a>,
    tenant: &'a TenantId,
    fact: &'a EntitlementFact,
    lapsed: u64,
}

fn lapsed(
    state: &AppState,
    policy: &NonpayPolicy,
    case: Case<'_>,
    now: u64,
    did: &mut Did,
) -> Result<(), JournalError> {
    let Case {
        product,
        tenant,
        fact,
        lapsed,
    } = case;
    let (pid, t) = (product.id(), tenant.0.as_str());
    let held = state.nonpay.lapse(pid, t, lapsed).unwrap_or_default();
    if held.deleted || held.cancelled || held.spared {
        return Ok(());
    }
    // ── who is never touched ─────────────────────────────────────────────
    let spared = if policy.exempt(t) {
        Some(format!(
            "{t} is in [nonpay] exempt: the operator's own namespace is never deleted"
        ))
    } else {
        match customer(state, pid, t) {
            Ok(true) => None,
            Ok(false) => Some(format!("{t} is not a customer (no signup, no purchase on the ledger): a machine principal or an operator's fact is never deleted")),
            Err(e) => {
                eprintln!("monetize-server: nonpay {}: could not tell whether this is a customer ({e}); nothing is decided this tick", line(pid, t));
                return Ok(());
            }
        }
    };
    if let Some(why) = spared {
        state
            .nonpay
            .append(now, pid, t, lapsed, Event::Spared { why: why.clone() })?;
        eprintln!("monetize-server: nonpay {}: spared — {why}", line(pid, t));
        return Ok(());
    }
    let days = policy.warn_days();
    let first_day = days.first().copied().unwrap_or(0);
    // ── the date, fixed once ─────────────────────────────────────────────
    let delete_on = match held.delete_on_unix_ms {
        Some(d) => d,
        None => {
            let by_policy = lapsed.saturating_add(u64::from(policy.delete_after_days) * DAY_MS);
            let full_notice =
                now.saturating_add(u64::from(policy.delete_after_days - first_day) * DAY_MS);
            let d = by_policy.max(full_notice);
            state.nonpay.append(
                now,
                pid,
                t,
                lapsed,
                Event::Scheduled {
                    delete_on_unix_ms: d,
                },
            )?;
            eprintln!(
                "monetize-server: nonpay {}: lapsed {}; READ-ONLY, deletion scheduled for {}",
                line(pid, t),
                iso_date(lapsed),
                iso_date(d)
            );
            d
        }
    };
    // ── the warnings: the latest one due, the earlier ones skipped ──────
    let warned = state
        .nonpay
        .lapse(pid, t, lapsed)
        .map(|l| l.warned)
        .unwrap_or_default();
    let due_at =
        |d: u16| delete_on.saturating_sub(u64::from(policy.delete_after_days - d) * DAY_MS);
    let due: Vec<u16> = days
        .iter()
        .copied()
        .filter(|d| !warned.contains(d) && now >= due_at(*d))
        .collect();
    if let Some(&day) = due.last() {
        for &skip in &due[..due.len() - 1] {
            state
                .nonpay
                .append(now, pid, t, lapsed, Event::WarningSkipped { day: skip })?;
        }
        let notice = warning(policy, pid, t, fact.state, lapsed, delete_on, now);
        let (sent, mail) = send(state, pid, t, &format!("nonpay.day{day}"), notice);
        state.nonpay.append(
            now,
            pid,
            t,
            lapsed,
            Event::Warned {
                day,
                delete_on_unix_ms: delete_on,
                sent,
                mail: mail.clone(),
            },
        )?;
        eprintln!(
            "monetize-server: nonpay {}: warning (day {day}) — {mail}",
            line(pid, t)
        );
        did.warned += 1;
    }
    // ── the deletion ─────────────────────────────────────────────────────
    if now < delete_on {
        return Ok(());
    }
    if let Some(failed_at) = held.last_failure_unix_ms {
        if now < failed_at.saturating_add(RETRY_EVERY_MS) {
            return Ok(());
        }
    }
    delete(state, policy, case, held.deleting_since_unix_ms, now, did)
}

fn delete(
    state: &AppState,
    policy: &NonpayPolicy,
    case: Case<'_>,
    deleting_since: Option<u64>,
    now: u64,
    did: &mut Did,
) -> Result<(), JournalError> {
    let Case {
        product,
        tenant,
        fact,
        lapsed,
    } = case;
    let (pid, t) = (product.id(), tenant.0.as_str());
    let who = line(pid, t);
    // ★ **NEVER INSIDE A PAID PERIOD** (owner, 2026-10-05). The fact this
    // tick was handed is re-read from the ledger at the last moment: a
    // payment, a prolong or an operator's restore that landed since moves
    // `paid_until` past now (or the state off the ladder), and then nothing
    // is deleted — the lapse is called off instead.
    match state.ledger.entitlement(pid, t) {
        Ok(Some(now_fact))
            if now_fact.paid_until_unix_ms.is_some_and(|p| p > now)
                || matches!(now_fact.state, State::Paid | State::Free) =>
        {
            state.nonpay.append(
                now,
                pid,
                t,
                lapsed,
                Event::Cancelled {
                    state: now_fact.state,
                },
            )?;
            eprintln!("monetize-server: nonpay {who}: NOT deleted — the account is paid (until {:?}); the lapse is called off", now_fact.paid_until_unix_ms.map(iso_date));
            did.cancelled += 1;
            return Ok(());
        }
        Ok(_) => {}
        Err(e) => {
            eprintln!("monetize-server: nonpay {who}: the entitlement could not be re-read before the deletion ({e}); nothing is deleted this tick");
            return Ok(());
        }
    }
    let reason = format!(
        "nonpay: {t} lapsed {} and was not paid for within {} days (owner ruling g-nonpay)",
        iso_date(lapsed),
        policy.delete_after_days
    );
    if deleting_since.is_none() {
        state.nonpay.append(now, pid, t, lapsed, Event::Deleting)?;
        alert(
            "nonpay.deleting",
            true,
            &format!("{who}: {reason}; the product is purging the account"),
        );
    }
    let fail = |state: &AppState, did: &mut Did, why: String| -> Result<(), JournalError> {
        state.nonpay.append(
            now,
            pid,
            t,
            lapsed,
            Event::DeleteFailed { why: why.clone() },
        )?;
        alert(
            "nonpay.delete_failed",
            false,
            &format!("{who}: the deletion did not complete and is retried in an hour: {why}"),
        );
        did.failures += 1;
        Ok(())
    };
    // ── 1. the data ─────────────────────────────────────────────────────
    let purged = match product.purge_tenant(tenant, &reason) {
        Ok(p) => p,
        Err(e) => return fail(state, did, format!("the product would not purge: {e}")),
    };
    if !purged.failures.is_empty() {
        return fail(
            state,
            did,
            format!(
                "the product purged only partly ({}): {}",
                purged.detail,
                purged.failures.join("; ")
            ),
        );
    }
    if purged.still_locked {
        // A purge is a lane: the product walks for minutes. Asked again next tick.
        let since = deleting_since.unwrap_or(now);
        if now.saturating_sub(since) >= PURGE_STUCK_MS {
            return fail(
                state,
                did,
                format!(
                    "the account has been locked by its purge for over a day: {}",
                    purged.detail
                ),
            );
        }
        eprintln!("monetize-server: nonpay {who}: purging — {}", purged.detail);
        return Ok(());
    }
    // ── 2. the iron ─────────────────────────────────────────────────────
    let cloud = state.registry.cloud_for_transactions();
    let released = match monetize::transaction::release_tenant(
        cloud,
        &state.ledger,
        now,
        pid,
        tenant,
        &reason,
    ) {
        Ok(r) => r,
        Err(e) => {
            return fail(
                state,
                did,
                format!("the data is gone and the iron could not be released: {e}"),
            )
        }
    };
    let mut detail = purged.detail.clone();
    match state.ledger.references_with_prefix(&format!("{pid}/{t}/")) {
        Ok(refs) if !refs.is_empty() => match monetize_cloud::ask::release_for(&cloud, &refs) {
            Ok(swept) if !swept.failures.is_empty() => detail.push_str(&format!(
                "; the provider sweep left: {}",
                swept.failures.join("; ")
            )),
            Ok(_) => {}
            Err(e) => detail.push_str(&format!("; the provider sweep could not run: {e}")),
        },
        Ok(_) => {}
        Err(e) => detail.push_str(&format!(
            "; the ledger would not list the tenant's references: {e}"
        )),
    }
    // ── 3. the quota ────────────────────────────────────────────────────
    let freed = fact.caps.clone();
    let zero = EntitlementFact {
        caps: fact.caps.keys().map(|k| (k.clone(), 0)).collect(),
        source: DELETED_SOURCE.into(),
        signature: Vec::new(),
        ..fact.clone()
    };
    let signed = state.signer_for(product.id()).sign_fact(zero);
    match state.apply_ladder_fact(product, Some(fact), &signed) {
        Ok(Some(_)) => {}
        Ok(None) => {
            // A payment landed while the product was purging. The data is
            // gone and the money is taken: that is an operator's to settle.
            alert(
                "nonpay.delete_failed",
                false,
                &format!("{who}: the account was PAID FOR while its purge ran — the data is already deleted; refund or restore by hand"),
            );
            did.failures += 1;
        }
        Err(e) => {
            return fail(
                state,
                did,
                format!("the data is gone and the freed quota could not be stored: {e}"),
            )
        }
    }
    // ── 4. the ledger row ───────────────────────────────────────────────
    let released_ids: Vec<String> = released
        .destroyed
        .iter()
        .map(|id| format!("{}:{}", id.provider, id.id))
        .collect();
    let step = Step::TenantDeleted {
        reference: format!("nonpay/{pid}/{t}/{lapsed}"),
        product: pid.to_owned(),
        tenant: t.to_owned(),
        lapsed_unix_ms: lapsed,
        detail: detail.clone(),
        released: released.destroyed.clone(),
        freed: freed.clone(),
    };
    let seq = match state.ledger.append(now, step) {
        Ok(seq) => seq,
        Err(e) => {
            return fail(
                state,
                did,
                format!("the data is gone and the ledger row could not be written: {e}"),
            )
        }
    };
    // ── 5. the record, and the operator ─────────────────────────────────
    state.nonpay.append(
        now,
        pid,
        t,
        lapsed,
        Event::Deleted {
            detail: detail.clone(),
            bytes_reclaimed: purged.bytes_reclaimed,
            released: released_ids.clone(),
            freed: freed.clone(),
            ledger_seq: seq,
        },
    )?;
    let quota: Vec<String> = freed
        .iter()
        .map(|(k, v)| format!("{k} {}", monetize_product::bytes(*v)))
        .collect();
    alert(
        "nonpay.deleted",
        true,
        &format!(
            "{who}: DELETED ({detail}); quota freed: {}; iron released: {}",
            if quota.is_empty() {
                "none".to_owned()
            } else {
                quota.join(", ")
            },
            if released_ids.is_empty() {
                "none".to_owned()
            } else {
                released_ids.join(", ")
            }
        ),
    );
    did.deleted += 1;
    Ok(())
}

/// **The renewal reminders**, `renewal_reminder_days` before the period ends
/// (7, 3 and 1 days) — one mail per day point per period. When several came
/// due between two ticks only the latest is sent: a customer is not mailed
/// "7 days left" on the day they have one.
fn remind(
    state: &AppState,
    policy: &NonpayPolicy,
    product: &str,
    tenant: &str,
    paid_until: u64,
    now: u64,
    did: &mut Did,
) -> Result<(), JournalError> {
    if now >= paid_until {
        return Ok(());
    }
    let left = paid_until - now;
    // The smallest day point already reached: the one due now.
    let Some(day) = policy
        .reminder_days()
        .into_iter()
        .filter(|d| u64::from(*d) * DAY_MS >= left)
        .min()
    else {
        return Ok(());
    };
    if state.nonpay.reminded(product, tenant, paid_until, day) {
        return Ok(());
    }
    if policy.exempt(tenant) || !customer(state, product, tenant).unwrap_or(false) {
        return Ok(());
    }
    let notice = reminder(policy, product, tenant, paid_until, now);
    let (sent, mail) = send(state, product, tenant, "renewal.reminder", notice);
    state.nonpay.append(
        now,
        product,
        tenant,
        paid_until,
        Event::RenewalReminded {
            sent,
            mail: mail.clone(),
            day: Some(day),
        },
    )?;
    eprintln!(
        "monetize-server: renewal {}: reminder ({day} day(s)) — {mail}",
        line(product, tenant)
    );
    did.reminded += 1;
    Ok(())
}

/// **A declined payment**: one mail per `Declined` ledger row under the
/// tenant's references, when the row is fresh.
fn payment_failures(
    state: &AppState,
    policy: &NonpayPolicy,
    product: &str,
    tenant: &str,
    now: u64,
    did: &mut Did,
) -> Result<(), JournalError> {
    if policy.exempt(tenant) {
        return Ok(());
    }
    let refs = match state
        .ledger
        .references_with_prefix(&format!("{product}/{tenant}/"))
    {
        Ok(r) => r,
        Err(_) => return Ok(()),
    };
    for reference in refs {
        let Ok(rows) = state.ledger.rows(&reference) else {
            continue;
        };
        for row in rows {
            let Step::Declined { reason, .. } = &row.step else {
                continue;
            };
            if now.saturating_sub(row.at_unix_ms) > DECLINED_FRESH_MS
                || state.nonpay.declined_told(&reference, row.seq)
            {
                continue;
            }
            let notice = declined(policy, product, tenant, reason);
            let (sent, mail) = send(state, product, tenant, "payment.failed", notice);
            state.nonpay.append(
                now,
                product,
                tenant,
                0,
                Event::PaymentFailed {
                    reference: reference.clone(),
                    seq: row.seq,
                    sent,
                    mail: mail.clone(),
                },
            )?;
            eprintln!(
                "monetize-server: payment {}: declined {reference} — {mail}",
                line(product, tenant)
            );
            did.reminded += 1;
        }
    }
    Ok(())
}

/// Deliver one notice through the deployment's one mail path; answers
/// `(sent, the line to record)`. No address is a refusal, recorded.
/// **A product's name as a customer reads it**: `gunnar` -> `Gunnar`, `holger` -> `Holger`
/// (owner: Gunnar is a name). The id stays the key everywhere else; only the words change.
pub(crate) fn shown(product: &str) -> String {
    let mut c = product.chars();
    match c.next() {
        Some(first) => first.to_uppercase().chain(c).collect(),
        None => String::new(),
    }
}

pub(crate) fn send(
    state: &AppState,
    product: &str,
    tenant: &str,
    kind: &str,
    (subject, body): (String, String),
) -> (bool, String) {
    let to = match address_of(state, product, tenant) {
        Ok(Some(to)) => to,
        Ok(None) => {
            return (
                false,
                "no address: no signup recorded one and no payment vendor reported one".to_owned(),
            )
        }
        Err(e) => return (false, format!("no address: {e}")),
    };
    let notice = CustomerNotice {
        product: product.to_owned(),
        tenant: tenant.to_owned(),
        to,
        kind: kind.to_owned(),
        subject,
        body,
    };
    match state.signup_sink.notify(&notice) {
        Ok(line) => (true, line),
        Err(SignupError::Refused(why)) | Err(SignupError::NameTaken(why)) => {
            (false, format!("not sent: {why}"))
        }
        Err(SignupError::Unavailable(why) | SignupError::Maintenance(why)) => {
            (false, format!("not sent, the relay did not take it: {why}"))
        }
    }
}

/// **Where a customer's mail goes**, in this order: the address they signed
/// up with; the address they gave the payment vendor at their newest checkout
/// (Stripe's `customer_details.email`, on the ledger's `Settled` row); the
/// payer the purchase itself named, when it is an address.
///
/// The second and third are the 2026-10-05 fix: an account that never signed
/// up through a checkout (an invited user, an operator's) bought room and was
/// told nothing — `room.ready … NOT sent — no address`.
pub fn address_of(state: &AppState, product: &str, tenant: &str) -> Result<Option<String>, String> {
    if let Some(to) = state
        .signups
        .email_of(product, tenant)
        .map_err(|e| format!("the signup store would not say ({e})"))?
    {
        if !to.is_empty() {
            return Ok(Some(to));
        }
    }
    let refs = state
        .ledger
        .references_with_prefix(&format!("{product}/{tenant}/"))
        .map_err(|e| format!("the ledger would not say ({e})"))?;
    let mut newest: Option<(u64, String)> = None;
    for reference in refs {
        let Ok(rows) = state.ledger.rows(&reference) else {
            continue;
        };
        // The reference's OWN tenant, from its Reserved row: a prefix is not a
        // tenant when a namespace may hold a slash (`team` vs `team/sub`).
        let mine = rows.iter().any(
            |r| matches!(&r.step, Step::Reserved { purchase, .. } if purchase.tenant.0 == tenant),
        );
        if !mine {
            continue;
        }
        for row in &rows {
            let found = match &row.step {
                Step::Settled { settlement, .. } => settlement.customer_email.clone(),
                Step::Reserved { purchase, .. } => {
                    purchase.payer.clone().filter(|p| p.contains('@'))
                }
                _ => None,
            };
            if let Some(email) = found.map(|e| e.trim().to_owned()).filter(|e| !e.is_empty()) {
                // A vendor's address outranks a payer hint written on the same
                // purchase; the newest purchase outranks older ones.
                let rank = row.at_unix_ms * 2 + u64::from(matches!(row.step, Step::Settled { .. }));
                if newest.as_ref().is_none_or(|(r, _)| rank >= *r) {
                    newest = Some((rank, email));
                }
            }
        }
    }
    Ok(newest.map(|(_, e)| e))
}

// ── the words ─────────────────────────────────────────────────────────────

fn warning(
    policy: &NonpayPolicy,
    product: &str,
    tenant: &str,
    state: State,
    lapsed: u64,
    delete_on: u64,
    now: u64,
) -> (String, String) {
    let product = shown(product);
    let date = iso_date(delete_on);
    let left = delete_on.saturating_sub(now).div_ceil(DAY_MS);
    // In Grace the account still WRITES: the subject must not say read-only
    // (it did, until 2026-10-05, on a ladder with a grace period).
    let subject = if matches!(state, State::Grace) {
        format!("{product}: the paid period for {tenant} has ended — it will be DELETED on {date} unless paid")
    } else {
        format!("{product}: {tenant} is read-only and will be DELETED on {date}")
    };
    let now_what = if matches!(state, State::Grace) {
        "The account is in its grace period: it still works as before for now, and becomes READ-ONLY when the grace period ends."
    } else {
        "The account is READ-ONLY from now on: you can still read and download everything in it, and nothing new can be written."
    };
    let body = format!(
        "The paid period for {tenant} on {product} ended on {}.\n\n{now_what}\n\n\
         Unless it is paid for, everything in the account — every repository and every file — will be DELETED on {date} \
         ({left} day(s) from now), and it cannot be recovered.\n\n\
         Paying before then restores write access at once and cancels the deletion: {}\n",
        iso_date(lapsed),
        policy.where_to_pay()
    );
    (subject, body)
}

fn reminder(
    policy: &NonpayPolicy,
    product: &str,
    tenant: &str,
    paid_until: u64,
    now: u64,
) -> (String, String) {
    let product = shown(product);
    let date = iso_date(paid_until);
    let left = paid_until.saturating_sub(now).div_ceil(DAY_MS);
    let subject =
        format!("{product}: the paid period for {tenant} ends on {date} — in {left} day(s)");
    let then = if policy.deletes() {
        format!(
            "If it is not renewed, the account becomes read-only on {date}, and everything in it is deleted {} days later.",
            policy.delete_after_days
        )
    } else {
        format!("If it is not renewed, the account becomes read-only on {date}.")
    };
    let body = format!(
        "The paid period for {tenant} on {product} ends on {date}.\n\n{then}\n\nRenew here: {}\n",
        policy.where_to_pay()
    );
    (subject, body)
}

fn declined(policy: &NonpayPolicy, product: &str, tenant: &str, reason: &str) -> (String, String) {
    let product = shown(product);
    let subject = format!("{product}: a payment for {tenant} was declined");
    let body = format!(
        "A card payment for {tenant} on {product} was declined, and nothing was charged.\n\nThe payment provider said: {reason}\n\n\
         Nothing about the account has changed. Please try again, with another card if need be: {}\n",
        policy.where_to_pay()
    );
    (subject, body)
}

#[cfg(test)]
mod tests;

#[cfg(test)]
mod grace_tests;

/// R5a: a panic elsewhere that held the book must not take every later tick
/// down with it. The journal writes its file before the book folds, so the
/// book a panic leaves behind is the one on disk.
#[cfg(test)]
mod poisoned_book_tests {
    use super::*;

    #[test]
    fn a_poisoned_book_still_answers_and_appends() {
        let j = NonpayJournal::in_memory();
        j.append(
            1,
            "gunnar",
            "bob",
            10,
            Event::Scheduled {
                delete_on_unix_ms: 99,
            },
        )
        .expect("a first row");
        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            let _held = j.book.lock();
            panic!("a panic while the book is held");
        }));
        assert!(j.book.is_poisoned());
        assert_eq!(j.entries().len(), 1);
        assert_eq!(j.of("gunnar", "bob").len(), 1);
        assert!(j.lapse("gunnar", "bob", 10).is_some());
        let _ = j.open_lapses("gunnar", "bob");
        let _ = j.deleting("gunnar");
        assert!(!j.reminded("gunnar", "bob", 0, 1));
        assert!(!j.declined_told("r", 1));
        j.append(2, "gunnar", "bob", 10, Event::Spared { why: "test".into() })
            .expect("a row after the panic");
        assert_eq!(j.entries().len(), 2);
        assert!(j.merge(&[]).is_ok());
    }
}