//! 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_declare::declare; use quasi_router::screen::Tag; use quasi_router::{Method, Request, Response, RouteError}; 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)) } declare! { /// The panel in its region. /// /// `body` is gone: it existed to build the contents as a `Vec` so the /// empty case could return early, and a guard says that inline. Nothing it /// drew has moved. shape pane(item: ItemId, sales: &[SaleRow], export_route: &str) -> Node; region REGION as Pane { section "Sales"; empty "No sales yet. Sales will appear here once your first purchase is completed." when sales.is_empty(); // The export is offered only when there is something to export, which // is what the template said with `{% if !sales.is_empty() %}`. include super::export_act::act(export_route, "item-sales.csv") unless sales.is_empty(); include table(item, sales) unless sales.is_empty(); } } declare! { /// The transaction history. /// /// The columns are declared here and the cells are built in [`row`], so the /// cells name their columns rather than counting to them. Position would ask /// a reader of either function to hold the other one in their head, and the /// refund column at the end is the one that would move if this list ever /// grew a heading in the middle. shape table(item: ItemId, sales: &[SaleRow]) -> Node; table { column COL_DATE { width Content; priority Essential; } column COL_BUYER { width Fill; } column COL_AMOUNT { width Content; } column COL_STATUS { width Content; } column COL_ACTS { width Content; } for sale in sales.iter() { include row(item, sale); } } } /// The headings, written once so the two halves cannot drift apart. /// /// A name no column has is dropped without a word, which is what makes a shared /// constant worth more here than the literal. [`COL_ACTS`] is deliberately /// empty: the Refund control needs no heading over it, and the empty string is /// still the name its cell has to match. const COL_DATE: &str = "Date"; const COL_BUYER: &str = "Buyer"; const COL_AMOUNT: &str = "Amount"; const COL_STATUS: &str = "Status"; const COL_ACTS: &str = ""; declare! { /// One sale. shape row(item: ItemId, sale: &SaleRow) -> Row; cells { cell at COL_DATE sale.date.clone(); cell at COL_BUYER sale.buyer.clone(); cell at COL_AMOUNT sale.amount_display.clone(); cell at COL_STATUS "" { token badge(sale); } cell at COL_ACTS "" { act "Refund" to post "{NEST}/{item}/{sale.transaction_id}" awaiting when sale.refundable { tone Danger; confirm "Issue a full refund for {sale.amount_display}? This cannot be undone."; } } } } /// The status badge, toned. /// /// A supplier because the tone is a mapping from a string the row carries, and /// a mapping is what [`tone`] is. fn badge(sale: &SaleRow) -> Tag { Tag::badge(sale.status.clone()).tone(tone(sale.status_tone)) } /// 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.payment_caps.refundable.as_ref(), viewer.reader()?, 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.reader()?.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}" ); } /// The five headings. `COL_ACTS` is deliberately empty, so the four that /// read are what a dropped column would take with it. #[test] fn every_column_the_table_had_is_still_named() { let html = render(&[sale(true)]); for heading in [COL_DATE, COL_BUYER, COL_AMOUNT, COL_STATUS] { assert!(html.contains(heading), "{heading} is gone from {html}"); } } /// The status badge's tone, which the template set with `data-tone` and a /// supplier now maps. A badge that lost its tone reads as neutral and says /// nothing about whether the sale went through. #[test] fn a_sale_status_keeps_the_tone_the_template_set() { let mut refunded = sale(false); refunded.status = "refunded".to_owned(); refunded.status_tone = "warning"; let html = render(&[refunded]); assert!(html.contains("refunded"), "{html}"); assert!(html.contains(r#"data-tone="warning""#), "{html}"); } #[test] fn a_buyer_cannot_smuggle_markup() { let mut hostile = sale(true); hostile.buyer = "".into(); assert!(!render(&[hostile]).contains("