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/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;
}