//! The item dashboard's Sales panel, described. //! //! The second panel on the writes-only nest (`03c0977b`), and the tab //! `b25dd957` was filed for. Its export half shipped first (`1ea96868`); this //! is the rest. //! //! # A fill, and the reason is the address //! //! `item_tab_sales` answers `/dashboard/item/{id}/tabs/sales`. A nest mounts at //! a fixed prefix, so a parameterized address cannot be a mounted screen and //! this panel's **read** stays on its Askama handler, exactly as //! [`super::project_analytics`] does. See [`super::writes_only`]. //! //! # What the Refund button was, and what it is //! //! One control, addressing `POST /api/items/{id}/refund` with the transaction //! in `hx-vals`, targeting its own row with `outerHTML`, and then carrying //! `data-after="refresh"` with the panel's address and target passed //! positionally in `data-arg` and `data-arg2` -- because an API route answers //! `{"ok": true}` and cannot name the region it changed. //! //! So the markup did both a row swap and a whole-panel refresh, in that order: //! the row came back stale (the `refund.created` webhook is what marks the //! transaction, and it has not arrived), and the refresh then replaced the //! panel underneath it. A described answer replaces the panel once, so the //! flicker and the stale row both stop being a thing. //! //! # The money path is not reimplemented here //! //! [`crate::payments::refund::refund`] holds every check and the atomic //! `completed -> refunding` claim, and both this panel and the API route call //! it. Converting a tab is not a licence to touch a refund, which is why the //! extraction was its own task rather than a step in this one. //! //! The API route stays registered for API consumers, the same way //! [`super::project_members`] left `/api/projects/{id}/members` alone. use makeover_layout as layout; use quasi_router::screen::{Act, Cell, Cells, Column, Tag}; use quasi_router::{Method, Node, RegionKind, Request, Response, RouteError, Slot}; use quasi_webview::Webview; use super::Viewer; use crate::db::{self, ItemId, TransactionId}; use crate::types::SaleRow; /// The region the answer replaces, keeping the id the tab strip draws. pub const REGION: &str = "item-sales"; /// Where the write lives. A fixed prefix; the ids are in the inner path. pub const NEST: &str = "/dashboard/described/item-sales"; /// Refunding one transaction, relative to [`NEST`]. const REFUND: &str = "/{item}/{transaction}"; /// The writes this panel serves. Registered under [`NEST`] by /// [`super::writes_only`]. pub const WRITES: &[(Method, &str, super::Screen)] = &[(Method::Post, REFUND, refund)]; /// The panel wrapped in its region, for the tab route to answer with. #[must_use] pub fn fragment(item: ItemId, sales: &[SaleRow], export_route: &str) -> String { use quasi_axum::Serves as _; Webview::new().fragment(&pane(item, sales, export_route)) } /// The panel in its region. fn pane(item: ItemId, sales: &[SaleRow], export_route: &str) -> Node { let mut slot = Slot::new(REGION, RegionKind::Pane); for node in body(item, sales, export_route) { slot = slot.with(node); } Node::Region(slot) } /// The panel's contents, in order. fn body(item: ItemId, sales: &[SaleRow], export_route: &str) -> Vec { let mut out = vec![Node::section("Sales")]; if sales.is_empty() { out.push(Node::empty( "No sales yet. Sales will appear here once your first purchase is completed.", )); return out; } // The export is offered only when there is something to export, which is // what the template said with `{% if !sales.is_empty() %}`. out.push(super::export_act::act(export_route, "item-sales.csv")); out.push(table(item, sales)); out } /// The transaction history. fn table(item: ItemId, sales: &[SaleRow]) -> Node { Node::Table { columns: vec![ Column::new("Date") .width(layout::Width::Content) .priority(layout::Priority::Essential), Column::new("Buyer").width(layout::Width::Fill), Column::new("Amount").width(layout::Width::Content), Column::new("Status").width(layout::Width::Content), Column::new("").width(layout::Width::Content), ], rows: sales.iter().map(|sale| row(item, sale)).collect(), more: None, } } /// One sale. fn row(item: ItemId, sale: &SaleRow) -> Cells { let mut status = Tag::badge(sale.status.clone()); status.tone = tone(sale.status_tone); let mut action = Cell::new(String::new()); if sale.refundable { action = action.act( Act::new( "Refund", quasi_router::Action::post(format!("{NEST}/{item}/{}", sale.transaction_id)) .awaiting(), ) .tone(layout::Tone::Danger) .confirm(format!( "Issue a full refund for {}? This cannot be undone.", sale.amount_display )), ); } Cells::new([ Cell::new(sale.date.clone()), Cell::new(sale.buyer.clone()), Cell::new(sale.amount_display.clone()), Cell::new(String::new()).token(status), action, ]) } /// The badge tone the template set with `data-tone`. fn tone(status_tone: &str) -> layout::Tone { match status_tone { "success" => layout::Tone::Success, "warning" => layout::Tone::Warning, "danger" => layout::Tone::Danger, _ => layout::Tone::Neutral, } } /// The rows this panel draws, built once for both callers. /// /// The Askama read and this module's write answer have to agree about what a /// sale looks like, and two spellings of that mapping is how they stop /// agreeing. #[must_use] pub fn rows(sales: &[db::DbTransaction]) -> Vec { sales .iter() .map(|tx| { let buyer_display = tx .guest_email .clone() .or_else(|| tx.buyer_id.map(|_| "Registered user".to_string())) .unwrap_or_else(|| "Unknown".to_string()); let cents = tx.amount_cents.as_i64(); SaleRow { transaction_id: tx.id.to_string(), buyer: buyer_display, amount_display: if cents == 0 { "Free".to_string() } else { // The sale's own currency, not the viewer's: a refunded // older sale can predate a settlement-currency change. crate::formatting::format_revenue(cents, tx.currency()) }, status: tx.status.to_string(), status_tone: tx.status.badge_status().tone(), date: tx.created_at.format("%Y-%m-%d %H:%M").to_string(), refundable: tx.status == db::TransactionStatus::Completed && tx.stripe_payment_intent_id.is_some() && cents > 0, } }) .collect() } /// Refund one transaction, and answer with the panel as it now stands. pub fn refund(viewer: &Viewer, request: Request) -> Result { let captures = request.captures; let item: ItemId = captures .get("item") .and_then(|id| id.parse::().ok()) .ok_or_else(|| RouteError::not_found("no such item"))? .into(); let transaction: TransactionId = captures .get("transaction") .and_then(|id| id.parse::().ok()) .ok_or_else(|| RouteError::not_found("no such transaction"))? .into(); // Every check and the atomic claim live in the core, which the API route // calls too. Nothing about authorization is decided here. viewer .block_on(crate::payments::refund::refund( &viewer.app.db, viewer.app.stripe.as_ref(), &viewer.user, item, transaction, )) .map_err(refused)?; answer(viewer, item) } /// What the reader is told when the core refuses. /// /// The core answers in `AppError`, which is an HTTP status and a sentence; a /// nest answers in `RouteError`, whose classes are `not_found`, `denied`, /// `conflict` and `internal`. Mapped rather than flattened to `internal`, /// because "this transaction is not refundable" and "the database is down" are /// not the same thing to the person holding the button. fn refused(error: crate::error::AppError) -> RouteError { use crate::error::AppError; match error { AppError::NotFound => RouteError::not_found("no such transaction"), AppError::Forbidden => RouteError::not_found("no such transaction"), AppError::BadRequest(said) => RouteError::conflict(said), AppError::ServiceUnavailable(said) => RouteError::conflict(said), _ => RouteError::internal("that refund could not be issued"), } } /// The panel as it now stands, for the write to answer with. fn answer(viewer: &Viewer, item: ItemId) -> Result { let sales = viewer .block_on(db::transactions::get_sales_by_item( &viewer.app.db, item, viewer.user.id, )) .map_err(|_| RouteError::internal("the sales could not be read"))?; let rows = rows(&sales); let export_route = format!("/api/export/items/{item}/sales"); Ok(Response::fragment(REGION, pane(item, &rows, &export_route))) } /// The renderer this panel's writes are drawn with. pub fn renderer(viewer: &Viewer) -> Webview { Webview::new().with_shell(viewer.shell()) } #[cfg(test)] mod tests { use super::*; const ITEM: &str = "00000000-0000-0000-0000-0000000000aa"; const TX: &str = "00000000-0000-0000-0000-0000000000bb"; fn item() -> ItemId { ITEM.parse::().unwrap().into() } fn sale(refundable: bool) -> SaleRow { SaleRow { transaction_id: TX.into(), buyer: "buyer@example.com".into(), amount_display: "$9.99".into(), status: "completed".into(), status_tone: "success", date: "2026-08-26 10:00".into(), refundable, } } fn render(sales: &[SaleRow]) -> String { fragment(item(), sales, &format!("/api/export/items/{ITEM}/sales")) } #[test] fn the_panel_carries_the_region_the_strip_draws() { // `item_tabs` swaps this tab's answer into `item-sales`. If the answer // named a different region it would land nowhere. let html = render(&[sale(true)]); assert!(html.contains(&format!("id=\"{REGION}\"")), "{html}"); } #[test] fn the_refund_addresses_the_nest_and_not_the_api_route() { let html = render(&[sale(true)]); assert!( html.contains(&format!("hx-post=\"{NEST}/{ITEM}/{TX}\"")), "{html}" ); // The API route stays registered and is simply not what this panel // calls any more. assert!(!html.contains("/api/items/"), "{html}"); } #[test] fn nothing_here_goes_through_the_dispatcher() { // The whole point of the conversion. This site was // `data-after="refresh"` with the panel's address and target passed // positionally, on top of a row-scoped `hx-target`/`hx-swap` pair. let html = render(&[sale(true)]); assert!(!html.contains("data-after"), "{html}"); assert!(!html.contains("data-arg"), "{html}"); assert!(!html.contains("data-action"), "{html}"); // The row swap is gone with it: the answer replaces the panel once // rather than swapping a row that the webhook has not marked yet. assert!(!html.contains("hx-swap=\"outerHTML\""), "{html}"); } #[test] fn a_sale_that_cannot_be_refunded_offers_no_button() { let html = render(&[sale(false)]); assert!(!html.contains("Refund"), "{html}"); assert!(!html.contains(NEST), "{html}"); } #[test] fn the_refund_still_asks_before_it_moves_money() { let html = render(&[sale(true)]); assert!(html.contains("This cannot be undone."), "{html}"); assert!(html.contains("$9.99"), "{html}"); } #[test] fn an_empty_panel_offers_no_export() { // `{% if !sales.is_empty() %}` around the export control, kept: an // export of nothing is a file the reader did not want. let html = render(&[]); assert!(html.contains("No sales yet."), "{html}"); assert!(!html.contains("Export CSV"), "{html}"); assert!(!html.contains("role=\"table\""), "{html}"); } #[test] fn a_populated_panel_offers_the_export() { let html = render(&[sale(true)]); assert!(html.contains("Export CSV"), "{html}"); assert!( html.contains(&format!("/api/export/items/{ITEM}/sales")), "{html}" ); } #[test] fn a_buyer_cannot_smuggle_markup() { let mut hostile = sale(true); hostile.buyer = "".into(); assert!(!render(&[hostile]).contains("