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())
}