Skip to main content

max / makenotwork

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