Skip to main content

max / makenotwork

10.0 KB · 251 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(
87 &viewer.app.db,
88 viewer.reader()?.id,
89 ))
90 .map_err(|_| RouteError::internal("your account could not be read"))?
91 .ok_or_else(|| RouteError::not_found("that account is gone"))?;
92
93 // Both `None` cases are the same screen: no Stripe account configured and a
94 // balance call that failed are indistinguishable to a reader, and the
95 // template already drew one card for both.
96 let balance = match (&viewer.app.payments, &user.stripe_account_id) {
97 (Some(stripe), Some(account_id)) => {
98 match viewer.block_on(stripe.get_balance(account_id, user.settlement_currency)) {
99 Ok(balance) => Some(BalanceView {
100 available: crate::formatting::format_revenue(
101 balance.available_cents,
102 user.settlement_currency,
103 ),
104 pending: crate::formatting::format_revenue(
105 balance.pending_cents,
106 user.settlement_currency,
107 ),
108 }),
109 Err(e) => {
110 // Warned rather than surfaced, as the Askama handler did:
111 // a creator cannot act on a Stripe API error and the card
112 // says so in words they can.
113 tracing::warn!(error = ?e, "failed to fetch Stripe balance for payout summary");
114 None
115 }
116 }
117 }
118 _ => None,
119 };
120
121 Ok(Response::fragment(
122 REGION,
123 card(balance.as_ref(), user.stripe_payouts_enabled),
124 ))
125 }
126
127 /// Everything inside the card.
128 fn card(balance: Option<&BalanceView>, payouts_enabled: bool) -> Node {
129 // `Subsection`, so the heading stays an `h3`: this card sits inside the
130 // Payments tab's own sections and the template wrote `<h3 class="card-h">`.
131 let slot = Slot::new(REGION, RegionKind::Pane)
132 .with(Node::Heading {
133 level: layout::Heading::Subsection,
134 text: "Payout Summary".into(),
135 })
136 .with(stripe_link());
137
138 let Some(balance) = balance else {
139 return Node::Region(slot.with(Node::failed(
140 "Unable to load balance. Check your Stripe dashboard for details.",
141 )));
142 };
143
144 let slot = slot
145 .with(Node::text(
146 "Payouts are processed automatically via Stripe Connect.",
147 ))
148 .with(Node::stats([
149 Figure::new(balance.available.clone(), "Available Balance"),
150 Figure::new(balance.pending.clone(), "Pending"),
151 ]));
152
153 Node::Region(if payouts_enabled {
154 slot
155 } else {
156 slot.with(Node::banner(
157 layout::Tone::Warning,
158 "Payouts are not yet enabled. Complete your Stripe account setup above.",
159 ))
160 })
161 }
162
163 /// The way out to Stripe's own dashboard.
164 ///
165 /// [`Action::external`] rather than a route: this address is not one the server
166 /// answers, and saying so is what lets a renderer that is not a browser decide
167 /// what "leaving" means for it.
168 fn stripe_link() -> Node {
169 Node::act("View in Stripe", Action::external(STRIPE_PAYOUTS))
170 }
171
172 /// The renderer this screen is drawn with.
173 pub fn renderer(viewer: &Viewer) -> Webview {
174 Webview::new().with_shell(viewer.shell())
175 }
176
177 #[cfg(test)]
178 mod tests {
179 use super::*;
180 use quasi_axum::Serves;
181
182 fn balance() -> BalanceView {
183 BalanceView {
184 available: "$1,240.00".into(),
185 pending: "$310.50".into(),
186 }
187 }
188
189 fn render(node: &Node) -> String {
190 Webview::new().fragment(node)
191 }
192
193 #[test]
194 fn the_region_is_the_one_the_payments_tab_leaves_empty() {
195 // The tab fills this on `revealed`. If the id or the address ever
196 // disagrees, the card loads into nothing and nothing else notices.
197 let payments = include_str!("../../templates/partials/tabs/user_payments.html");
198 assert!(payments.contains(&format!("id=\"{REGION}\"")), "{REGION}");
199 assert!(payments.contains(&format!("hx-get=\"{PATH}\"")), "{PATH}");
200 }
201
202 #[test]
203 fn the_balances_are_a_strip_of_two_figures() {
204 let html = render(&card(Some(&balance()), true));
205
206 assert!(html.contains("$1,240.00"), "{html}");
207 assert!(html.contains("Available Balance"), "{html}");
208 assert!(html.contains("$310.50"), "{html}");
209 assert!(html.contains("Pending"), "{html}");
210 }
211
212 #[test]
213 fn a_creator_without_payouts_enabled_is_told_so() {
214 let html = render(&card(Some(&balance()), false));
215 assert!(html.contains("Payouts are not yet enabled."), "{html}");
216 }
217
218 #[test]
219 fn a_creator_with_payouts_enabled_is_not() {
220 let html = render(&card(Some(&balance()), true));
221 assert!(!html.contains("Payouts are not yet enabled."), "{html}");
222 }
223
224 #[test]
225 fn a_balance_that_did_not_load_is_a_readiness_and_not_a_second_card() {
226 let html = render(&card(None, true));
227
228 assert!(html.contains("Unable to load balance."), "{html}");
229 // The figures are the thing that is missing. A strip of two blanks
230 // would be the template's failure mode, not this one's.
231 assert!(!html.contains("Available Balance"), "{html}");
232 // And the way out to Stripe survives the failure, which is the whole
233 // reason the template drew a second card rather than nothing.
234 assert!(html.contains(STRIPE_PAYOUTS), "{html}");
235 }
236
237 #[test]
238 fn the_stripe_link_leaves_and_carries_no_verb() {
239 let html = render(&stripe_link());
240
241 assert!(
242 html.contains(&format!("href=\"{STRIPE_PAYOUTS}\"")),
243 "{html}"
244 );
245 assert!(html.contains("target=\"_blank\""), "{html}");
246 assert!(html.contains("rel=\"noopener noreferrer\""), "{html}");
247 // An external address is not htmx's business.
248 assert!(!html.contains("hx-get"), "{html}");
249 }
250 }
251