Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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