Skip to main content

max / makenotwork

12.9 KB · 364 lines History Blame Raw
1 //! The item dashboard's Sales panel, described.
2 //!
3 //! The second panel on the writes-only nest (`03c0977b`), and the tab
4 //! `b25dd957` was filed for. Its export half shipped first (`1ea96868`); this
5 //! is the rest.
6 //!
7 //! # A fill, and the reason is the address
8 //!
9 //! `item_tab_sales` answers `/dashboard/item/{id}/tabs/sales`. A nest mounts at
10 //! a fixed prefix, so a parameterized address cannot be a mounted screen and
11 //! this panel's **read** stays on its Askama handler, exactly as
12 //! [`super::project_analytics`] does. See [`super::writes_only`].
13 //!
14 //! # What the Refund button was, and what it is
15 //!
16 //! One control, addressing `POST /api/items/{id}/refund` with the transaction
17 //! in `hx-vals`, targeting its own row with `outerHTML`, and then carrying
18 //! `data-after="refresh"` with the panel's address and target passed
19 //! positionally in `data-arg` and `data-arg2` -- because an API route answers
20 //! `{"ok": true}` and cannot name the region it changed.
21 //!
22 //! So the markup did both a row swap and a whole-panel refresh, in that order:
23 //! the row came back stale (the `refund.created` webhook is what marks the
24 //! transaction, and it has not arrived), and the refresh then replaced the
25 //! panel underneath it. A described answer replaces the panel once, so the
26 //! flicker and the stale row both stop being a thing.
27 //!
28 //! # The money path is not reimplemented here
29 //!
30 //! [`crate::payments::refund::refund`] holds every check and the atomic
31 //! `completed -> refunding` claim, and both this panel and the API route call
32 //! it. Converting a tab is not a licence to touch a refund, which is why the
33 //! extraction was its own task rather than a step in this one.
34 //!
35 //! The API route stays registered for API consumers, the same way
36 //! [`super::project_members`] left `/api/projects/{id}/members` alone.
37
38 use makeover_layout as layout;
39 use quasi_router::screen::{Act, Cell, Cells, Column, Tag};
40 use quasi_router::{Method, Node, RegionKind, Request, Response, RouteError, Slot};
41 use quasi_webview::Webview;
42
43 use super::Viewer;
44 use crate::db::{self, ItemId, TransactionId};
45 use crate::types::SaleRow;
46
47 /// The region the answer replaces, keeping the id the tab strip draws.
48 pub const REGION: &str = "item-sales";
49
50 /// Where the write lives. A fixed prefix; the ids are in the inner path.
51 pub const NEST: &str = "/dashboard/described/item-sales";
52
53 /// Refunding one transaction, relative to [`NEST`].
54 const REFUND: &str = "/{item}/{transaction}";
55
56 /// The writes this panel serves. Registered under [`NEST`] by
57 /// [`super::writes_only`].
58 pub const WRITES: &[(Method, &str, super::Screen)] = &[(Method::Post, REFUND, refund)];
59
60 /// The panel wrapped in its region, for the tab route to answer with.
61 #[must_use]
62 pub fn fragment(item: ItemId, sales: &[SaleRow], export_route: &str) -> String {
63 use quasi_axum::Serves as _;
64
65 Webview::new().fragment(&pane(item, sales, export_route))
66 }
67
68 /// The panel in its region.
69 fn pane(item: ItemId, sales: &[SaleRow], export_route: &str) -> Node {
70 let mut slot = Slot::new(REGION, RegionKind::Pane);
71 for node in body(item, sales, export_route) {
72 slot = slot.with(node);
73 }
74 Node::Region(slot)
75 }
76
77 /// The panel's contents, in order.
78 fn body(item: ItemId, sales: &[SaleRow], export_route: &str) -> Vec<Node> {
79 let mut out = vec![Node::section("Sales")];
80
81 if sales.is_empty() {
82 out.push(Node::empty(
83 "No sales yet. Sales will appear here once your first purchase is completed.",
84 ));
85 return out;
86 }
87
88 // The export is offered only when there is something to export, which is
89 // what the template said with `{% if !sales.is_empty() %}`.
90 out.push(super::export_act::act(export_route, "item-sales.csv"));
91 out.push(table(item, sales));
92 out
93 }
94
95 /// The transaction history.
96 fn table(item: ItemId, sales: &[SaleRow]) -> Node {
97 Node::Table {
98 columns: vec![
99 Column::new("Date")
100 .width(layout::Width::Content)
101 .priority(layout::Priority::Essential),
102 Column::new("Buyer").width(layout::Width::Fill),
103 Column::new("Amount").width(layout::Width::Content),
104 Column::new("Status").width(layout::Width::Content),
105 Column::new("").width(layout::Width::Content),
106 ],
107 rows: sales.iter().map(|sale| row(item, sale)).collect(),
108 more: None,
109 }
110 }
111
112 /// One sale.
113 fn row(item: ItemId, sale: &SaleRow) -> Cells {
114 let mut status = Tag::badge(sale.status.clone());
115 status.tone = tone(sale.status_tone);
116
117 let mut action = Cell::new(String::new());
118 if sale.refundable {
119 action = action.act(
120 Act::new(
121 "Refund",
122 quasi_router::Action::post(format!("{NEST}/{item}/{}", sale.transaction_id))
123 .awaiting(),
124 )
125 .tone(layout::Tone::Danger)
126 .confirm(format!(
127 "Issue a full refund for {}? This cannot be undone.",
128 sale.amount_display
129 )),
130 );
131 }
132
133 Cells::new([
134 Cell::new(sale.date.clone()),
135 Cell::new(sale.buyer.clone()),
136 Cell::new(sale.amount_display.clone()),
137 Cell::new(String::new()).token(status),
138 action,
139 ])
140 }
141
142 /// The badge tone the template set with `data-tone`.
143 fn tone(status_tone: &str) -> layout::Tone {
144 match status_tone {
145 "success" => layout::Tone::Success,
146 "warning" => layout::Tone::Warning,
147 "danger" => layout::Tone::Danger,
148 _ => layout::Tone::Neutral,
149 }
150 }
151
152 /// The rows this panel draws, built once for both callers.
153 ///
154 /// The Askama read and this module's write answer have to agree about what a
155 /// sale looks like, and two spellings of that mapping is how they stop
156 /// agreeing.
157 #[must_use]
158 pub fn rows(sales: &[db::DbTransaction]) -> Vec<SaleRow> {
159 sales
160 .iter()
161 .map(|tx| {
162 let buyer_display = tx
163 .guest_email
164 .clone()
165 .or_else(|| tx.buyer_id.map(|_| "Registered user".to_string()))
166 .unwrap_or_else(|| "Unknown".to_string());
167 let cents = tx.amount_cents.as_i64();
168 SaleRow {
169 transaction_id: tx.id.to_string(),
170 buyer: buyer_display,
171 amount_display: if cents == 0 {
172 "Free".to_string()
173 } else {
174 // The sale's own currency, not the viewer's: a refunded
175 // older sale can predate a settlement-currency change.
176 crate::formatting::format_revenue(cents, tx.currency())
177 },
178 status: tx.status.to_string(),
179 status_tone: tx.status.badge_status().tone(),
180 date: tx.created_at.format("%Y-%m-%d %H:%M").to_string(),
181 refundable: tx.status == db::TransactionStatus::Completed
182 && tx.stripe_payment_intent_id.is_some()
183 && cents > 0,
184 }
185 })
186 .collect()
187 }
188
189 /// Refund one transaction, and answer with the panel as it now stands.
190 pub fn refund(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
191 let captures = request.captures;
192
193 let item: ItemId = captures
194 .get("item")
195 .and_then(|id| id.parse::<uuid::Uuid>().ok())
196 .ok_or_else(|| RouteError::not_found("no such item"))?
197 .into();
198 let transaction: TransactionId = captures
199 .get("transaction")
200 .and_then(|id| id.parse::<uuid::Uuid>().ok())
201 .ok_or_else(|| RouteError::not_found("no such transaction"))?
202 .into();
203
204 // Every check and the atomic claim live in the core, which the API route
205 // calls too. Nothing about authorization is decided here.
206 viewer
207 .block_on(crate::payments::refund::refund(
208 &viewer.app.db,
209 viewer.app.payment_caps.refundable.as_ref(),
210 viewer.reader()?,
211 item,
212 transaction,
213 ))
214 .map_err(refused)?;
215
216 answer(viewer, item)
217 }
218
219 /// What the reader is told when the core refuses.
220 ///
221 /// The core answers in `AppError`, which is an HTTP status and a sentence; a
222 /// nest answers in `RouteError`, whose classes are `not_found`, `denied`,
223 /// `conflict` and `internal`. Mapped rather than flattened to `internal`,
224 /// because "this transaction is not refundable" and "the database is down" are
225 /// not the same thing to the person holding the button.
226 fn refused(error: crate::error::AppError) -> RouteError {
227 use crate::error::AppError;
228
229 match error {
230 AppError::NotFound => RouteError::not_found("no such transaction"),
231 AppError::Forbidden => RouteError::not_found("no such transaction"),
232 AppError::BadRequest(said) => RouteError::conflict(said),
233 AppError::ServiceUnavailable(said) => RouteError::conflict(said),
234 _ => RouteError::internal("that refund could not be issued"),
235 }
236 }
237
238 /// The panel as it now stands, for the write to answer with.
239 fn answer(viewer: &Viewer, item: ItemId) -> Result<Response, RouteError> {
240 let sales = viewer
241 .block_on(db::transactions::get_sales_by_item(
242 &viewer.app.db,
243 item,
244 viewer.reader()?.id,
245 ))
246 .map_err(|_| RouteError::internal("the sales could not be read"))?;
247
248 let rows = rows(&sales);
249 let export_route = format!("/api/export/items/{item}/sales");
250
251 Ok(Response::fragment(REGION, pane(item, &rows, &export_route)))
252 }
253
254 /// The renderer this panel's writes are drawn with.
255 pub fn renderer(viewer: &Viewer) -> Webview {
256 Webview::new().with_shell(viewer.shell())
257 }
258
259 #[cfg(test)]
260 mod tests {
261 use super::*;
262
263 const ITEM: &str = "00000000-0000-0000-0000-0000000000aa";
264 const TX: &str = "00000000-0000-0000-0000-0000000000bb";
265
266 fn item() -> ItemId {
267 ITEM.parse::<uuid::Uuid>().unwrap().into()
268 }
269
270 fn sale(refundable: bool) -> SaleRow {
271 SaleRow {
272 transaction_id: TX.into(),
273 buyer: "buyer@example.com".into(),
274 amount_display: "$9.99".into(),
275 status: "completed".into(),
276 status_tone: "success",
277 date: "2026-08-26 10:00".into(),
278 refundable,
279 }
280 }
281
282 fn render(sales: &[SaleRow]) -> String {
283 fragment(item(), sales, &format!("/api/export/items/{ITEM}/sales"))
284 }
285
286 #[test]
287 fn the_panel_carries_the_region_the_strip_draws() {
288 // `item_tabs` swaps this tab's answer into `item-sales`. If the answer
289 // named a different region it would land nowhere.
290 let html = render(&[sale(true)]);
291 assert!(html.contains(&format!("id=\"{REGION}\"")), "{html}");
292 }
293
294 #[test]
295 fn the_refund_addresses_the_nest_and_not_the_api_route() {
296 let html = render(&[sale(true)]);
297
298 assert!(
299 html.contains(&format!("hx-post=\"{NEST}/{ITEM}/{TX}\"")),
300 "{html}"
301 );
302 // The API route stays registered and is simply not what this panel
303 // calls any more.
304 assert!(!html.contains("/api/items/"), "{html}");
305 }
306
307 #[test]
308 fn nothing_here_goes_through_the_dispatcher() {
309 // The whole point of the conversion. This site was
310 // `data-after="refresh"` with the panel's address and target passed
311 // positionally, on top of a row-scoped `hx-target`/`hx-swap` pair.
312 let html = render(&[sale(true)]);
313
314 assert!(!html.contains("data-after"), "{html}");
315 assert!(!html.contains("data-arg"), "{html}");
316 assert!(!html.contains("data-action"), "{html}");
317 // The row swap is gone with it: the answer replaces the panel once
318 // rather than swapping a row that the webhook has not marked yet.
319 assert!(!html.contains("hx-swap=\"outerHTML\""), "{html}");
320 }
321
322 #[test]
323 fn a_sale_that_cannot_be_refunded_offers_no_button() {
324 let html = render(&[sale(false)]);
325 assert!(!html.contains("Refund"), "{html}");
326 assert!(!html.contains(NEST), "{html}");
327 }
328
329 #[test]
330 fn the_refund_still_asks_before_it_moves_money() {
331 let html = render(&[sale(true)]);
332 assert!(html.contains("This cannot be undone."), "{html}");
333 assert!(html.contains("$9.99"), "{html}");
334 }
335
336 #[test]
337 fn an_empty_panel_offers_no_export() {
338 // `{% if !sales.is_empty() %}` around the export control, kept: an
339 // export of nothing is a file the reader did not want.
340 let html = render(&[]);
341
342 assert!(html.contains("No sales yet."), "{html}");
343 assert!(!html.contains("Export CSV"), "{html}");
344 assert!(!html.contains("role=\"table\""), "{html}");
345 }
346
347 #[test]
348 fn a_populated_panel_offers_the_export() {
349 let html = render(&[sale(true)]);
350 assert!(html.contains("Export CSV"), "{html}");
351 assert!(
352 html.contains(&format!("/api/export/items/{ITEM}/sales")),
353 "{html}"
354 );
355 }
356
357 #[test]
358 fn a_buyer_cannot_smuggle_markup() {
359 let mut hostile = sale(true);
360 hostile.buyer = "<script>x()</script>".into();
361 assert!(!render(&[hostile]).contains("<script>x()"));
362 }
363 }
364