crates/monetize-server/tests/room_plans.rs
//! **ROOM AS PLANS, over the wire** (owner, 2026-10-05): `Purchase.Bundles`,
//! `Quote` and `Start` for a product with `[pricing.gunnar]` — the rule's
//! prices, the 1-month limit, prolong from the current end and its 3-month
//! horizon, the upgrade's credit, the yearly bump to the same end, the price
//! locked at purchase and the floor re-checked at the buy.
//!
//! The deployment is the owner's estate on the mock cloud (`[machines]` and a
//! data disk and the public IPv4 addresses priced through the seam; 137.33 EUR +
//! 0.26 EUR/GiB a month), a
//! vendor with a hosted checkout, a Finnish buyer (EUR).
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use monetize::pricing::{PriceRule, MONTH_MS};
use monetize::testing::{Answer, MockCloud, NOW};
use monetize_server::testing::estate::FakeEstate;
use monetize_server::testing::products::TestProduct;
use monetize_embed::civil::{iso_date, DAY_MS};
use monetize_proto::purchase_client::PurchaseClient;
use monetize_proto::{self as pb, PurchaseState};
use monetize_server::config::Config;
use monetize_server::registry::Registry;
use monetize_server::state::AppState;
use monetize_server::testing::{http_post, CallbackPay, TestServer};
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::{Code, Request};
const BEARER: &str = "test-bearer-token-of-sufficient-length";
const SECRET: &str = "mockpay-shared-secret";
const GIB: u64 = 1 << 30;
/// The owner's ladder: reminded 7, 3, 1 days before the end; 14 days of
/// grace; read-only; deleted 60 days after the end.
const LADDER: &str = r#"
[policy]
period = "month"
grace_days = 14
retention_days = 47
[nonpay]
delete_after_days = 60
warn_days = [0, 14, 53, 59]
renewal_reminder_days = [7, 3, 1]
"#;
/// The estate, as `monetize.toml` says it.
const TOML: &str = r#"
[pricing.gunnar]
currency = "Eur"
base_extra_month_minor = 9680
replica_gib_month_milli = 15990
fx = { SEK = 110000 }
[pricing.gunnar.plans."10gb-1m"]
size_bytes = 10737418240
period = "month"
[pricing.gunnar.plans."10gb-12m"]
size_bytes = 10737418240
period = "year"
[pricing.gunnar.plans."100gb-12m"]
size_bytes = 107374182400
period = "year"
[pricing.gunnar.plans."500gb-12m"]
size_bytes = 536870912000
period = "year"
[machines.main]
role = "appliance"
hostname = "gunnar-appliance"
cloud = "mockcloud"
server = "srv-main"
plan = "4xCPU-8GB"
[machines.twin]
role = "twin"
hostname = "gunnar-twin"
cloud = "mockcloud"
server = "srv-twin"
plan = "2xCPU-4GB"
[machines.front]
role = "front"
hostname = "gunnar-front"
cloud = "mockcloud"
server = "srv-front"
plan = "1xCPU-2GB"
"#;
struct Rig {
/// The product's estate.
product: FakeEstate,
state: Arc<AppState>,
cloud: Arc<MockCloud>,
pay: CallbackPay,
clock: Arc<AtomicU64>,
server: TestServer,
channel: Channel,
}
impl Rig {
async fn up() -> Rig {
let cloud = Arc::new(MockCloud::new());
cloud.answer_all(Answer::OnHand);
let pay = CallbackPay::with_checkout(SECRET, "https://checkout.example/c/");
let cfg = Config::from_toml(&format!("{LADDER}{TOML}")).expect("the estate's [pricing] parses");
let mut state = AppState::in_memory(Registry::new().pulled_product(TestProduct::gunnar(), "gunnar.rs").sells("gunnar", pay.clone()).cloud(cloud.clone()));
let product = FakeEstate::declare(&state, "gunnar", "gunnar.rs");
product.measures(900 * GIB, 1000 * GIB);
state.plans = monetize_server::plans::PlanBook::new(cfg.pricing.clone());
{
let terms = state.terms.get_mut().unwrap();
terms.policy = cfg.policy;
terms.nonpay = cfg.nonpay.clone();
}
*state.config.write().unwrap() = cfg;
let clock = Arc::new(AtomicU64::new(NOW));
let c = clock.clone();
state.clock = Box::new(move || c.load(Ordering::SeqCst));
let state = Arc::new(state);
let server = TestServer::spawn(state.clone(), BEARER).await;
let channel = Channel::from_shared(server.endpoint()).unwrap().connect().await.expect("connect");
Rig { product, state, cloud, pay, clock, server, channel }
}
fn at(&self, ms: u64) {
self.clock.store(ms, Ordering::SeqCst);
}
fn authed<T>(&self, req: T) -> Request<T> {
let mut r = Request::new(req);
r.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {BEARER}")).unwrap());
r
}
/// The window, asked again while the provider's price is being read.
async fn window(&self, tenant: &str) -> pb::ListBundlesResponse {
for _ in 0..100 {
let w = PurchaseClient::new(self.channel.clone())
.bundles(self.authed(pb::ListBundlesRequest { product: "gunnar".into(), tenant: tenant.into(), market: pb::Market::Fi as i32, actor_ticket: vec![] }))
.await
.expect("the window")
.into_inner();
if !w.reading {
return w;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("the window never stopped reading");
}
async fn start(&self, tenant: &str, bundle: &str, renew: bool, caps: &[(&str, u64)]) -> Result<pb::StartPurchaseResponse, tonic::Status> {
PurchaseClient::new(self.channel.clone())
.start(self.authed(pb::StartPurchaseRequest {
product: "gunnar".into(),
tenant: tenant.into(),
vendor: "mockpay".into(),
market: pb::Market::Fi as i32,
bundle: bundle.into(),
renew,
caps: caps.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
..Default::default()
}))
.await
.map(|r| r.into_inner())
}
/// Start, and pay at the vendor (the signed webhook).
async fn buy(&self, tenant: &str, bundle: &str, renew: bool, caps: &[(&str, u64)]) -> pb::StartPurchaseResponse {
let started = self.start(tenant, bundle, renew, caps).await.unwrap_or_else(|e| panic!("{bundle}: {e:?}"));
assert_eq!(started.state(), PurchaseState::Pending, "money goes through the vendor: {started:?}");
self.pay.authorize(&started.handle_id);
let body = CallbackPay::callback_body(&started.reference, &started.handle_id, "authorized");
let (code, said) = http_post(self.server.addr, "/callback/mockpay", &self.pay.signed_headers(), &body).expect("the webhook answers");
assert_eq!(code, 200, "{said}");
// The estate's main pulls the paid fact on its next call home.
self.product.call_home();
let fact = self.state.ledger.entitlement("gunnar", tenant).unwrap().expect("a fact");
assert_eq!(fact.plan, started.reference, "the paid order is the fact");
started
}
fn fact(&self, tenant: &str) -> monetize_product::EntitlementFact {
self.state.ledger.entitlement("gunnar", tenant).unwrap().expect("a fact")
}
}
fn eur(minor: u64) -> String {
monetize_pay::Money { minor, currency: monetize_pay::Currency::Eur }.in_words()
}
fn basis() -> monetize::pricing::CostBasis {
monetize::pricing::CostBasis { currency: monetize_pay::Currency::Eur, base_month_minor: 13_733, storage_gib_month_milli: 25_990 }
}
fn row<'a>(w: &'a pb::ListBundlesResponse, id: &str) -> &'a pb::Bundle {
w.bundles.iter().find(|b| b.id == id).unwrap_or_else(|| panic!("no row {id}: {:?}", w.bundles.iter().map(|b| &b.id).collect::<Vec<_>>()))
}
fn customer_words(s: &tonic::Status) -> String {
s.metadata().get_bin(monetize_server::services::purchase::CUSTOMER_WORDS_KEY).map(|v| String::from_utf8(v.to_bytes().unwrap().to_vec()).unwrap()).unwrap_or_default()
}
/// ★ **THE FIRST VISIT: "10 GiB · 1 month" and the 12-month plans at 10 /
/// 100 / 500 GiB, priced by the rule** — at N = 20, 13.71 EUR a month, and
/// the 12-month plan 20 % cheaper a month, charged for the year.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_first_visit_is_one_month_of_ten_gib_and_the_twelve_month_plans_priced_by_the_rule() {
let rig = Rig::up().await;
let w = rig.window("dora").await;
let ids: Vec<(&str, &str, &str)> = w.bundles.iter().map(|b| (b.id.as_str(), b.label.as_str(), b.action.as_str())).collect();
assert_eq!(
ids,
[
("10gb-1m", "10 GiB · 1 month", "start"),
("10gb-12m", "10 GiB · 12 months", "start"),
("100gb-12m", "100 GiB · 12 months", "start"),
("500gb-12m", "500 GiB · 12 months", "start"),
]
);
assert_eq!((row(&w, "10gb-1m").price.as_str(), row(&w, "10gb-1m").period.as_str()), ("13.71 EUR", "month"));
let r = PriceRule::default();
assert_eq!(row(&w, "10gb-12m").price, eur(r.yearly_total_minor(&basis(), 10 * GIB, 20)), "12 × 10.97");
assert_eq!(row(&w, "100gb-12m").price, eur(r.yearly_total_minor(&basis(), 100 * GIB, 20)));
assert!(w.bundles.iter().all(|b| b.can_serve), "{:?}", w.bundles);
assert_eq!(row(&w, "100gb-12m").target.get("pack_bytes"), Some(&(100 * GIB)), "the ticket covers the plan's size");
assert_eq!(w.plan.as_ref().map(|p| p.state.as_str()), Some("none"));
rig.server.stop().await;
}
/// ★ **A PLAN PAST THE ROOM LEFT IS AN OFFER STATE, NOT AN ORDER'S
/// REFUSAL** (test2, 2026-10-05: the More room panel showed, on load, "We
/// cannot raise this account to pack_bytes 500.0 GiB right now: 37.8 GiB is
/// all there is left … Nothing has been charged …" for an offer nobody had
/// pressed). The window marks the row — `can_serve = false` and the offer's
/// own short sentence — and the BUY is still re-checked: pressing it anyway
/// is refused with the order's sentence and nothing is reserved.
///
/// **Round 5.3** (owner 2026-10-08: "the customer must not know about a
/// cap!"): this rig has NO grow set — a box that is never resized — so the
/// row is still marked, but no sentence names the room left (37.8 GiB) or a
/// cap. On an estate whose set grows every size is offered and queued
/// (`shop_window_reading::an_offer_past_the_growable_room_is_offered_…`).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_plan_past_the_room_left_is_marked_with_the_offers_sentence_and_the_buy_is_still_refused() {
let rig = Rig::up().await;
// test2's set: 39.8 GiB free less the 2.0 GiB disk floor.
rig.product.holds_up_to("pack_bytes", 37 * GIB + 858_993_459);
let w = rig.window("rickard").await;
for id in ["10gb-1m", "10gb-12m"] {
assert!(row(&w, id).can_serve, "{id} fits: {:?}", row(&w, id));
}
for id in ["100gb-12m", "500gb-12m"] {
let r = row(&w, id);
assert!(!r.can_serve, "{id} does not fit: {r:?}");
assert_eq!(r.reason, monetize::transaction::words::NOT_OFFERED_HERE, "{id}");
assert!(!r.reason.contains("GiB") && !r.reason.contains("room left"), "{id}: no room left named: {:?}", r.reason);
}
let e = rig.start("rickard", "500gb-12m", false, &[]).await.unwrap_err();
assert_eq!(e.code(), Code::FailedPrecondition, "{e:?}");
let said = customer_words(&e);
assert!(said.contains("Nothing has been charged") && !said.contains("37.8 GiB") && !said.contains("left"), "{said}");
assert!(rig.pay.payers().is_empty(), "nothing was reserved");
rig.server.stop().await;
}
/// ★ **cov-10 (estate-box, 2026-10-06): a greyed offer never names a room
/// that contradicts it** — and since round 5.3 (owner 2026-10-08: "the
/// customer must not know about a cap!") it names no room at all. The order
/// cloud is the box's own pool: 7 GiB free while the disk could take
/// 35.1 GiB; on this rig (no grow set) every row past the pool is marked
/// with the one short line, which carries no number.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_room_left_named_on_a_greyed_offer_is_the_wall_that_refused_it() {
let rig = Rig::up().await;
rig.product.holds_up_to("pack_bytes", 35 * GIB + 107_374_182);
rig.cloud.pool_left(7 * GIB);
let w = rig.window("covd").await;
for id in ["10gb-1m", "10gb-12m", "100gb-12m", "500gb-12m"] {
let r = row(&w, id);
assert!(!r.can_serve, "{id} does not fit the 7 GiB the pool has left: {r:?}");
assert_eq!(r.reason, monetize::transaction::words::NOT_OFFERED_HERE, "{id}: no room, cap or limit named");
}
rig.server.stop().await;
}
/// ★ **A 1-MONTH ORDER ABOVE 10 GiB IS REFUSED BY MONETIZE**, with the
/// sentence that names the way on — and nothing is reserved.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_one_month_order_above_ten_gib_is_refused_with_a_clear_sentence() {
let rig = Rig::up().await;
rig.window("dora").await;
let e = rig.start("dora", "", false, &[("pack_bytes", 1000 * GIB)]).await.unwrap_err();
assert_eq!(e.code(), Code::FailedPrecondition, "{e:?}");
assert_eq!(
customer_words(&e),
"A 1-month plan holds at most 10 GiB. For more room, choose a 12-month plan — its size can be raised at any time. Nothing has been charged."
);
assert!(rig.pay.payers().is_empty(), "nothing was reserved");
// The QUOTE of the same order is a quote that cannot be served — the rule's
// limit in the reason, the customer's sentence in `words` — not an error.
let q = PurchaseClient::new(rig.channel.clone())
.quote(rig.authed(pb::QuoteRequest { product: "gunnar".into(), tenant: "dora".into(), market: pb::Market::Fi as i32, caps: [("pack_bytes".to_string(), 1000 * GIB)].into(), ..Default::default() }))
.await
.expect("a refused plan still quotes")
.into_inner();
assert!(!q.can_serve && q.reason.contains("limit") && q.words.contains("at most 10 GiB"), "{q:?}");
rig.server.stop().await;
}
/// ★ **After a paid month: the gauge (days left, the end, the cap),
/// "Prolong 1 month" at the LOCKED price, and the 12-month upgrades.**
/// Prolong takes the money now and moves the end one month from the
/// CURRENT END; it stacks to 3 months ahead, then the way on is the 12-month
/// plan.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_paid_month_shows_the_gauge_prolongs_from_the_end_and_stops_three_months_ahead() {
let rig = Rig::up().await;
rig.window("dora").await;
let first = rig.buy("dora", "10gb-1m", false, &[]).await;
let end = rig.fact("dora").paid_until_unix_ms.unwrap();
assert_eq!(iso_date(end), iso_date(monetize::Period::Month.end_of(NOW)), "one month from the purchase");
assert!(first.reference.ends_with("+plan:month"), "{}", first.reference);
rig.at(NOW + 10 * DAY_MS);
let w = rig.window("dora").await;
let p = w.plan.clone().expect("the plan");
assert_eq!((p.state.as_str(), p.label.as_str(), p.cap_bytes, p.period.as_str()), ("paid", "10 GiB · 1 month", 10 * GIB, "month"));
assert_eq!(p.paid_until_unix_ms, end);
assert_eq!(p.days_left, ((end - (NOW + 10 * DAY_MS)).div_ceil(DAY_MS)) as u32);
assert!(p.ends_words.contains(&iso_date(end)), "{}", p.ends_words);
assert_eq!(p.price_words, "13.71 EUR per month", "the price LOCKED at the purchase");
let actions: Vec<(&str, &str)> = w.bundles.iter().map(|b| (b.id.as_str(), b.action.as_str())).collect();
assert_eq!(actions, [("prolong-1m", "prolong"), ("10gb-12m", "upgrade"), ("100gb-12m", "upgrade"), ("500gb-12m", "upgrade")]);
assert_eq!(row(&w, "prolong-1m").price, "13.71 EUR");
assert!(row(&w, "prolong-1m").target.is_empty(), "a prolong is a renewal: no caps, a renew ticket");
// PROLONG: the money now, the end one month from the CURRENT end.
let prolonged = rig.buy("dora", "prolong-1m", false, &[]).await;
let end2 = rig.fact("dora").paid_until_unix_ms.unwrap();
assert_eq!(end2, monetize::Period::Month.end_of(end), "one month from the current END, not from today");
assert_eq!(rig.fact("dora").caps["pack_bytes"], 10 * GIB, "a prolong keeps the room");
let sold = rig.state.ledger.attempt(&prolonged.reference).unwrap().unwrap().purchase;
let terms = sold.plan.expect("a plan purchase carries its terms");
assert_eq!((terms.verb, terms.month_price.in_words(), sold.price.in_words()), (monetize::plan::Verb::Prolong, "13.71 EUR".into(), "13.71 EUR".into()));
assert!(sold.delta.is_empty(), "a prolong sells no new room");
// The renew flag is the same verb.
rig.buy("dora", "", true, &[]).await;
let end3 = rig.fact("dora").paid_until_unix_ms.unwrap();
assert_eq!(end3, monetize::Period::Month.end_of(end2));
// Three months ahead: the row says so, and Start refuses it.
let w = rig.window("dora").await;
let r = row(&w, "prolong-1m");
assert!(!r.can_serve && r.reason.contains("3 months ahead") && r.reason.contains("12-month plan"), "{r:?}");
// ★ cov-10 (2026-10-06): an offer drawn on a panel just opened carries the
// OFFER's words, never an order's ("Nothing has been charged" — nothing was
// ordered); the buy, if pressed, is refused with the order's.
assert!(!r.reason.contains("Nothing has been charged"), "an order's words on load: {r:?}");
let e = rig.start("dora", "prolong-1m", false, &[]).await.unwrap_err();
assert!(customer_words(&e).contains("3 months ahead"), "{e:?}");
assert!(customer_words(&e).contains("Nothing has been charged"), "the buy is refused in the order's words: {e:?}");
rig.server.stop().await;
}
/// ★ **UPGRADE: a 1-month plan becomes a 100 GiB 12-month plan from today,
/// the unused paid time credited**; the end moves a year out, the room up.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_upgrade_to_a_yearly_plan_credits_the_unused_month_and_runs_a_year_from_today() {
let rig = Rig::up().await;
rig.window("dora").await;
rig.buy("dora", "10gb-1m", false, &[]).await;
let end = rig.fact("dora").paid_until_unix_ms.unwrap();
let now = NOW + 15 * DAY_MS;
rig.at(now);
let w = rig.window("dora").await;
let r = PriceRule::default();
let credit = r.credit_minor(r.monthly_minor(&basis(), 10 * GIB, 20), end - now);
let want = r.yearly_total_minor(&basis(), 100 * GIB, 20) - credit;
assert_eq!(row(&w, "100gb-12m").price, eur(want), "the year, less {} of unused month", eur(credit));
rig.buy("dora", "100gb-12m", false, &[]).await;
let fact = rig.fact("dora");
assert_eq!(fact.caps["pack_bytes"], 100 * GIB);
// The SAME size for a year (a 10 GiB month → 10 GiB · 12 months) is not
// "an increase", and it still sells: the room held, a year from today.
rig.at(now);
let other = rig.buy("erik", "10gb-1m", false, &[]).await;
let _ = other;
let r = rig.buy("erik", "10gb-12m", false, &[]).await;
let erik = rig.fact("erik");
assert_eq!((erik.caps["pack_bytes"], erik.paid_until_unix_ms), (10 * GIB, Some(monetize::Period::Year.end_of(now))), "{}", r.reference);
assert_eq!(fact.paid_until_unix_ms, Some(monetize::Period::Year.end_of(now)), "a year from today");
let p = rig.window("dora").await.plan.unwrap();
assert_eq!((p.period.as_str(), p.label.as_str()), ("year", "100 GiB · 12 months"));
assert!(p.bump_words.starts_with("Any bigger size:") && p.bump_words.contains("per GiB per month"), "{}", p.bump_words);
assert_eq!(p.bump_min_bytes, 101 * GIB);
rig.server.stop().await;
}
/// ★ **A 12-month plan is BUMPED to any size, at the per-GiB yearly rate, to
/// the SAME end** — typed, or a bigger declared plan.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_yearly_plan_is_bumped_to_any_size_to_the_same_end() {
let rig = Rig::up().await;
rig.window("dora").await;
rig.buy("dora", "10gb-12m", false, &[]).await;
let end = rig.fact("dora").paid_until_unix_ms.unwrap();
let now = NOW + 6 * MONTH_MS;
rig.at(now);
let w = rig.window("dora").await;
assert_eq!(w.bundles.iter().map(|b| (b.id.as_str(), b.action.as_str())).collect::<Vec<_>>(), [("100gb-12m", "bump"), ("500gb-12m", "bump")]);
let quoted = PurchaseClient::new(rig.channel.clone())
.quote(rig.authed(pb::QuoteRequest { product: "gunnar".into(), tenant: "dora".into(), market: pb::Market::Fi as i32, caps: [("pack_bytes".to_string(), 37 * GIB)].into(), ..Default::default() }))
.await
.expect("a bump quotes")
.into_inner();
let r = PriceRule::default();
assert_eq!(quoted.price_words, eur(r.bump_minor(&basis(), 10 * GIB, 37 * GIB, end - now)));
rig.buy("dora", "", false, &[("pack_bytes", 37 * GIB)]).await;
let fact = rig.fact("dora");
assert_eq!((fact.caps["pack_bytes"], fact.paid_until_unix_ms), (37 * GIB, Some(end)), "bigger, to the SAME end");
rig.server.stop().await;
}
/// ★ **The price is LOCKED at the purchase — and the 10 % floor is
/// re-checked at the buy with a FRESH provider price**: the provider doubles
/// its prices; the shown (locked, cached) prolong price no longer clears cost
/// + 10 %, so the buy is refused and nothing is charged; the window then
/// shows today's price, which sells.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_price_under_the_floor_at_the_fresh_provider_price_is_refused_at_the_buy() {
let rig = Rig::up().await;
rig.window("dora").await;
rig.buy("dora", "10gb-1m", false, &[]).await;
rig.at(NOW + 20 * DAY_MS);
assert_eq!(row(&rig.window("dora").await, "prolong-1m").price, "13.71 EUR");
*rig.cloud.price_minor.lock().unwrap() = Some(9_000);
let payers = rig.pay.payers().len();
let e = rig.start("dora", "prolong-1m", false, &[]).await.unwrap_err();
assert_eq!(customer_words(&e), monetize::plan::words::PRICE_MOVED, "{e:?}");
assert_eq!(rig.pay.payers().len(), payers, "nothing reserved under the floor");
let today = row(&rig.window("dora").await, "prolong-1m").price.clone();
assert_ne!(today, "13.71 EUR", "the window now shows today's price");
rig.buy("dora", "prolong-1m", false, &[]).await;
rig.server.stop().await;
}
/// ★ **THE BANNER — monetize's words, with the one button**: reminded in the
/// last 7 days ("in N days", the button Prolong 1 month), in the GRACE (pay
/// by the grace's end to keep pushing; the deletion date), and READ-ONLY
/// (clone and fetch work, pushes refused, the deletion date; paying restores).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_banner_reminds_before_the_end_then_names_the_grace_and_read_only() {
let rig = Rig::up().await;
rig.window("dora").await;
rig.buy("dora", "10gb-1m", false, &[]).await;
let end = rig.fact("dora").paid_until_unix_ms.unwrap();
rig.at(end - 20 * DAY_MS);
assert_eq!(rig.window("dora").await.plan.unwrap().banner, "", "no banner three weeks before the end");
rig.at(end - 2 * DAY_MS - 3_600_000);
let p = rig.window("dora").await.plan.unwrap();
assert!(p.banner.contains(&iso_date(end)) && p.banner.contains("in 3 days") && p.banner.contains("Prolong 1 month"), "{p:?}");
assert_eq!((p.banner_action.as_str(), p.banner_tone.as_str()), ("prolong-1m", "warn"));
// The end passes; the poller's ladder moves the fact to GRACE (and the
// first mail fixes the deletion date: 60 days after the end).
rig.at(end + 60_000);
monetize_server::poller::tick(&rig.state);
let p = rig.window("dora").await.plan.unwrap();
assert_eq!(p.state, "grace", "{p:?}");
let grace_end = iso_date(end + 14 * DAY_MS);
let deleted = iso_date(end + 60 * DAY_MS);
assert!(p.banner.contains(&format!("Pay by {grace_end}")) && p.banner.contains(&deleted), "{p:?}");
assert_eq!((p.banner_action.as_str(), p.banner_tone.as_str()), ("prolong-1m", "bad"));
// Past the grace: READ-ONLY.
rig.at(end + 15 * DAY_MS);
monetize_server::poller::tick(&rig.state);
let p = rig.window("dora").await.plan.unwrap();
assert_eq!(p.state, "read_only", "{p:?}");
assert!(p.banner.contains("read-only since") && p.banner.contains("clone and fetch work") && p.banner.contains(&deleted) && p.banner.contains("Paying restores"), "{p:?}");
for w in monetize::transaction::words::NEVER_SHOWN {
assert!(!p.banner.to_lowercase().contains(w), "{w:?} in the banner: {}", p.banner);
}
// Paying restores: the prolong from today (the lapsed days are not billed).
rig.buy("dora", "prolong-1m", false, &[]).await;
let fact = rig.fact("dora");
assert_eq!(fact.state, monetize_product::State::Paid);
assert_eq!(fact.paid_until_unix_ms, Some(monetize::Period::Month.end_of(end + 15 * DAY_MS)));
rig.server.stop().await;
}