Skip to main content

max / makenotwork

10.1 KB · 250 lines History Blame Raw
1 //! The Stripe payout-summary card, described.
2 //!
3 //! S4, and the first of the batch ordered by the tab inventory taken
4 //! 2026-08-26: 44 lines, no `hx-` attributes of its own, no id or class that
5 //! any file in `static/` or `frontend/src` reaches for, and no form. It is the
6 //! cleanest remaining conversion in the tree, which is why it goes first.
7 //!
8 //! It replaces `dashboard_tab_payout_summary`, which is deleted with its
9 //! `PayoutSummaryPartialTemplate` and `templates/partials/tabs/payout_summary.html`.
10 //! There is no switch and no fallback: since `64b33b26` a described screen owns
11 //! its address outright, so the Askama half goes in the same change.
12 //!
13 //! # It is a card the Payments tab fetches, not a tab of its own
14 //!
15 //! `templates/partials/tabs/user_payments.html:74` leaves an empty
16 //! `#payout-summary-section` and fills it on `revealed`, because the Stripe
17 //! balance is a network round trip and the tab should not wait for it (Run 11
18 //! Perf SER-2). That split is preserved exactly: this screen answers the same
19 //! address on the same trigger and replaces the same region. The lazy fetch is
20 //! the point of the split and not an artefact of Askama.
21 //!
22 //! That also makes it the one conversion in the batch where [`super::ssh_keys`]'s
23 //! "a list it can read now is a list the reader should not wait for twice"
24 //! does NOT apply. Reading the balance inline would put a Stripe round trip in
25 //! front of the Payments tab, which is the cost the split was made to avoid.
26 //!
27 //! # The three things the description says that the template did not
28 //!
29 //! 1. **The failure branch is a readiness, not a second card.** The template's
30 //! `{% else %}` hand-writes a near-copy of the whole card carrying "Unable
31 //! to load balance". That is [`layout::Readiness::Failed`], which every
32 //! renderer already draws, so it is [`Node::failed`] here and the copy goes.
33 //! 2. **The two balances are a strip.** `.payout-stats-grid` holding two
34 //! `.amount-big` divs with `.text-xs` captions is [`Node::stats`] over two
35 //! [`Figure`]s. The grid was the markup spelling of "these are tiles".
36 //! 3. **"Payouts are not yet enabled" is a banner.** The template gives it a
37 //! bespoke `.payouts-disabled-notice` class; it is a persistent message
38 //! dismissed by fixing its cause, which is [`layout::Notice::Banner`] at
39 //! [`layout::Tone::Warning`].
40 //!
41 //! # Two parity differences, both deliberate
42 //!
43 //! The parity test names each rather than blanket-ignoring them.
44 //!
45 //! - **`rel`.** The template writes `rel="noopener"`; quasi-webview emits
46 //! `rel="noopener noreferrer"` for every external destination. The stricter
47 //! one is right and it is not this screen's call to make either way.
48 //! - **The Stripe link is an anchor, not a button inside an anchor.** The
49 //! template nests `<button>` inside `<a>`, which is invalid: a button is
50 //! interactive content and an anchor may not contain it. Browsers recover,
51 //! so it renders; a described act that leaves is one anchor. Fixing it was
52 //! not this conversion's goal and is a consequence of saying the thing once.
53
54 use makeover_layout as layout;
55 use quasi_router::screen::Figure;
56 use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
57 use quasi_webview::Webview;
58
59 use super::Viewer;
60 use crate::db;
61
62 /// This screen's name, for the tab strips that mark a section described.
63 pub const SCREEN: &str = "payout_summary";
64
65 /// The address this screen answers, and the one the Askama route gives up.
66 pub const PATH: &str = "/dashboard/tabs/payout-summary";
67
68 /// The region the answer replaces: the div the Payments tab leaves empty.
69 ///
70 /// `pub` so `tests/workflows/described_screens.rs` reads it rather than
71 /// transcribing it. That file says why: a constant its table can name is a
72 /// constant its table cannot disagree with.
73 pub const REGION: &str = "payout-summary-section";
74
75 /// Where a creator manages payouts, which is not a page this server serves.
76 const STRIPE_PAYOUTS: &str = "https://dashboard.stripe.com/payouts";
77
78 /// The balance, as the screen needs it: two strings the app has already
79 /// formatted in the creator's settlement currency.
80 pub struct BalanceView {
81 available: String,
82 pending: String,
83 }
84
85 /// The card.
86 pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
87 let user = viewer
88 .block_on(db::users::get_user_by_id(&viewer.app.db, viewer.user.id))
89 .map_err(|_| RouteError::internal("your account could not be read"))?
90 .ok_or_else(|| RouteError::not_found("that account is gone"))?;
91
92 // Both `None` cases are the same screen: no Stripe account configured and a
93 // balance call that failed are indistinguishable to a reader, and the
94 // template already drew one card for both.
95 let balance = match (&viewer.app.stripe, &user.stripe_account_id) {
96 (Some(stripe), Some(account_id)) => {
97 match viewer.block_on(stripe.get_balance(account_id, user.settlement_currency)) {
98 Ok(balance) => Some(BalanceView {
99 available: crate::formatting::format_revenue(
100 balance.available_cents,
101 user.settlement_currency,
102 ),
103 pending: crate::formatting::format_revenue(
104 balance.pending_cents,
105 user.settlement_currency,
106 ),
107 }),
108 Err(e) => {
109 // Warned rather than surfaced, as the Askama handler did:
110 // a creator cannot act on a Stripe API error and the card
111 // says so in words they can.
112 tracing::warn!(error = ?e, "failed to fetch Stripe balance for payout summary");
113 None
114 }
115 }
116 }
117 _ => None,
118 };
119
120 Ok(Response::fragment(
121 REGION,
122 card(balance.as_ref(), user.stripe_payouts_enabled),
123 ))
124 }
125
126 /// Everything inside the card.
127 fn card(balance: Option<&BalanceView>, payouts_enabled: bool) -> Node {
128 // `Subsection`, so the heading stays an `h3`: this card sits inside the
129 // Payments tab's own sections and the template wrote `<h3 class="card-h">`.
130 let slot = Slot::new(REGION, RegionKind::Pane)
131 .with(Node::Heading {
132 level: layout::Heading::Subsection,
133 text: "Payout Summary".into(),
134 })
135 .with(stripe_link());
136
137 let Some(balance) = balance else {
138 return Node::Region(slot.with(Node::failed(
139 "Unable to load balance. Check your Stripe dashboard for details.",
140 )));
141 };
142
143 let slot = slot
144 .with(Node::text(
145 "Payouts are processed automatically via Stripe Connect.",
146 ))
147 .with(Node::stats([
148 Figure::new(balance.available.clone(), "Available Balance"),
149 Figure::new(balance.pending.clone(), "Pending"),
150 ]));
151
152 Node::Region(if payouts_enabled {
153 slot
154 } else {
155 slot.with(Node::banner(
156 layout::Tone::Warning,
157 "Payouts are not yet enabled. Complete your Stripe account setup above.",
158 ))
159 })
160 }
161
162 /// The way out to Stripe's own dashboard.
163 ///
164 /// [`Action::external`] rather than a route: this address is not one the server
165 /// answers, and saying so is what lets a renderer that is not a browser decide
166 /// what "leaving" means for it.
167 fn stripe_link() -> Node {
168 Node::act("View in Stripe", Action::external(STRIPE_PAYOUTS))
169 }
170
171 /// The renderer this screen is drawn with.
172 pub fn renderer(viewer: &Viewer) -> Webview {
173 Webview::new().with_shell(viewer.shell())
174 }
175
176 #[cfg(test)]
177 mod tests {
178 use super::*;
179 use quasi_axum::Serves;
180
181 fn balance() -> BalanceView {
182 BalanceView {
183 available: "$1,240.00".into(),
184 pending: "$310.50".into(),
185 }
186 }
187
188 fn render(node: &Node) -> String {
189 Webview::new().fragment(node)
190 }
191
192 #[test]
193 fn the_region_is_the_one_the_payments_tab_leaves_empty() {
194 // The tab fills this on `revealed`. If the id or the address ever
195 // disagrees, the card loads into nothing and nothing else notices.
196 let payments = include_str!("../../templates/partials/tabs/user_payments.html");
197 assert!(payments.contains(&format!("id=\"{REGION}\"")), "{REGION}");
198 assert!(payments.contains(&format!("hx-get=\"{PATH}\"")), "{PATH}");
199 }
200
201 #[test]
202 fn the_balances_are_a_strip_of_two_figures() {
203 let html = render(&card(Some(&balance()), true));
204
205 assert!(html.contains("$1,240.00"), "{html}");
206 assert!(html.contains("Available Balance"), "{html}");
207 assert!(html.contains("$310.50"), "{html}");
208 assert!(html.contains("Pending"), "{html}");
209 }
210
211 #[test]
212 fn a_creator_without_payouts_enabled_is_told_so() {
213 let html = render(&card(Some(&balance()), false));
214 assert!(html.contains("Payouts are not yet enabled."), "{html}");
215 }
216
217 #[test]
218 fn a_creator_with_payouts_enabled_is_not() {
219 let html = render(&card(Some(&balance()), true));
220 assert!(!html.contains("Payouts are not yet enabled."), "{html}");
221 }
222
223 #[test]
224 fn a_balance_that_did_not_load_is_a_readiness_and_not_a_second_card() {
225 let html = render(&card(None, true));
226
227 assert!(html.contains("Unable to load balance."), "{html}");
228 // The figures are the thing that is missing. A strip of two blanks
229 // would be the template's failure mode, not this one's.
230 assert!(!html.contains("Available Balance"), "{html}");
231 // And the way out to Stripe survives the failure, which is the whole
232 // reason the template drew a second card rather than nothing.
233 assert!(html.contains(STRIPE_PAYOUTS), "{html}");
234 }
235
236 #[test]
237 fn the_stripe_link_leaves_and_carries_no_verb() {
238 let html = render(&stripe_link());
239
240 assert!(
241 html.contains(&format!("href=\"{STRIPE_PAYOUTS}\"")),
242 "{html}"
243 );
244 assert!(html.contains("target=\"_blank\""), "{html}");
245 assert!(html.contains("rel=\"noopener noreferrer\""), "{html}");
246 // An external address is not htmx's business.
247 assert!(!html.contains("hx-get"), "{html}");
248 }
249 }
250