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/tests.rs

//! **The owner's rulings g-nonpay and g-renewal, on a clock that travels.**
//!
//! A shop with one paid-signup customer, bob, driven through the real poller
//! tick (`crate::poller::tick`) — the ladder, the reclamation queue and this
//! module in the order a served process runs them — with the mail sink and
//! the product recording what they were asked.

use std::sync::{Arc, Mutex};

use monetize::testing::{Answer, MockCloud, MockPay, MockProduct};
use monetize_pay::signup::{NameVerdict, PaidSignup, SignupOutcome};
use monetize_pay::{CustomerNotice, SignupError, SignupSink};
use monetize_product::{State, TenantId};

use super::*;
use crate::poller::tick;
use crate::registry::Registry;
use crate::signup::tests::{a_signup, plans};
use crate::signup::SignupState;
use crate::state::AppState;

const DAY: u64 = DAY_MS;
const GIB: u64 = 1 << 30;

/// The one mail path, recording.
#[derive(Default)]
struct Mails(Mutex<Vec<CustomerNotice>>);

impl Mails {
    fn kinds(&self) -> Vec<String> {
        self.0
            .lock()
            .unwrap()
            .iter()
            .map(|n| n.kind.clone())
            .collect()
    }
    fn last(&self) -> CustomerNotice {
        self.0
            .lock()
            .unwrap()
            .last()
            .cloned()
            .expect("a mail was sent")
    }
}

impl SignupSink for Mails {
    fn paid(&self, _: &PaidSignup) -> Result<SignupOutcome, SignupError> {
        Err(SignupError::Refused("not a signup test".into()))
    }
    fn name_verdict(&self, _: &str) -> Result<NameVerdict, SignupError> {
        Ok(NameVerdict::Free)
    }
    fn notify(&self, n: &CustomerNotice) -> Result<String, SignupError> {
        self.0.lock().unwrap().push(n.clone());
        Ok(format!("sent to {}", n.to))
    }
}

/// The ruling, as the kit renders it: read-only at once, deleted at day 30,
/// warned on days 0, 23 and 29, reminded 7 days before a renewal.
fn ruling() -> NonpayPolicy {
    NonpayPolicy {
        delete_after_days: 30,
        warn_days: vec![0, 23, 29],
        renewal_reminder_days: 7.into(),
        exempt: vec!["rickard".into()],
        account_url: "https://shop.example/".into(),
        products: Default::default(),
    }
}

struct Rig {
    s: AppState,
    product: Arc<MockProduct>,
    mails: Arc<Mails>,
    pay: MockPay,
    paid_until: u64,
}

impl Rig {
    /// bob paid a 30-day signup and exists on the appliance with its 10 GiB.
    fn shop() -> Rig {
        Rig::shop_on(|r| r)
    }

    /// [`Rig::shop`] on a registry `sites` adds to (estates, the product's home).
    fn shop_on(sites: impl FnOnce(Registry) -> Registry) -> Rig {
        let product = Arc::new(MockProduct::gunnar());
        let cloud = MockCloud::new();
        cloud.answer_all(Answer::Provisioned);
        let mut s = AppState::in_memory(sites(
            Registry::new().shared_product(product.clone()).cloud(cloud),
        ));
        s.signup_plans = plans().into();
        let mails = Arc::new(Mails::default());
        s.signup_sink = mails.clone();
        {
            let mut t = s.terms.write().unwrap();
            t.policy.grace_days = 0;
            t.policy.retention_days = 30;
            t.nonpay = ruling();
        }
        let signup: PaidSignup = a_signup("cs_bob", "bob");
        let paid_at = signup.paid_at_unix_ms as u64;
        s.clock = Box::new(move || paid_at);
        s.signups
            .record_pending(&signup, signup.paid_at_unix_ms)
            .unwrap();
        s.signups
            .set(
                "mockpay",
                "cs_bob",
                SignupState::Created {
                    namespace: "bob".into(),
                    username: "bob".into(),
                    at_unix_ms: signup.paid_at_unix_ms,
                },
            )
            .unwrap();
        let fact = crate::signup::signup_facts(&s, "gunnar")
            .unwrap()
            .pop()
            .unwrap();
        product.push_entitlement(&fact).unwrap();
        let paid_until = fact.paid_until_unix_ms.unwrap();
        Rig {
            s,
            product,
            mails,
            pay: MockPay::new(),
            paid_until,
        }
    }

    fn at(&mut self, now: u64) -> crate::poller::Tick {
        self.s.clock = Box::new(move || now);
        tick(&self.s)
    }

    fn day(&mut self, d: u64) -> crate::poller::Tick {
        let t = self.paid_until + d * DAY + 60_000;
        self.at(t)
    }

    fn fact(&self) -> monetize_product::EntitlementFact {
        self.s
            .ledger
            .entitlement("gunnar", "bob")
            .unwrap()
            .expect("bob has a fact")
    }

    fn appliance(&self) -> monetize_product::EntitlementFact {
        self.product
            .pushed()
            .into_iter()
            .rev()
            .find(|f| f.tenant.0 == "bob")
            .expect("the appliance holds a fact for bob")
    }

    fn events(&self) -> Vec<&'static str> {
        self.s
            .nonpay
            .of("gunnar", "bob")
            .iter()
            .map(|e| e.event.name())
            .collect()
    }

    fn product(&self) -> monetize_product::Served<'_> {
        self.s.registry.products().next().unwrap()
    }

    /// bob renews his period, as the console's renew button does.
    fn renew(&self) {
        let terms = self.s.terms.read().unwrap().clone();
        let host = terms.host("gunnar");
        let t = terms.terms("gunnar", terms.default_market, &host);
        let q = self
            .s
            .transaction(self.product(), monetize_pay::Vendor::of(&self.pay))
            .renewal(&TenantId("bob".into()), &t, None)
            .expect("the renewal quotes");
        let out = self
            .s
            .transaction(self.product(), monetize_pay::Vendor::of(&self.pay))
            .buy(&q.purchase)
            .expect("the renewal sells");
        assert!(
            matches!(out, monetize::transaction::Outcome::Paid { .. }),
            "{out:?}"
        );
    }

    fn fleet_allowance(&self) -> u64 {
        let facts = crate::signup::with_signup_facts(
            &self.s,
            "gunnar",
            self.s.ledger.entitlements("gunnar").unwrap(),
        )
        .unwrap();
        facts.iter().filter_map(|f| f.caps.get("pack_bytes")).sum()
    }
}

/// **The whole ruling, end to end: lapse → read-only → the warnings →
/// deletion at day 30 → the quota freed.**
#[test]
fn a_customer_who_stops_paying_is_read_only_at_once_warned_and_deleted_on_day_30() {
    let mut r = Rig::shop();
    r.product
        .purges_answer(vec![MockProduct::purged_clean("bob", 3 * GIB)]);
    assert_eq!(r.fleet_allowance(), 10 * GIB, "bob holds his 10 GiB");

    // Seven days before the period ends: the renewal reminder, once.
    r.at(r.paid_until - 7 * DAY + 1);
    assert_eq!(r.mails.kinds(), vec!["renewal.reminder"]);
    assert!(
        r.mails.last().subject.contains("ends on"),
        "{:?}",
        r.mails.last()
    );
    r.at(r.paid_until - 6 * DAY);
    assert_eq!(r.mails.kinds().len(), 1, "one reminder per period");

    // Day 0: READ-ONLY at once (Suspended, pushed) and the day-0 mail names the date.
    r.day(0);
    assert_eq!(
        r.appliance().state,
        State::Suspended,
        "grace_days = 0: the lapse is read-only at once"
    );
    let delete_on = r.paid_until + 30 * DAY;
    let mail = r.mails.last();
    assert_eq!(mail.kind, "nonpay.day0");
    assert_eq!(mail.to, "bob@example.com");
    assert!(
        mail.subject.contains("read-only") && mail.subject.contains(&iso_date(delete_on)),
        "{mail:?}"
    );
    assert!(
        mail.body.contains("https://shop.example/"),
        "the mail says where to pay: {mail:?}"
    );
    assert_eq!(r.events(), vec!["renewal_reminded", "scheduled", "warned"]);

    // Nothing between the warnings.
    r.day(10);
    r.day(22);
    assert_eq!(r.mails.kinds().len(), 2);

    r.day(23);
    assert_eq!(r.mails.last().kind, "nonpay.day23");
    r.day(29);
    assert_eq!(r.mails.last().kind, "nonpay.day29");
    assert!(
        r.product.purges().is_empty(),
        "nothing is deleted before day 30"
    );
    assert_eq!(r.fact().caps["pack_bytes"], 10 * GIB);

    // Day 30: DELETED.
    let t = r.day(30);
    assert_eq!(t.nonpay.deleted, 1, "{:?}", t.nonpay);
    assert_eq!(r.product.purges().len(), 1);
    assert_eq!(r.product.purges()[0].0, "bob");
    assert!(
        r.product.purges()[0].1.contains("g-nonpay"),
        "the reason names the ruling: {:?}",
        r.product.purges()
    );
    // The quota freed: the stored fact and the appliance's both hold zero.
    assert_eq!(r.fact().caps["pack_bytes"], 0);
    assert_eq!(r.fact().source, DELETED_SOURCE);
    assert_eq!(
        r.appliance().caps["pack_bytes"],
        0,
        "the appliance is told the quota is gone"
    );
    assert_eq!(
        r.fleet_allowance(),
        0,
        "the estate's sold total no longer counts bob"
    );
    // The ledger row.
    let reference = format!("nonpay/gunnar/bob/{}", r.paid_until);
    let rows = r.s.ledger.rows(&reference).unwrap();
    assert_eq!(rows.len(), 1);
    assert!(
        matches!(&rows[0].step, monetize::ledger::Step::TenantDeleted { freed, .. } if freed["pack_bytes"] == 10 * GIB),
        "{rows:?}"
    );
    assert_eq!(r.events().last(), Some(&"deleted"));

    // Idempotent: the ticks after ask nothing and write nothing.
    let n = r.s.nonpay.entries().len();
    r.day(31);
    r.day(45);
    assert_eq!(
        r.product.purges().len(),
        1,
        "a deleted tenant is never purged again"
    );
    assert_eq!(r.s.nonpay.entries().len(), n);
    assert_eq!(r.s.ledger.rows(&reference).unwrap().len(), 1);
}

/// **Paying on day 10 restores write access and cancels the deletion.**
#[test]
fn paying_on_day_10_restores_write_access_and_nothing_is_deleted() {
    let mut r = Rig::shop();
    r.product
        .purges_answer(vec![MockProduct::purged_clean("bob", GIB)]);
    r.day(0);
    assert_eq!(r.appliance().state, State::Suspended);
    r.day(10);
    r.renew();
    assert_eq!(
        r.appliance().state,
        State::Paid,
        "the payment's own fact restores write access at once"
    );
    let t = r.day(10);
    assert_eq!(t.nonpay.cancelled, 1);
    assert!(r.events().contains(&"cancelled"), "{:?}", r.events());

    for d in [23, 29, 30, 31, 45] {
        r.day(d);
    }
    assert!(
        r.product.purges().is_empty(),
        "a customer who paid is never purged"
    );
    assert_eq!(r.fact().caps["pack_bytes"], 10 * GIB, "the quota stands");
    assert!(
        !r.mails
            .kinds()
            .iter()
            .any(|k| k == "nonpay.day23" || k == "nonpay.day29"),
        "no warning after paying: {:?}",
        r.mails.kinds()
    );
}

/// **The operator's own namespace and a machine principal are never touched**,
/// whatever their facts say — and the journal says why, once.
#[test]
fn the_operators_namespace_and_a_machine_principal_are_never_deleted() {
    let mut r = Rig::shop();
    r.product
        .purges_answer(vec![MockProduct::purged_clean("x", 0)]);
    let lapsing = |tenant: &str| {
        r.s.signer_for("gunnar")
            .sign_fact(monetize_product::EntitlementFact {
                tenant: TenantId(tenant.into()),
                plan: "hand".into(),
                state: State::Paid,
                paid_until_unix_ms: Some(r.paid_until),
                caps: [("pack_bytes".to_string(), GIB)].into(),
                source: "operator".into(),
                signature: Vec::new(),
                issued_unix_ms: None,
                issued_signature: Vec::new(),
            })
    };
    for who in ["rickard", "gunnar-twin"] {
        let f = lapsing(who);
        let _ = r.s.apply_fact(r.product(), &f).unwrap();
    }
    r.day(0);
    r.day(30);
    r.day(31);
    let purged: Vec<String> = r.product.purges().into_iter().map(|(t, _)| t).collect();
    assert_eq!(purged, vec!["bob".to_string()], "only the customer");
    for who in ["rickard", "gunnar-twin"] {
        let rows: Vec<&'static str> =
            r.s.nonpay
                .of("gunnar", who)
                .iter()
                .map(|e| e.event.name())
                .collect();
        assert_eq!(rows, vec!["spared"], "{who}: {rows:?}");
        assert_eq!(
            r.s.ledger.entitlement("gunnar", who).unwrap().unwrap().caps["pack_bytes"],
            GIB
        );
    }
}

/// **Switching this on for an estate whose tenants lapsed long ago deletes
/// nobody on the first tick**: they get the day-0 mail and the full notice.
#[test]
fn an_estate_that_turns_the_ruling_on_gives_an_old_lapse_the_full_notice() {
    let mut r = Rig::shop();
    r.product
        .purges_answer(vec![MockProduct::purged_clean("bob", GIB)]);
    {
        // The old policy: grace 14, suspended for 90 — bob has been read-only for weeks.
        r.s.terms.write().unwrap().nonpay = NonpayPolicy::default();
        r.s.terms.write().unwrap().policy.grace_days = 14;
    }
    r.day(40);
    assert!(r.mails.kinds().is_empty());
    r.s.terms.write().unwrap().nonpay = ruling();
    let first = r.paid_until + 41 * DAY;
    r.at(first);
    assert!(
        r.product.purges().is_empty(),
        "no word said, nothing deleted"
    );
    assert_eq!(r.mails.last().kind, "nonpay.day0");
    assert!(
        r.mails.last().subject.contains(&iso_date(first + 30 * DAY)),
        "{:?}",
        r.mails.last()
    );
    r.at(first + 29 * DAY);
    assert!(r.product.purges().is_empty());
    r.at(first + 30 * DAY);
    assert_eq!(r.product.purges().len(), 1);
}

/// **A purge the product refuses leaves everything as it was**, alerts, and is
/// retried — never a quota freed over data that is still there.
#[test]
fn a_refused_purge_frees_nothing_and_is_retried() {
    let mut r = Rig::shop();
    r.product.purges_answer(vec![
        Err(monetize_product::ProductError::Unavailable(
            "the box is rebooting".into(),
        )),
        MockProduct::purged_clean("bob", GIB),
    ]);
    r.day(0);
    r.day(30);
    assert_eq!(r.product.purges().len(), 1);
    assert_eq!(
        r.fact().caps["pack_bytes"],
        10 * GIB,
        "nothing freed over data that is still there"
    );
    assert_eq!(r.events().last(), Some(&"delete_failed"));
    // Inside the hour: not asked again.
    r.at(r.paid_until + 30 * DAY + 30 * 60_000);
    assert_eq!(r.product.purges().len(), 1);
    // After it: asked, and done.
    r.at(r.paid_until + 30 * DAY + 2 * 60 * 60_000);
    assert_eq!(r.product.purges().len(), 2);
    assert_eq!(r.fact().caps["pack_bytes"], 0);
}

/// **A purge that is still walking is asked again**, and nothing is freed until it settles.
#[test]
fn a_purge_still_running_is_asked_again_until_it_settles() {
    let mut r = Rig::shop();
    let running = Ok(monetize_product::Purged {
        tenant: TenantId("bob".into()),
        detail: "job 7 is still running".into(),
        bytes_reclaimed: Some(0),
        still_locked: true,
        failures: vec![],
    });
    r.product
        .purges_answer(vec![running, MockProduct::purged_clean("bob", GIB)]);
    r.day(0);
    r.day(30);
    assert_eq!(r.fact().caps["pack_bytes"], 10 * GIB);
    assert_eq!(r.events().last(), Some(&"deleting"));
    r.at(r.paid_until + 30 * DAY + 5 * 60_000);
    assert_eq!(r.fact().caps["pack_bytes"], 0);
    assert_eq!(
        r.events().iter().filter(|e| **e == "deleting").count(),
        1,
        "one Deleting row per deletion"
    );
}

/// **A declined card is mailed about, once** (g-renewal).
#[test]
fn a_declined_payment_is_mailed_once() {
    let mut r = Rig::shop();
    r.at(r.paid_until - 20 * DAY);
    let reference = format!("gunnar/bob/{}+decl", r.paid_until);
    r.s.ledger
        .append(
            r.paid_until - 20 * DAY,
            monetize::ledger::Step::Declined {
                reference: reference.clone(),
                reason: "card_declined: insufficient funds".into(),
            },
        )
        .unwrap();
    r.at(r.paid_until - 20 * DAY + 60_000);
    assert_eq!(r.mails.kinds(), vec!["payment.failed"]);
    assert!(r.mails.last().body.contains("insufficient funds"));
    r.at(r.paid_until - 19 * DAY);
    assert_eq!(r.mails.kinds().len(), 1, "once per declined row");
}

/// **The policy is refused by name** when it would warn after the deletion,
/// or delete a tenant who can still write.
#[test]
fn a_policy_that_deletes_a_writable_tenant_or_warns_too_late_is_refused() {
    let p = monetize::Policy {
        grace_days: 0,
        ..monetize::Policy::default()
    };
    assert!(ruling().check(&p).is_ok());
    let late = NonpayPolicy {
        warn_days: vec![0, 30],
        ..ruling()
    };
    assert!(late.check(&p).unwrap_err().contains("warn_days has 30"));
    let writable = monetize::Policy {
        grace_days: 30,
        ..p
    };
    assert!(ruling()
        .check(&writable)
        .unwrap_err()
        .contains("read-only FIRST"));
    assert!(
        NonpayPolicy::default().check(&writable).is_ok(),
        "off is always legal"
    );
    let cfg = crate::config::Config::from_toml("[policy]\ngrace_days = 0\n[nonpay]\ndelete_after_days = 30\nwarn_days = [0, 23, 29]\nrenewal_reminder_days = 7\n").unwrap();
    assert_eq!(cfg.nonpay.delete_after_days, 30);
    assert!(
        crate::config::Config::from_toml("[nonpay]\ndelete_after_days = 10\n").is_err(),
        "a deletion inside the default grace (14) is refused"
    );
}

/// **The journal survives a restart**, and a line it cannot read is refused.
#[test]
fn the_journal_is_folded_back_from_its_file_and_refuses_a_bad_line() {
    let dir = std::env::temp_dir().join(format!("nonpay-journal-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    let path = dir.join(JOURNAL_FILE);
    {
        let j = NonpayJournal::open(&path).unwrap();
        j.append(
            1,
            "gunnar",
            "bob",
            100,
            Event::Scheduled {
                delete_on_unix_ms: 200,
            },
        )
        .unwrap();
        j.append(
            2,
            "gunnar",
            "bob",
            100,
            Event::Warned {
                day: 0,
                delete_on_unix_ms: 200,
                sent: true,
                mail: "sent".into(),
            },
        )
        .unwrap();
    }
    let j = NonpayJournal::open(&path).unwrap();
    let l = j.lapse("gunnar", "bob", 100).unwrap();
    assert_eq!(
        (l.delete_on_unix_ms, l.warned.len(), l.open()),
        (Some(200), 1, true)
    );
    std::fs::write(
        &path,
        format!(
            "{}\nnot json\n",
            std::fs::read_to_string(&path)
                .unwrap()
                .lines()
                .next()
                .unwrap()
        ),
    )
    .unwrap();
    assert!(NonpayJournal::open(&path)
        .unwrap_err()
        .to_string()
        .contains("line 2"));
    let _ = std::fs::remove_dir_all(&dir);
}

/// **The journal travels in the operational-state backup**: a front recharged
/// from its backup keeps every lapse's fixed date, every warning sent and every
/// cancellation — without it the first tick after a restore would reschedule
/// every deletion from scratch and mail every lapsed customer day 0 again.
#[test]
fn the_nonpay_journal_is_in_the_backup_and_restores_into_an_empty_journal() {
    let dir = std::env::temp_dir().join(format!("nonpay-backup-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let (ledger, usage) = (
        monetize::Ledger::in_memory().unwrap(),
        crate::state::UsageStore::in_memory().unwrap(),
    );
    let (signups, reconcile) = (
        crate::signup::SignupStore::in_memory().unwrap(),
        crate::reconcile::ReconcileStore::in_memory().unwrap(),
    );
    let j = NonpayJournal::in_memory();
    j.append(
        1,
        "gunnar",
        "bob",
        100,
        Event::Scheduled {
            delete_on_unix_ms: 200,
        },
    )
    .unwrap();
    j.append(
        2,
        "gunnar",
        "bob",
        100,
        Event::Warned {
            day: 0,
            delete_on_unix_ms: 200,
            sent: true,
            mail: "sent".into(),
        },
    )
    .unwrap();
    let stores = crate::backup::Stores {
        ledger: &ledger,
        usage: &usage,
        signups: &signups,
        reconcile: &reconcile,
        nonpay: &j,
    };
    assert_eq!(
        crate::backup::export(&dir, &stores, "k")
            .unwrap()
            .nonpay_rows,
        2
    );
    let restored = NonpayJournal::in_memory();
    let into = crate::backup::Stores {
        nonpay: &restored,
        ..stores
    };
    assert_eq!(
        crate::backup::import(&dir, &into, "k", false)
            .unwrap()
            .nonpay_rows,
        2
    );
    assert_eq!(
        restored.entries(),
        j.entries(),
        "the same rows, the same seqs"
    );
    assert_eq!(
        restored
            .lapse("gunnar", "bob", 100)
            .unwrap()
            .delete_on_unix_ms,
        Some(200)
    );
    let err = crate::backup::import(&dir, &into, "k", false).unwrap_err();
    assert!(
        err.to_string()
            .contains("nonpay journal that already holds rows"),
        "never folded onto a journal that holds rows: {err}"
    );
    let _ = std::fs::remove_dir_all(&dir);
}

/// ★ **MEASURED on the mock 2026-09-29: a purge removes the namespace, so the
/// product stops LISTING the tenant — and the deletion must still finish.** The
/// first ask answered "still queued"; gunnar then purged the account (store and
/// log gone) and dropped the namespace from its listing, the poller never
/// visited the tenant again, and the quota, the ledger row and the ALERT never
/// came. A lapse that is deleting is followed from the journal, listed or not.
#[test]
fn a_deletion_finishes_when_the_purge_takes_the_tenant_off_the_products_list() {
    let mut r = Rig::shop();
    let running = Ok(monetize_product::Purged {
        tenant: TenantId("bob".into()),
        detail: "job purge-3 is still queued".into(),
        bytes_reclaimed: Some(0),
        still_locked: true,
        failures: vec![],
    });
    r.product
        .purges_answer(vec![running, MockProduct::purged_clean("bob", GIB)]);
    r.day(0);
    r.day(30);
    assert_eq!(r.events().last(), Some(&"deleting"));
    r.product.unlisted.lock().unwrap().push("bob".into());
    r.at(r.paid_until + 30 * DAY + 5 * 60_000);
    assert_eq!(
        r.product.purges().len(),
        2,
        "the purge is asked again though the product no longer lists bob"
    );
    assert_eq!(r.events().last(), Some(&"deleted"));
    assert_eq!(r.fact().caps["pack_bytes"], 0, "the quota freed");
}

// ── the owner's ladder of 2026-10-05 ────────────────────────────────────────

/// The ladder the kit renders from 2026-10-05: reminded 7, 3 and 1 days
/// before the end; 14 days of grace (writes allowed, a banner); then
/// read-only with the room released and the data kept; deleted 60 days after
/// the end, on a date the FIRST mail states.
fn ladder(r: &mut Rig) {
    let mut t = r.s.terms.write().unwrap();
    t.policy.grace_days = 14;
    // Suspended through day 60, so paying still renews right up to the
    // deletion (Retention refuses a self-service renewal).
    t.policy.retention_days = 47;
    t.nonpay = NonpayPolicy {
        delete_after_days: 60,
        warn_days: vec![0, 14, 53, 59],
        renewal_reminder_days: ReminderDays(vec![7, 3, 1]),
        exempt: vec!["rickard".into()],
        account_url: "https://shop.example/".into(),
        products: Default::default(),
    };
    t.nonpay
        .check(&t.policy)
        .expect("the owner's ladder is a valid policy");
}

/// ★ **Reminded 7, 3 and 1 days before the end — one mail at each, none
/// between; then 14 days of GRACE (still writing, and the mail does not say
/// read-only), then READ-ONLY with its room released, then DELETED 60 days
/// after the end — on the date the day-0 mail named.**
#[test]
fn the_owners_ladder_reminds_7_3_1_then_grace_then_read_only_then_deletes_at_day_60() {
    let mut r = Rig::shop();
    ladder(&mut r);
    r.product
        .purges_answer(vec![MockProduct::purged_clean("bob", 3 * GIB)]);
    // bob stores 2 GiB of his 10.
    r.product.stores("bob", "pack_bytes", 2 * GIB);

    for (at, want) in [(8, 0usize), (7, 1), (5, 1), (3, 2), (2, 2), (1, 3)] {
        r.at(r.paid_until - at * DAY + 1);
        assert_eq!(
            r.mails
                .kinds()
                .iter()
                .filter(|k| *k == "renewal.reminder")
                .count(),
            want,
            "{at} day(s) before the end"
        );
    }
    let reminders: Vec<String> = r
        .mails
        .0
        .lock()
        .unwrap()
        .iter()
        .map(|m| m.subject.clone())
        .collect();
    assert!(
        reminders[0].contains("in 7 day(s)")
            && reminders[1].contains("in 3 day(s)")
            && reminders[2].contains("in 1 day(s)"),
        "{reminders:?}"
    );

    // Day 0: GRACE. Writes still allowed; the first mail names the deletion date.
    r.day(0);
    assert_eq!(
        r.appliance().state,
        State::Grace,
        "14 days of grace: the account still writes"
    );
    let delete_on = r.paid_until + 60 * DAY;
    let first = r.mails.last();
    assert_eq!(first.kind, "nonpay.day0");
    assert!(
        !first.subject.contains("read-only"),
        "in grace the subject must not say read-only: {first:?}"
    );
    assert!(
        first.subject.contains(&iso_date(delete_on)) && first.body.contains(&iso_date(delete_on)),
        "the FIRST mail states the date: {first:?}"
    );
    assert_eq!(
        promised_bytes(&r.s, "gunnar").unwrap(),
        10 * GIB,
        "in grace the room is still held"
    );

    r.day(13);
    assert_eq!(r.appliance().state, State::Grace);
    // Day 14: READ-ONLY. The room is released; the stored bytes still count.
    r.day(14);
    assert_eq!(
        r.appliance().state,
        State::Suspended,
        "read-only after the grace"
    );
    assert_eq!(r.mails.last().kind, "nonpay.day14");
    assert!(
        r.mails.last().subject.contains("read-only"),
        "{:?}",
        r.mails.last()
    );
    assert_eq!(
        promised_bytes(&r.s, "gunnar").unwrap(),
        2 * GIB,
        "the ROOM is released, the DATA (2 GiB) kept"
    );
    assert_eq!(
        r.fact().caps["pack_bytes"],
        10 * GIB,
        "the cap itself is untouched: paying restores it as it was"
    );

    for d in [30, 53, 59] {
        r.day(d);
    }
    assert!(
        r.product.purges().is_empty(),
        "nothing is deleted before day 60"
    );
    let t = r.day(60);
    assert_eq!(t.nonpay.deleted, 1, "{:?}", t.nonpay);
    assert_eq!(r.product.purges().len(), 1);
}

/// ★ **NEVER DELETED INSIDE A PAID PERIOD.** A payment that lands between the
/// tick reading the lapse and the deletion: the deletion re-reads the fact,
/// finds the account paid, and calls the lapse off — nothing is purged.
#[test]
fn nothing_is_deleted_inside_a_paid_period_even_when_the_payment_races_the_deletion() {
    let mut r = Rig::shop();
    ladder(&mut r);
    r.product
        .purges_answer(vec![MockProduct::purged_clean("bob", GIB)]);
    for d in [0, 14, 53, 59] {
        r.day(d);
    }
    let stale = r.fact();
    assert_eq!(stale.state, State::Suspended);
    // bob pays at the last moment; the tick that is deleting still holds the
    // fact it read before.
    let now = r.paid_until + 60 * DAY + 60_000;
    r.s.clock = Box::new(move || now);
    r.renew();
    assert!(
        r.fact().paid_until_unix_ms.unwrap() > now,
        "the payment moved the end past now"
    );
    let policy = r.s.terms.read().unwrap().nonpay.clone();
    let mut did = Did::default();
    let product = r.product.clone();
    lapsed(
        &r.s,
        &policy,
        Case {
            product: product.served(),
            tenant: &TenantId("bob".into()),
            fact: &stale,
            lapsed: r.paid_until,
        },
        now,
        &mut did,
    )
    .unwrap();
    assert!(
        r.product.purges().is_empty(),
        "a paid account is never purged"
    );
    assert_eq!(did.deleted, 0);
    assert!(r.events().contains(&"cancelled"), "{:?}", r.events());
}

/// ★ **Each site's mails name its OWN account page** (gunnar.rs audit NP-3): the ONE box
/// sends the nonpay and renewal mails of every site, and a test2 customer told to renew at
/// gunnar.rs is sent to a site where they have no account. A site's `[estates.<e>]
/// account_url` wins; a site with none is its own `https://<site>/`; a product with no sites
/// keeps the box's `[nonpay] account_url`.
#[test]
fn a_sites_reminder_names_that_sites_own_account_page() {
    let site = |site: &str, url: &str| crate::config::EstateConfig {
        product: "gunnar".into(),
        site: site.into(),
        account_url: url.into(),
        ..Default::default()
    };
    let mut r = Rig::shop_on(|reg| {
        reg.home("gunnar", "gunnar-rs")
            .estate("gunnar-rs", site("gunnar.rs", "https://gunnar.rs/account"))
            .estate("test2-gunnar-rs", site("test2.gunnar.rs", ""))
    });
    ladder(&mut r);
    r.at(r.paid_until - 7 * DAY + 1);
    let m = r.mails.last();
    assert_eq!(m.kind, "renewal.reminder");
    assert!(
        m.body.contains("Renew here: https://gunnar.rs/account")
            && !m.body.contains("shop.example"),
        "the home site's own page: {}",
        m.body
    );

    assert_eq!(
        r.s.registry.account_url_of("test2-gunnar-rs").as_deref(),
        Some("https://test2.gunnar.rs/"),
        "a site with no page of its own: its site"
    );
    let on = |t: &str| {
        r.s.terms
            .read()
            .unwrap()
            .nonpay
            .for_product("gunnar")
            .on_site(
                r.s.registry
                    .estate_of_tenant("gunnar", t)
                    .and_then(|e| r.s.registry.account_url_of(&e)),
            )
    };
    assert_eq!(
        on("@test2-gunnar-rs/nina").account_url,
        "https://test2.gunnar.rs/"
    );
    assert_eq!(on("bob").account_url, "https://gunnar.rs/account");
    let bare = Rig::shop();
    assert_eq!(
        bare.s
            .terms
            .read()
            .unwrap()
            .nonpay
            .for_product("gunnar")
            .on_site(None)
            .account_url,
        "https://shop.example/",
        "no sites: the box's own"
    );
}

/// `renewal_reminder_days` reads both the old single number and the list.
#[test]
fn the_reminder_days_read_as_one_number_or_a_list() {
    let one = crate::config::Config::from_toml("[nonpay]\nrenewal_reminder_days = 7\n").unwrap();
    assert_eq!(one.nonpay.reminder_days(), vec![7]);
    let many =
        crate::config::Config::from_toml("[nonpay]\nrenewal_reminder_days = [1, 7, 3, 3, 0]\n")
            .unwrap();
    assert_eq!(
        many.nonpay.reminder_days(),
        vec![7, 3, 1],
        "largest first, zeros and repeats dropped"
    );
    let none = crate::config::Config::from_toml("[nonpay]\n").unwrap();
    assert!(none.nonpay.reminder_days().is_empty());
}

/// The disk caps every tenant holds (the ledger's facts and the paid signups'), summed over the
/// product's DiskBytes meters, a read-only account's room released and its stored bytes kept
/// ([`crate::oversell::released`]).
fn promised_bytes(state: &AppState, product: &str) -> Result<u64, String> {
    let p = state
        .registry
        .find_product(product)
        .ok_or_else(|| format!("product {product:?} is not linked"))?;
    let disk: Vec<&str> = p
        .meters()
        .iter()
        .filter(|m| m.backing == Some(monetize_product::Backing::DiskBytes))
        .map(|m| m.name)
        .collect();
    let facts = state
        .ledger
        .entitlements(product)
        .map_err(|e| format!("the ledger's entitlements: {e}"))?;
    let facts = crate::signup::with_signup_facts(state, product, facts)
        .map_err(|e| format!("the signup store: {e}"))?;
    let usages = state
        .usage
        .all(product)
        .map_err(|e| format!("the usage store: {e}"))?;
    let facts = crate::oversell::released(facts, &usages, &disk);
    Ok(facts
        .iter()
        .flat_map(|f| f.caps.iter())
        .filter(|(m, _)| disk.contains(&m.as_str()))
        .map(|(_, v)| *v)
        .sum())
}