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(¬ice) {
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());
}
}