crates/monetize-ui-web/src/pages/ledger.rs
//! `docs/v2/07-console.md` screen 4 — Activity: the ledger, de-jargoned. Since 2026-10-02 it is
//! an estate's MONEY (payments, refunds, failed charges, the money waiting at a
//! vendor, the paid signups, and the spend against each cloud's ceiling), and
//! the refused vendor deliveries with the ledger's own steps are its EVENTS.
//!
//! NEEDS YOU first: an unfinished attempt is the only row that is actually a
//! task — money reserved and not yet captured or released — so it floats to
//! the top with its checkout link, when the vendor has one. Everything else is
//! history: one outcome line per attempt (`Sold bob 10.0 GiB · 220.00 SEK
//! ✓`), with the ledger steps behind a disclosure in plain words. The
//! step-by-step dump is no longer the default rendering.
//!
//! Decisions: `fmt::split_needs_you`, `sales_for`, `sale_line`, `sale_mark`,
//! `needs_you_line`, `month_day`, `steps_of`, `plain_step`, `step_tone` — all
//! tested on the host. This file lays them out.
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use crate::fmt::{
date, money, month_day, needs_you_line, paid_at_vendor_line, paid_at_vendor_mark,
paid_at_vendor_note, paid_at_vendor_title, plain_step, refusal_line, refusal_mark,
refusals_title, sale_line, sale_mark, sales_for, signup_line, signup_mark, signup_reason,
split_needs_you, step_tone, steps_of, PAID_AT_VENDOR_NOTE, REFUSALS_NOTE,
};
use crate::pages::{
product_href, tenant_href, LedgerJson, PaidAtVendorJson, PaidAtVendorListJson, RefusalJson,
RefusalsJson, RowJson, SaleJson, SignupJson, SignupsJson,
};
use crate::ui::{resolved, Badge, Empty, PageHeader, Panel, Tone};
#[component]
pub fn LedgerPage() -> impl IntoView {
let query = use_query_map();
let product = Memo::new(move |_| query.read().get("product").unwrap_or_default());
let tenant = Memo::new(move |_| query.read().get("tenant").unwrap_or_default());
let feed = crate::feed::use_feed();
let reload = RwSignal::new(0u32);
let data = LocalResource::new(move || {
reload.get();
feed.reread.get();
let p = product.get();
async move {
crate::api::get::<LedgerJson>(&format!(
"/api/ledger?product={}",
crate::fmt::percent_encode(&p)
))
.await
}
});
let retry = Callback::new(move |()| reload.update(|n| *n += 1));
let estate = crate::api::estate().unwrap_or_default();
let title = move || {
let (p, t) = (product.get(), tenant.get());
match (p.is_empty(), t.is_empty()) {
(true, _) => format!("{estate} · Money"),
(false, true) => format!("{estate} · Money · {p}"),
(false, false) => format!("{estate} · Money · {t}"),
}
};
view! {
<PageHeader title=title>
{move || match feed.connection.get() {
crate::feed::Connection::Live => view! { <Badge tone=Tone::Live label="live"/> }.into_any(),
crate::feed::Connection::Connecting => view! { <Badge tone=Tone::Plain label="connecting …"/> }.into_any(),
crate::feed::Connection::Ended => view! { <Badge tone=Tone::Bad label="feed ended"/> }.into_any(),
}}
{move || {
let p = product.get();
(!p.is_empty()).then(|| view! { <a class="btn quiet" href=product_href(&p)>{format!("← {p}")}</a> })
}}
<a class="btn quiet" href=crate::pages::here("tax")>"Tax →"</a>
</PageHeader>
{move || resolved(data.get(), Some(retry), |l: LedgerJson| {
let scoped = sales_for(l.sales, Some(&tenant.get()));
let (needs, history) = split_needs_you(scoped);
let recent = l.recent;
view! {
<div class="stack">
{(!needs.is_empty()).then(|| view! {
<Panel title=format!("Needs you · {}", needs.len())>
<div class="sale-list">
{needs.into_iter().map(|s| {
let href = tenant_href(&s.product, &s.tenant);
let reference = s.reference.clone();
let abandon = serde_json::json!({ "reference": s.reference });
view! {
<div class="sale-row needs-you">
<span class="when hint">{month_day(s.at_unix_ms)}</span>
<span class="what"><Badge tone=Tone::Warn label="⚠"/>" "{needs_you_line(&s)}</span>
<span class="price mono"><Checkout reference=reference/></span>
// ★ UW-18: the operator gives up a stuck attempt: what it reserved is released.
<crate::pages::money::MoneyAction label="Abandon…" confirm_label="Confirm: abandon it and release what it holds" path="/api/purchase/abandon".to_owned() base=abandon words_field="reason" on_done=retry/>
<a class="mark" href=href title="the tenant">"→"</a>
</div>
}
}).collect_view()}
</div>
</Panel>
})}
<PaidAtVendor/>
<crate::pages::money::OwedBack/>
<Signups/>
<Panel title="History">
{if history.is_empty() {
view! { <Empty title="Nothing yet" detail="Nothing here yet."/> }.into_any()
} else {
view! {
<div class="sale-list">
{history.into_iter().map(|s| { let steps = steps_of(&recent, &s.reference); view! { <SaleRow sale=s steps=steps/> } }).collect_view()}
</div>
}.into_any()
}}
</Panel>
<crate::pages::products::SpendLines/>
</div>
}
})}
}
}
/// **An estate's EVENTS**: what reached it and was refused (the vendor
/// deliveries monetize would not take), and the ledger's own steps, newest
/// first — the step-by-step record that Money folds into one line per
/// attempt. Live: every delta on the feed re-reads both.
#[component]
pub fn EventsPage() -> impl IntoView {
let feed = crate::feed::use_feed();
let reload = RwSignal::new(0u32);
let data = LocalResource::new(move || {
reload.get();
feed.reread.get();
async move { crate::api::get::<LedgerJson>("/api/ledger").await }
});
let retry = Callback::new(move |()| reload.update(|n| *n += 1));
view! {
<PageHeader title=format!("{} · Events", crate::api::estate().unwrap_or_default()) subtitle="Newest first: refused payment messages and every ledger step.">
{move || match feed.connection.get() {
crate::feed::Connection::Live => view! { <Badge tone=Tone::Live label="live"/> }.into_any(),
crate::feed::Connection::Connecting => view! { <Badge tone=Tone::Plain label="connecting …"/> }.into_any(),
crate::feed::Connection::Ended => view! { <Badge tone=Tone::Bad label="feed ended"/> }.into_any(),
}}
</PageHeader>
<div class="stack">
<Refusals/>
<Panel title="Ledger steps">
{move || resolved(data.get(), Some(retry), |l: LedgerJson| {
if l.recent.is_empty() {
return view! { <Empty title="Nothing yet" detail="Nothing here yet."/> }.into_any();
}
view! {
<div class="sale-list">
{l.recent.into_iter().map(|r| view! {
<div class="sale-row">
<span class="when hint" title=date(r.at_unix_ms)>{month_day(r.at_unix_ms)}</span>
<span class="what mono">{r.reference.clone()}</span>
<span class="price hint mono">{format!("#{}", r.seq)}</span>
<span class="mark timeline"><span class=format!("step {}", step_tone(&r.kind).word()) title=r.kind.clone()>{plain_step(&r.kind)}</span></span>
</div>
}).collect_view()}
</div>
}.into_any()
})}
</Panel>
</div>
}
}
/// **Paid at the vendor** — money the vendor says it took that monetize had
/// no row for.
///
/// It sits ABOVE the refusals, because it is the thing the refusals were
/// hiding. MEASURED 2026-09-14 on the front: an Apple Pay checkout was paid
/// at 11:10 UTC, every webhook for it was refused over a two-hour clock skew,
/// and for hours the only trace anywhere was a journald line while the
/// customer read "This session is not known to us". monetize's poller now
/// asks the vendor what it took, and a payment it has no row for becomes a
/// row here: handed to the same signup engine the webhook feeds when it
/// carries a plan this deployment sells, or left for a person, by name.
///
/// Like the two panels below it fetches on its own, so a monetize too old to
/// serve `ReconcileAdmin` leaves the rest of the page alone, and it draws
/// NOTHING when there are no cases. The absence of the panel is the good
/// state.
#[component]
fn PaidAtVendor() -> impl IntoView {
let feed = crate::feed::use_feed();
let data = LocalResource::new(move || {
// Re-read on every feed delta: monetize publishes a case to the bus as
// it records it, so a console that is open when the poller finds the
// money sees it on that tick.
feed.reread.get();
async move {
crate::api::get::<PaidAtVendorListJson>(&crate::api::site_path("/api/paid-at-vendor"))
.await
}
});
move || {
let read = data.get().and_then(|r| r.ok()).unwrap_or_default();
let rows = read.cases.clone();
(!rows.is_empty()).then(|| {
view! {
<Panel title=paid_at_vendor_title(rows.len(), read.needs_operator)>
<p class="hint">{PAID_AT_VENDOR_NOTE}</p>
<div class="sale-list">
{rows.into_iter().map(|c| view! { <PaidAtVendorRow case=c/> }).collect_view()}
</div>
</Panel>
}
})
}
}
/// One case. The vendor's session id is the row's `title` and its right-hand
/// column, for the reason the signup row does the same: it is what an
/// operator pastes into the vendor's dashboard, and it is unreadable as a
/// heading. The date is when the MONEY MOVED, not when monetize noticed —
/// the gap between the two is the outage, and the operator wants the former.
#[component]
fn PaidAtVendorRow(case: PaidAtVendorJson) -> impl IntoView {
let (tone, mark) = paid_at_vendor_mark(&case.state);
let note = paid_at_vendor_note(&case);
let handle = format!("{}:{}", case.vendor, case.session);
let when = if case.paid_at_unix_ms > 0 {
case.paid_at_unix_ms
} else {
case.first_seen_unix_ms
};
view! {
<div class="sale-row">
<span class="when hint">{month_day(when)}</span>
<span class="what" title=handle.clone()>
{paid_at_vendor_line(&case)}
{note.map(|n| view! { <br/><span class="hint">{n}</span> })}
</span>
<span class="price mono hint">{handle}</span>
<span class="mark"><Badge tone=tone label=mark/></span>
</div>
}
}
/// **The refused callbacks** — vendor deliveries monetize threw away.
///
/// It sits ABOVE the signups, and above them for a reason: a refused signup at
/// least left a durable record with a name on it, while a refused callback used
/// to leave nothing at all. MEASURED 2026-09-14 on the front — a two-hour clock
/// skew put every signed Stripe timestamp outside the 300 s tolerance, monetize
/// refused every genuine delivery, and the only trace was a journald line
/// nobody was reading. The owner watched a customer pay and this console showed
/// the same page it shows when nothing has happened.
///
/// Like the signups it fetches on its own, so a monetize too old to serve
/// `CallbackAdmin` leaves the rest of the page alone, and a failed read draws
/// nothing rather than a red box over the ledger the operator came for.
///
/// It draws NOTHING when there are no refusals. The absence of the panel is the
/// good state; a permanent empty panel saying "0" would be one more thing to
/// scan past on a page that is read in a hurry.
#[component]
pub fn Refusals() -> impl IntoView {
let feed = crate::feed::use_feed();
let data = LocalResource::new(move || {
// Re-read on every feed delta, and monetize publishes a refusal to the
// bus as it records it — so during an outage where NOTHING else moves
// (no ledger row, no entitlement change: that is what a refused
// callback means) this panel is still the thing that updates.
feed.reread.get();
async move { crate::api::get::<RefusalsJson>("/api/callback-refusals").await }
});
move || {
let read = data.get().and_then(|r| r.ok()).unwrap_or_default();
let rows = read.refusals.clone();
(!rows.is_empty()).then(|| {
view! {
<Panel title=refusals_title(rows.len(), read.total_since_start)>
<p class="hint">{REFUSALS_NOTE}</p>
<div class="sale-list">
{rows.into_iter().map(|r| view! { <RefusalRow refusal=r/> }).collect_view()}
</div>
</Panel>
}
})
}
}
/// One refused callback.
///
/// monetize's refusal is under the line, as TEXT — Leptos escapes a text node,
/// and the string was bounded and stripped of control characters by monetize
/// before it was ever kept. An unverified row says so on its own line rather
/// than only in a badge: the sentence below it can quote the caller's own
/// numbers back, and an operator has to know that before reading them.
#[component]
fn RefusalRow(refusal: RefusalJson) -> impl IntoView {
let (tone, mark) = refusal_mark(&refusal.refusal);
let detail = refusal.detail.clone();
// The sequence number is the row's `title` for the same reason the signup's
// session id is: it is what tells an operator how many refusals are missing
// between two rows, and it is unreadable as a column.
let seq = format!("#{}", refusal.refusal_seq);
view! {
<div class="sale-row">
<span class="when hint">{month_day(refusal.at_unix_ms)}</span>
<span class="what" title=seq.clone()>
{refusal_line(&refusal)}
{(!detail.is_empty()).then(|| view! { <br/><span class="hint">{detail}</span> })}
</span>
<span class="price mono hint">{seq}</span>
<span class="mark"><Badge tone=tone label=mark/></span>
</div>
}
}
/// **The paid signups** — money from people who had no account here when they
/// paid, refusals included.
///
/// It sits on Activity and not on a screen of its own because that is where an
/// operator already looks for "what happened lately", and a refused signup is
/// the most urgent thing this console can show: a card was charged and there is
/// no account. It is its own panel rather than rows in History because a signup
/// is not a ledger attempt — no reference, no tenant, no steps — and mixing the
/// two would order one list by two different clocks.
///
/// It fetches on its own, so a monetize with no signup store (or none taken
/// yet) leaves the rest of the page alone; a failed read draws nothing rather
/// than a red box over the ledger the operator came for.
#[component]
fn Signups() -> impl IntoView {
let feed = crate::feed::use_feed();
let data = LocalResource::new(move || {
feed.reread.get();
async move { crate::api::get::<SignupsJson>(&crate::api::site_path("/api/signups")).await }
});
move || {
let rows = data
.get()
.and_then(|r| r.ok())
.map(|s| s.signups)
.unwrap_or_default();
(!rows.is_empty()).then(|| {
view! {
<Panel title=format!("Paid signups · {}", rows.len())>
<div class="sale-list">
{rows.into_iter().map(|s| view! { <SignupRow signup=s/> }).collect_view()}
</div>
</Panel>
}
})
}
}
/// One signup line. The vendor's session id is the row's `title`, because it is
/// what an operator pastes into the vendor's dashboard and it is unreadable as
/// a column.
#[component]
fn SignupRow(signup: SignupJson) -> impl IntoView {
let (tone, mark) = signup_mark(&signup.state);
let reason = signup_reason(&signup);
let source = crate::fmt::signup_source(&signup);
let handle = format!("{}:{}", signup.vendor, signup.session);
let when = if signup.recorded_at_unix_ms > 0 {
signup.recorded_at_unix_ms
} else {
signup.paid_at_unix_ms
};
view! {
<div class="sale-row">
<span class="when hint">{month_day(when)}</span>
<span class="what" title=handle.clone()>
{signup_line(&signup)}
{reason.map(|r| view! { <br/><span class="hint">{r}</span> })}
<br/><span class="hint">{source}</span>
</span>
<span class="price mono hint">{handle}</span>
<span class="mark"><Badge tone=tone label=mark/></span>
</div>
}
}
/// One outcome line, the steps behind `⌄`.
#[component]
fn SaleRow(sale: SaleJson, steps: Vec<RowJson>) -> impl IntoView {
let (tone, mark) = sale_mark(&sale.outcome, sale.drawn);
let reason = sale.reason.clone();
let reference = sale.reference.clone();
let count = sale.steps;
view! {
<details class="sale-row disclosed taxed">
<summary>
<span class="when hint">{month_day(sale.at_unix_ms)}</span>
<span class="what" title=reason>{sale_line(&sale)}</span>
<span class="price mono" title=crate::fmt::sale_money_line(&sale)>{money(sale.price_minor, &sale.currency)}</span>
<span class="tax mono hint">{crate::fmt::sale_tax_line(&sale).unwrap_or_default()}</span>
<span class="mark"><Badge tone=tone label=mark/></span>
</summary>
<div class="steps">
<span class="hint mono">{reference.clone()}</span>
{if steps.is_empty() {
view! { <Steps reference=reference/> }.into_any()
} else {
view! { <Timeline rows=steps/> }.into_any()
}}
<span class="hint">{format!("{count} step{}", if count == 1 { "" } else { "s" })}</span>
</div>
</details>
}
}
/// `reserved → bought disk → charged → granted`, each step's variant name and
/// full date in its title.
#[component]
fn Timeline(rows: Vec<RowJson>) -> impl IntoView {
view! {
<div class="timeline">
{rows.into_iter().enumerate().map(|(i, r)| view! {
{(i > 0).then(|| view! { <span class="arrow">"→"</span> })}
<span class=format!("step {}", step_tone(&r.kind).word()) title=format!("{} · {}", r.kind, date(r.at_unix_ms))>{plain_step(&r.kind)}</span>
}).collect_view()}
</div>
}
}
/// The steps of an attempt older than the recent window, fetched by reference
/// when the disclosure is opened — the same status read that carries the
/// checkout link.
#[component]
fn Steps(reference: String) -> impl IntoView {
let rows = LocalResource::new(move || {
let path = format!("/api/purchase/{reference}");
async move {
crate::api::get::<serde_json::Value>(&path)
.await
.ok()
.map(|v| {
v["rows"]
.as_array()
.map(|rows| {
rows.iter()
.filter_map(|r| serde_json::from_value::<RowJson>(r.clone()).ok())
.collect::<Vec<_>>()
})
.unwrap_or_default()
})
}
});
view! {
{move || match rows.get() {
None => view! { <span class="hint">"loading the steps …"</span> }.into_any(),
Some(Some(rows)) if !rows.is_empty() => view! { <Timeline rows=rows/> }.into_any(),
Some(_) => view! { <span class="hint">"the steps could not be read"</span> }.into_any(),
}}
}
}
/// Where to send the customer for ONE open attempt, or nothing.
///
/// Deliberately quiet about every kind of absence: a vendor with no checkout
/// page (an invoice), a link the vendor has let expire, and a status call that
/// failed all render as an em dash rather than as an error — none of them is a
/// problem with the attempt, and a red box on this page for "this vendor has
/// no web page" would teach an operator to ignore red boxes.
#[component]
fn Checkout(reference: String) -> impl IntoView {
let link = LocalResource::new(move || {
let path = format!("/api/purchase/{}", reference);
async move {
crate::api::get::<serde_json::Value>(&path)
.await
.ok()
.and_then(|v| v["redirect_url"].as_str().map(str::to_owned))
.filter(|u| !u.is_empty())
}
});
view! {
// The outer Option is "has the resource resolved yet", the inner one is
// "did the vendor mint a URL at all" — flatten collapses both to the one
// question this view asks, and an unresolved resource renders the same
// dash as a vendor with no checkout page.
{move || match link.get().flatten() {
Some(url) => {
let href = url.clone();
// rel=noreferrer as well as noopener: a payment URL carries the
// reference, and the vendor need not learn our origin.
view! { <a href=href target="_blank" rel="noopener noreferrer" class="mono" style="font-size:.78rem">"Open checkout"</a> }.into_any()
}
None => view! { <span class="hint">"—"</span> }.into_any(),
}}
}
}