Skip to main content

max / makenotwork

14.7 KB · 416 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_declare::declare;
40 use quasi_router::screen::Tag;
41 use quasi_router::{Method, Request, Response, RouteError};
42 use quasi_webview::Webview;
43
44 use super::Viewer;
45 use crate::db::{self, ItemId, TransactionId};
46 use crate::types::SaleRow;
47
48 /// The region the answer replaces, keeping the id the tab strip draws.
49 pub const REGION: &str = "item-sales";
50
51 /// Where the write lives. A fixed prefix; the ids are in the inner path.
52 pub const NEST: &str = "/dashboard/described/item-sales";
53
54 /// Refunding one transaction, relative to [`NEST`].
55 const REFUND: &str = "/{item}/{transaction}";
56
57 /// The writes this panel serves. Registered under [`NEST`] by
58 /// [`super::writes_only`].
59 pub const WRITES: &[(Method, &str, super::Screen)] = &[(Method::Post, REFUND, refund)];
60
61 /// The panel wrapped in its region, for the tab route to answer with.
62 #[must_use]
63 pub fn fragment(item: ItemId, sales: &[SaleRow], export_route: &str) -> String {
64 use quasi_axum::Serves as _;
65
66 Webview::new().fragment(&pane(item, sales, export_route))
67 }
68
69 declare! {
70 /// The panel in its region.
71 ///
72 /// `body` is gone: it existed to build the contents as a `Vec<Node>` so the
73 /// empty case could return early, and a guard says that inline. Nothing it
74 /// drew has moved.
75 shape pane(item: ItemId, sales: &[SaleRow], export_route: &str) -> Node;
76
77 region REGION as Pane {
78 section "Sales";
79
80 empty "No sales yet. Sales will appear here once your first purchase is completed."
81 when sales.is_empty();
82
83 // The export is offered only when there is something to export, which
84 // is what the template said with `{% if !sales.is_empty() %}`.
85 include super::export_act::act(export_route, "item-sales.csv")
86 unless sales.is_empty();
87 include table(item, sales) unless sales.is_empty();
88 }
89 }
90
91 declare! {
92 /// The transaction history.
93 ///
94 /// The columns are declared here and the cells are built in [`row`], so the
95 /// cells name their columns rather than counting to them. Position would ask
96 /// a reader of either function to hold the other one in their head, and the
97 /// refund column at the end is the one that would move if this list ever
98 /// grew a heading in the middle.
99 shape table(item: ItemId, sales: &[SaleRow]) -> Node;
100
101 table {
102 column COL_DATE {
103 width Content;
104 priority Essential;
105 }
106 column COL_BUYER {
107 width Fill;
108 }
109 column COL_AMOUNT {
110 width Content;
111 }
112 column COL_STATUS {
113 width Content;
114 }
115 column COL_ACTS {
116 width Content;
117 }
118
119 for sale in sales.iter() {
120 include row(item, sale);
121 }
122 }
123 }
124
125 /// The headings, written once so the two halves cannot drift apart.
126 ///
127 /// A name no column has is dropped without a word, which is what makes a shared
128 /// constant worth more here than the literal. [`COL_ACTS`] is deliberately
129 /// empty: the Refund control needs no heading over it, and the empty string is
130 /// still the name its cell has to match.
131 const COL_DATE: &str = "Date";
132 const COL_BUYER: &str = "Buyer";
133 const COL_AMOUNT: &str = "Amount";
134 const COL_STATUS: &str = "Status";
135 const COL_ACTS: &str = "";
136
137 declare! {
138 /// One sale.
139 shape row(item: ItemId, sale: &SaleRow) -> Row;
140
141 cells {
142 cell at COL_DATE sale.date.clone();
143 cell at COL_BUYER sale.buyer.clone();
144 cell at COL_AMOUNT sale.amount_display.clone();
145 cell at COL_STATUS "" {
146 token badge(sale);
147 }
148 cell at COL_ACTS "" {
149 act "Refund"
150 to post "{NEST}/{item}/{sale.transaction_id}" awaiting
151 when sale.refundable
152 {
153 tone Danger;
154 confirm "Issue a full refund for {sale.amount_display}? This cannot be undone.";
155 }
156 }
157 }
158 }
159
160 /// The status badge, toned.
161 ///
162 /// A supplier because the tone is a mapping from a string the row carries, and
163 /// a mapping is what [`tone`] is.
164 fn badge(sale: &SaleRow) -> Tag {
165 Tag::badge(sale.status.clone()).tone(tone(sale.status_tone))
166 }
167
168 /// The badge tone the template set with `data-tone`.
169 fn tone(status_tone: &str) -> layout::Tone {
170 match status_tone {
171 "success" => layout::Tone::Success,
172 "warning" => layout::Tone::Warning,
173 "danger" => layout::Tone::Danger,
174 _ => layout::Tone::Neutral,
175 }
176 }
177
178 /// The rows this panel draws, built once for both callers.
179 ///
180 /// The Askama read and this module's write answer have to agree about what a
181 /// sale looks like, and two spellings of that mapping is how they stop
182 /// agreeing.
183 #[must_use]
184 pub fn rows(sales: &[db::DbTransaction]) -> Vec<SaleRow> {
185 sales
186 .iter()
187 .map(|tx| {
188 let buyer_display = tx
189 .guest_email
190 .clone()
191 .or_else(|| tx.buyer_id.map(|_| "Registered user".to_string()))
192 .unwrap_or_else(|| "Unknown".to_string());
193 let cents = tx.amount_cents.as_i64();
194 SaleRow {
195 transaction_id: tx.id.to_string(),
196 buyer: buyer_display,
197 amount_display: if cents == 0 {
198 "Free".to_string()
199 } else {
200 // The sale's own currency, not the viewer's: a refunded
201 // older sale can predate a settlement-currency change.
202 crate::formatting::format_revenue(cents, tx.currency())
203 },
204 status: tx.status.to_string(),
205 status_tone: tx.status.badge_status().tone(),
206 date: tx.created_at.format("%Y-%m-%d %H:%M").to_string(),
207 refundable: tx.status == db::TransactionStatus::Completed
208 && tx.stripe_payment_intent_id.is_some()
209 && cents > 0,
210 }
211 })
212 .collect()
213 }
214
215 /// Refund one transaction, and answer with the panel as it now stands.
216 pub fn refund(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
217 let captures = request.captures;
218
219 let item: ItemId = captures
220 .get("item")
221 .and_then(|id| id.parse::<uuid::Uuid>().ok())
222 .ok_or_else(|| RouteError::not_found("no such item"))?
223 .into();
224 let transaction: TransactionId = captures
225 .get("transaction")
226 .and_then(|id| id.parse::<uuid::Uuid>().ok())
227 .ok_or_else(|| RouteError::not_found("no such transaction"))?
228 .into();
229
230 // Every check and the atomic claim live in the core, which the API route
231 // calls too. Nothing about authorization is decided here.
232 viewer
233 .block_on(crate::payments::refund::refund(
234 &viewer.app.db,
235 viewer.app.payment_caps.refundable.as_ref(),
236 viewer.reader()?,
237 item,
238 transaction,
239 ))
240 .map_err(refused)?;
241
242 answer(viewer, item)
243 }
244
245 /// What the reader is told when the core refuses.
246 ///
247 /// The core answers in `AppError`, which is an HTTP status and a sentence; a
248 /// nest answers in `RouteError`, whose classes are `not_found`, `denied`,
249 /// `conflict` and `internal`. Mapped rather than flattened to `internal`,
250 /// because "this transaction is not refundable" and "the database is down" are
251 /// not the same thing to the person holding the button.
252 fn refused(error: crate::error::AppError) -> RouteError {
253 use crate::error::AppError;
254
255 match error {
256 AppError::NotFound => RouteError::not_found("no such transaction"),
257 AppError::Forbidden => RouteError::not_found("no such transaction"),
258 AppError::BadRequest(said) => RouteError::conflict(said),
259 AppError::ServiceUnavailable(said) => RouteError::conflict(said),
260 _ => RouteError::internal("that refund could not be issued"),
261 }
262 }
263
264 /// The panel as it now stands, for the write to answer with.
265 fn answer(viewer: &Viewer, item: ItemId) -> Result<Response, RouteError> {
266 let sales = viewer
267 .block_on(db::transactions::get_sales_by_item(
268 &viewer.app.db,
269 item,
270 viewer.reader()?.id,
271 ))
272 .map_err(|_| RouteError::internal("the sales could not be read"))?;
273
274 let rows = rows(&sales);
275 let export_route = format!("/api/export/items/{item}/sales");
276
277 Ok(Response::fragment(REGION, pane(item, &rows, &export_route)))
278 }
279
280 /// The renderer this panel's writes are drawn with.
281 pub fn renderer(viewer: &Viewer) -> Webview {
282 Webview::new().with_shell(viewer.shell())
283 }
284
285 #[cfg(test)]
286 mod tests {
287 use super::*;
288
289 const ITEM: &str = "00000000-0000-0000-0000-0000000000aa";
290 const TX: &str = "00000000-0000-0000-0000-0000000000bb";
291
292 fn item() -> ItemId {
293 ITEM.parse::<uuid::Uuid>().unwrap().into()
294 }
295
296 fn sale(refundable: bool) -> SaleRow {
297 SaleRow {
298 transaction_id: TX.into(),
299 buyer: "buyer@example.com".into(),
300 amount_display: "$9.99".into(),
301 status: "completed".into(),
302 status_tone: "success",
303 date: "2026-08-26 10:00".into(),
304 refundable,
305 }
306 }
307
308 fn render(sales: &[SaleRow]) -> String {
309 fragment(item(), sales, &format!("/api/export/items/{ITEM}/sales"))
310 }
311
312 #[test]
313 fn the_panel_carries_the_region_the_strip_draws() {
314 // `item_tabs` swaps this tab's answer into `item-sales`. If the answer
315 // named a different region it would land nowhere.
316 let html = render(&[sale(true)]);
317 assert!(html.contains(&format!("id=\"{REGION}\"")), "{html}");
318 }
319
320 #[test]
321 fn the_refund_addresses_the_nest_and_not_the_api_route() {
322 let html = render(&[sale(true)]);
323
324 assert!(
325 html.contains(&format!("hx-post=\"{NEST}/{ITEM}/{TX}\"")),
326 "{html}"
327 );
328 // The API route stays registered and is simply not what this panel
329 // calls any more.
330 assert!(!html.contains("/api/items/"), "{html}");
331 }
332
333 #[test]
334 fn nothing_here_goes_through_the_dispatcher() {
335 // The whole point of the conversion. This site was
336 // `data-after="refresh"` with the panel's address and target passed
337 // positionally, on top of a row-scoped `hx-target`/`hx-swap` pair.
338 let html = render(&[sale(true)]);
339
340 assert!(!html.contains("data-after"), "{html}");
341 assert!(!html.contains("data-arg"), "{html}");
342 assert!(!html.contains("data-action"), "{html}");
343 // The row swap is gone with it: the answer replaces the panel once
344 // rather than swapping a row that the webhook has not marked yet.
345 assert!(!html.contains("hx-swap=\"outerHTML\""), "{html}");
346 }
347
348 #[test]
349 fn a_sale_that_cannot_be_refunded_offers_no_button() {
350 let html = render(&[sale(false)]);
351 assert!(!html.contains("Refund"), "{html}");
352 assert!(!html.contains(NEST), "{html}");
353 }
354
355 #[test]
356 fn the_refund_still_asks_before_it_moves_money() {
357 let html = render(&[sale(true)]);
358 assert!(html.contains("This cannot be undone."), "{html}");
359 assert!(html.contains("$9.99"), "{html}");
360 }
361
362 #[test]
363 fn an_empty_panel_offers_no_export() {
364 // `{% if !sales.is_empty() %}` around the export control, kept: an
365 // export of nothing is a file the reader did not want.
366 let html = render(&[]);
367
368 assert!(html.contains("No sales yet."), "{html}");
369 assert!(!html.contains("Export CSV"), "{html}");
370 assert!(!html.contains("role=\"table\""), "{html}");
371 }
372
373 #[test]
374 fn a_populated_panel_offers_the_export() {
375 let html = render(&[sale(true)]);
376 assert!(html.contains("Export CSV"), "{html}");
377 assert!(
378 html.contains(&format!("/api/export/items/{ITEM}/sales")),
379 "{html}"
380 );
381 }
382
383 /// The five headings. `COL_ACTS` is deliberately empty, so the four that
384 /// read are what a dropped column would take with it.
385 #[test]
386 fn every_column_the_table_had_is_still_named() {
387 let html = render(&[sale(true)]);
388
389 for heading in [COL_DATE, COL_BUYER, COL_AMOUNT, COL_STATUS] {
390 assert!(html.contains(heading), "{heading} is gone from {html}");
391 }
392 }
393
394 /// The status badge's tone, which the template set with `data-tone` and a
395 /// supplier now maps. A badge that lost its tone reads as neutral and says
396 /// nothing about whether the sale went through.
397 #[test]
398 fn a_sale_status_keeps_the_tone_the_template_set() {
399 let mut refunded = sale(false);
400 refunded.status = "refunded".to_owned();
401 refunded.status_tone = "warning";
402
403 let html = render(&[refunded]);
404
405 assert!(html.contains("refunded"), "{html}");
406 assert!(html.contains(r#"data-tone="warning""#), "{html}");
407 }
408
409 #[test]
410 fn a_buyer_cannot_smuggle_markup() {
411 let mut hostile = sale(true);
412 hostile.buyer = "<script>x()</script>".into();
413 assert!(!render(&[hostile]).contains("<script>x()"));
414 }
415 }
416